feat: Claude Code Monitor — lanes, pipelines and a merged workspace
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Downloads official Prometheus + Grafana OSS binaries into monitoring/.bin/.
|
||||
* Supports macOS (arm64/Intel), Linux (arm64/amd64), and Windows (x64).
|
||||
* Run via `npm run monitoring:setup` — no Homebrew, apt, or global install needed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
BIN_ROOT,
|
||||
DATA_ROOT,
|
||||
VERSIONS,
|
||||
SUPPORTED_TARGETS,
|
||||
prometheusUrl,
|
||||
grafanaUrl,
|
||||
prometheusArchiveName,
|
||||
grafanaArchiveName,
|
||||
prometheusArchiveExt,
|
||||
grafanaArchiveExt,
|
||||
prometheusHome,
|
||||
grafanaHome,
|
||||
prometheusBinary,
|
||||
grafanaBinary,
|
||||
prometheusPlatform,
|
||||
binariesReady,
|
||||
} = require("./paths");
|
||||
const {
|
||||
downloadFile,
|
||||
extractArchive,
|
||||
findExtractedRoot,
|
||||
replaceDir,
|
||||
prometheusContainsFile,
|
||||
grafanaContainsFile,
|
||||
} = require("./install-utils");
|
||||
|
||||
async function installPrometheus(tmpDir) {
|
||||
const archive = path.join(tmpDir, `${prometheusArchiveName()}.${prometheusArchiveExt()}`);
|
||||
const extractRoot = path.join(tmpDir, "prometheus-extract");
|
||||
console.log(`→ Prometheus ${VERSIONS.prometheus} (${prometheusPlatform()})`);
|
||||
await downloadFile(prometheusUrl(), archive);
|
||||
await fs.promises.rm(extractRoot, { recursive: true, force: true });
|
||||
await extractArchive(archive, extractRoot);
|
||||
const extracted = await findExtractedRoot(
|
||||
extractRoot,
|
||||
[prometheusArchiveName(), `prometheus-${VERSIONS.prometheus}`],
|
||||
prometheusContainsFile
|
||||
);
|
||||
await replaceDir(prometheusHome(), extracted);
|
||||
}
|
||||
|
||||
async function installGrafana(tmpDir) {
|
||||
const archive = path.join(tmpDir, `${grafanaArchiveName()}.${grafanaArchiveExt()}`);
|
||||
const extractRoot = path.join(tmpDir, "grafana-extract");
|
||||
console.log(`→ Grafana OSS ${VERSIONS.grafana} (${prometheusPlatform()})`);
|
||||
await downloadFile(grafanaUrl(), archive);
|
||||
await fs.promises.rm(extractRoot, { recursive: true, force: true });
|
||||
await extractArchive(archive, extractRoot);
|
||||
const extracted = await findExtractedRoot(
|
||||
extractRoot,
|
||||
[grafanaArchiveName(), `grafana-${VERSIONS.grafana}`, `grafana-v${VERSIONS.grafana}`],
|
||||
grafanaContainsFile
|
||||
);
|
||||
await replaceDir(grafanaHome(), extracted);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (binariesReady()) {
|
||||
console.log("Monitoring binaries already present:");
|
||||
console.log(` Prometheus: ${prometheusBinary()}`);
|
||||
console.log(` Grafana: ${grafanaBinary()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Platform: ${process.platform} ${process.arch}`);
|
||||
console.log(`Supported: ${SUPPORTED_TARGETS.join(" · ")}`);
|
||||
|
||||
await fs.promises.mkdir(BIN_ROOT, { recursive: true });
|
||||
await fs.promises.mkdir(DATA_ROOT, { recursive: true });
|
||||
|
||||
const tmpDir = path.join(DATA_ROOT, "downloads");
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||
|
||||
console.log("Downloading CCAM monitoring binaries (one-time setup)…");
|
||||
await installPrometheus(tmpDir);
|
||||
await installGrafana(tmpDir);
|
||||
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
||||
|
||||
if (!binariesReady()) {
|
||||
throw new Error("Binary install finished but executables were not found.");
|
||||
}
|
||||
|
||||
console.log("Done. Binaries installed to monitoring/.bin/");
|
||||
console.log(` Prometheus: ${prometheusBinary()}`);
|
||||
console.log(` Grafana: ${grafanaBinary()}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`monitoring:setup failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @file Archive extraction and install-dir discovery for cross-platform binaries.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { pipeline } = require("node:stream/promises");
|
||||
const { Readable } = require("node:stream");
|
||||
const tar = require("tar");
|
||||
const AdmZip = require("adm-zip");
|
||||
const { isWindows } = require("./paths");
|
||||
|
||||
async function downloadFile(url, dest) {
|
||||
const response = await fetch(url, { redirect: "follow" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download failed (${response.status}): ${url}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error(`Empty response body: ${url}`);
|
||||
}
|
||||
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
|
||||
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(dest));
|
||||
}
|
||||
|
||||
async function extractTar(archivePath, destDir) {
|
||||
await fs.promises.mkdir(destDir, { recursive: true });
|
||||
await tar.x({ file: archivePath, cwd: destDir });
|
||||
}
|
||||
|
||||
async function extractZip(archivePath, destDir) {
|
||||
await fs.promises.mkdir(destDir, { recursive: true });
|
||||
const zip = new AdmZip(archivePath);
|
||||
zip.extractAllTo(destDir, true);
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath, destDir) {
|
||||
if (archivePath.endsWith(".zip")) {
|
||||
await extractZip(archivePath, destDir);
|
||||
return;
|
||||
}
|
||||
await extractTar(archivePath, destDir);
|
||||
}
|
||||
|
||||
async function listChildDirs(parent) {
|
||||
const entries = await fs.promises.readdir(parent, { withFileTypes: true });
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => path.join(parent, e.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate a single top-level directory after extraction. Official archives ship
|
||||
* one root folder; this also tolerates minor naming differences across OS builds.
|
||||
*/
|
||||
async function findExtractedRoot(extractRoot, preferredNames, containsFile) {
|
||||
for (const name of preferredNames) {
|
||||
const candidate = path.join(extractRoot, name);
|
||||
if (fs.existsSync(path.join(candidate, containsFile))) return candidate;
|
||||
}
|
||||
|
||||
const dirs = await listChildDirs(extractRoot);
|
||||
const matches = [];
|
||||
for (const dir of dirs) {
|
||||
if (fs.existsSync(path.join(dir, containsFile))) matches.push(dir);
|
||||
}
|
||||
if (matches.length === 1) return matches[0];
|
||||
if (matches.length > 1) {
|
||||
throw new Error(
|
||||
`Multiple install directories matched in ${extractRoot}: ${matches.map(path.basename).join(", ")}`
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Could not find install directory under ${extractRoot} (expected ${containsFile}).`
|
||||
);
|
||||
}
|
||||
|
||||
async function replaceDir(target, source) {
|
||||
await fs.promises.rm(target, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.promises.cp(source, target, { recursive: true });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
downloadFile,
|
||||
extractArchive,
|
||||
findExtractedRoot,
|
||||
replaceDir,
|
||||
prometheusContainsFile: isWindows() ? "prometheus.exe" : "prometheus",
|
||||
grafanaContainsFile: path.join("bin", isWindows() ? "grafana.exe" : "grafana"),
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* @file Shared helpers for starting and stopping the npm-managed monitoring stack.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const {
|
||||
DATA_ROOT,
|
||||
RUNTIME_PROVISIONING,
|
||||
GRAFANA_DASHBOARDS,
|
||||
GRAFANA_DATASOURCE_TEMPLATE,
|
||||
PROMETHEUS_PID,
|
||||
GRAFANA_PID,
|
||||
PROMETHEUS_LOG,
|
||||
GRAFANA_LOG,
|
||||
isWindows,
|
||||
toGrafanaPath,
|
||||
} = require("./paths");
|
||||
|
||||
function readPid(file) {
|
||||
try {
|
||||
const raw = fs.readFileSync(file, "utf8").trim();
|
||||
const pid = Number(raw);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRunning(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePid(file, pid) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, String(pid));
|
||||
}
|
||||
|
||||
function removePid(file) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
}
|
||||
|
||||
function killProcess(pid) {
|
||||
if (!pid) return;
|
||||
if (isWindows()) {
|
||||
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
|
||||
return;
|
||||
}
|
||||
process.kill(pid, "SIGTERM");
|
||||
}
|
||||
|
||||
function stopPid(file, label) {
|
||||
const pid = readPid(file);
|
||||
if (!pid) return false;
|
||||
if (!isRunning(pid)) {
|
||||
removePid(file);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
killProcess(pid);
|
||||
console.log(`Stopped ${label} (pid ${pid})`);
|
||||
} catch (err) {
|
||||
console.warn(`Could not stop ${label} (pid ${pid}): ${err.message}`);
|
||||
}
|
||||
removePid(file);
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeGrafanaProvisioning() {
|
||||
const dashboardsDir = path.join(RUNTIME_PROVISIONING, "dashboards");
|
||||
const datasourcesDir = path.join(RUNTIME_PROVISIONING, "datasources");
|
||||
fs.mkdirSync(dashboardsDir, { recursive: true });
|
||||
fs.mkdirSync(datasourcesDir, { recursive: true });
|
||||
|
||||
fs.copyFileSync(GRAFANA_DATASOURCE_TEMPLATE, path.join(datasourcesDir, "datasource.yml"));
|
||||
|
||||
const provider = [
|
||||
"apiVersion: 1",
|
||||
"",
|
||||
"providers:",
|
||||
" - name: CCAM",
|
||||
" orgId: 1",
|
||||
" type: file",
|
||||
" disableDeletion: false",
|
||||
" updateIntervalSeconds: 30",
|
||||
" allowUiUpdates: true",
|
||||
" options:",
|
||||
` path: ${toGrafanaPath(GRAFANA_DASHBOARDS)}`,
|
||||
" foldersFromFilesStructure: false",
|
||||
"",
|
||||
].join("\n");
|
||||
fs.writeFileSync(path.join(dashboardsDir, "provider.yml"), provider);
|
||||
}
|
||||
|
||||
function spawnDetached(binary, args, env, logFile) {
|
||||
fs.mkdirSync(path.dirname(logFile), { recursive: true });
|
||||
const logFd = fs.openSync(logFile, "a");
|
||||
const child = spawn(binary, args, {
|
||||
detached: !isWindows(),
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env: { ...process.env, ...env },
|
||||
windowsHide: true,
|
||||
});
|
||||
if (!isWindows()) child.unref();
|
||||
fs.closeSync(logFd);
|
||||
return child.pid;
|
||||
}
|
||||
|
||||
function spawnForeground(binary, args, env) {
|
||||
return spawn(binary, args, {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, ...env },
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHttp(url, attempts = 30, intervalMs = 500) {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (res.ok) return true;
|
||||
} catch {
|
||||
/* retry */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readPid,
|
||||
isRunning,
|
||||
writePid,
|
||||
removePid,
|
||||
killProcess,
|
||||
stopPid,
|
||||
writeGrafanaProvisioning,
|
||||
spawnDetached,
|
||||
spawnForeground,
|
||||
waitForHttp,
|
||||
PROMETHEUS_PID,
|
||||
GRAFANA_PID,
|
||||
PROMETHEUS_LOG,
|
||||
GRAFANA_LOG,
|
||||
DATA_ROOT,
|
||||
RUNTIME_PROVISIONING,
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* @file Shared paths and version pins for the CCAM monitoring stack.
|
||||
* Binaries are downloaded into monitoring/.bin/ by ensure-binaries.js so the
|
||||
* stack runs with plain npm on macOS, Linux, and Windows — no Homebrew, apt,
|
||||
* or global installs required.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const path = require("node:path");
|
||||
|
||||
const MONITORING_ROOT = path.resolve(__dirname, "..");
|
||||
const REPO_ROOT = path.resolve(MONITORING_ROOT, "..");
|
||||
|
||||
const VERSIONS = {
|
||||
prometheus: "3.2.1",
|
||||
grafana: "11.6.1",
|
||||
};
|
||||
|
||||
/** Human-readable list for error messages. */
|
||||
const SUPPORTED_TARGETS = [
|
||||
"macOS (Apple Silicon / Intel)",
|
||||
"Linux (arm64 / amd64)",
|
||||
"Windows (x64)",
|
||||
];
|
||||
|
||||
const BIN_ROOT = path.join(MONITORING_ROOT, ".bin");
|
||||
const DATA_ROOT = path.join(MONITORING_ROOT, ".data");
|
||||
const PROMETHEUS_DATA = path.join(DATA_ROOT, "prometheus");
|
||||
const GRAFANA_DATA = path.join(DATA_ROOT, "grafana");
|
||||
const RUNTIME_PROVISIONING = path.join(DATA_ROOT, "grafana-provisioning");
|
||||
|
||||
const PROMETHEUS_CONFIG = path.join(MONITORING_ROOT, "prometheus", "prometheus-native.yml");
|
||||
const PROMETHEUS_CONSOLES = path.join(MONITORING_ROOT, "prometheus", "consoles");
|
||||
const GRAFANA_DASHBOARDS = path.join(MONITORING_ROOT, "grafana", "dashboards");
|
||||
const GRAFANA_DATASOURCE_TEMPLATE = path.join(
|
||||
MONITORING_ROOT,
|
||||
"grafana",
|
||||
"provisioning-native",
|
||||
"datasources",
|
||||
"datasource.yml"
|
||||
);
|
||||
|
||||
const PROMETHEUS_PID = path.join(DATA_ROOT, "prometheus.pid");
|
||||
const GRAFANA_PID = path.join(DATA_ROOT, "grafana.pid");
|
||||
const PROMETHEUS_LOG = path.join(DATA_ROOT, "prometheus.log");
|
||||
const GRAFANA_LOG = path.join(DATA_ROOT, "grafana.log");
|
||||
|
||||
function isWindows() {
|
||||
return process.platform === "win32";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Node's `process.arch` to Prometheus/Grafana release asset arch slugs.
|
||||
* @returns {"arm64"|"amd64"}
|
||||
*/
|
||||
function releaseArch() {
|
||||
if (process.arch === "arm64") return "arm64";
|
||||
if (process.arch === "x64") return "amd64";
|
||||
throw new Error(
|
||||
`Unsupported CPU architecture "${process.arch}". Supported: ${SUPPORTED_TARGETS.join(", ")}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {string} Prometheus platform slug used in release asset names. */
|
||||
function prometheusPlatform() {
|
||||
const arch = releaseArch();
|
||||
if (process.platform === "darwin") return arch === "arm64" ? "darwin-arm64" : "darwin-amd64";
|
||||
if (process.platform === "linux") return arch === "arm64" ? "linux-arm64" : "linux-amd64";
|
||||
if (process.platform === "win32") return "windows-amd64";
|
||||
throw new Error(
|
||||
`Unsupported OS "${process.platform}". Supported: ${SUPPORTED_TARGETS.join(", ")}.`
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {string} Grafana platform slug used in release asset names. */
|
||||
function grafanaPlatform() {
|
||||
return prometheusPlatform();
|
||||
}
|
||||
|
||||
function prometheusArchiveName() {
|
||||
return `prometheus-${VERSIONS.prometheus}.${prometheusPlatform()}`;
|
||||
}
|
||||
|
||||
function grafanaArchiveName() {
|
||||
return `grafana-${VERSIONS.grafana}.${grafanaPlatform()}`;
|
||||
}
|
||||
|
||||
function prometheusArchiveExt() {
|
||||
return isWindows() ? "zip" : "tar.gz";
|
||||
}
|
||||
|
||||
function grafanaArchiveExt() {
|
||||
return isWindows() ? "zip" : "tar.gz";
|
||||
}
|
||||
|
||||
function prometheusUrl() {
|
||||
const name = prometheusArchiveName();
|
||||
return `https://github.com/prometheus/prometheus/releases/download/v${VERSIONS.prometheus}/${name}.${prometheusArchiveExt()}`;
|
||||
}
|
||||
|
||||
function grafanaUrl() {
|
||||
const plat = grafanaPlatform();
|
||||
return `https://dl.grafana.com/oss/release/grafana-${VERSIONS.grafana}.${plat}.${grafanaArchiveExt()}`;
|
||||
}
|
||||
|
||||
function prometheusHome() {
|
||||
return path.join(BIN_ROOT, "prometheus");
|
||||
}
|
||||
|
||||
function grafanaHome() {
|
||||
return path.join(BIN_ROOT, "grafana");
|
||||
}
|
||||
|
||||
function prometheusBinary() {
|
||||
const name = isWindows() ? "prometheus.exe" : "prometheus";
|
||||
return path.join(prometheusHome(), name);
|
||||
}
|
||||
|
||||
function grafanaBinary() {
|
||||
const name = isWindows() ? "grafana.exe" : "grafana";
|
||||
return path.join(grafanaHome(), "bin", name);
|
||||
}
|
||||
|
||||
const GRAFANA_ADMIN_USER = "admin";
|
||||
const GRAFANA_ADMIN_PASSWORD = "admin";
|
||||
|
||||
/** Grafana env vars that seed the default admin account on first start. */
|
||||
function grafanaAdminEnv() {
|
||||
return {
|
||||
GF_SECURITY_ADMIN_USER: GRAFANA_ADMIN_USER,
|
||||
GF_SECURITY_ADMIN_PASSWORD: GRAFANA_ADMIN_PASSWORD,
|
||||
GF_USERS_ALLOW_SIGN_UP: "false",
|
||||
GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_UID: "ccam-overview",
|
||||
};
|
||||
}
|
||||
|
||||
function grafanaLoginLabel() {
|
||||
return `${GRAFANA_ADMIN_USER} / ${GRAFANA_ADMIN_PASSWORD}`;
|
||||
}
|
||||
|
||||
/** Grafana file providers require forward slashes even on Windows. */
|
||||
function toGrafanaPath(filePath) {
|
||||
return filePath.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/** CLI args for Prometheus 3.x (console libraries removed upstream). */
|
||||
function prometheusServerArgs(configFile, storagePath = PROMETHEUS_DATA) {
|
||||
return [
|
||||
`--config.file=${configFile}`,
|
||||
`--storage.tsdb.path=${storagePath}`,
|
||||
"--web.enable-lifecycle",
|
||||
`--web.console.templates=${PROMETHEUS_CONSOLES}`,
|
||||
];
|
||||
}
|
||||
|
||||
function prometheusDockerServerArgs(configFile) {
|
||||
return [
|
||||
`--config.file=${configFile}`,
|
||||
`--storage.tsdb.path=/prometheus`,
|
||||
"--web.enable-lifecycle",
|
||||
`--web.console.templates=/etc/prometheus/consoles`,
|
||||
];
|
||||
}
|
||||
|
||||
const PROMETHEUS_CONSOLES_URL = "http://localhost:9090/consoles/index.html";
|
||||
|
||||
function binariesReady() {
|
||||
const fs = require("node:fs");
|
||||
return fs.existsSync(prometheusBinary()) && fs.existsSync(grafanaBinary());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MONITORING_ROOT,
|
||||
REPO_ROOT,
|
||||
VERSIONS,
|
||||
SUPPORTED_TARGETS,
|
||||
BIN_ROOT,
|
||||
DATA_ROOT,
|
||||
PROMETHEUS_DATA,
|
||||
GRAFANA_DATA,
|
||||
RUNTIME_PROVISIONING,
|
||||
PROMETHEUS_CONFIG,
|
||||
PROMETHEUS_CONSOLES,
|
||||
PROMETHEUS_CONSOLES_URL,
|
||||
GRAFANA_DASHBOARDS,
|
||||
GRAFANA_DATASOURCE_TEMPLATE,
|
||||
PROMETHEUS_PID,
|
||||
GRAFANA_PID,
|
||||
PROMETHEUS_LOG,
|
||||
GRAFANA_LOG,
|
||||
isWindows,
|
||||
releaseArch,
|
||||
prometheusPlatform,
|
||||
grafanaPlatform,
|
||||
prometheusArchiveName,
|
||||
grafanaArchiveName,
|
||||
prometheusArchiveExt,
|
||||
grafanaArchiveExt,
|
||||
prometheusUrl,
|
||||
grafanaUrl,
|
||||
prometheusHome,
|
||||
grafanaHome,
|
||||
prometheusBinary,
|
||||
grafanaBinary,
|
||||
prometheusServerArgs,
|
||||
prometheusDockerServerArgs,
|
||||
GRAFANA_ADMIN_USER,
|
||||
GRAFANA_ADMIN_PASSWORD,
|
||||
grafanaAdminEnv,
|
||||
grafanaLoginLabel,
|
||||
toGrafanaPath,
|
||||
binariesReady,
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Starts the npm-managed Prometheus + Grafana stack (no Docker / no Brew).
|
||||
* Downloads binaries on first run if monitoring:setup has not been run yet.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const fs = require("node:fs");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
PROMETHEUS_CONFIG,
|
||||
PROMETHEUS_DATA,
|
||||
GRAFANA_DATA,
|
||||
RUNTIME_PROVISIONING,
|
||||
prometheusBinary,
|
||||
prometheusServerArgs,
|
||||
PROMETHEUS_CONSOLES_URL,
|
||||
grafanaBinary,
|
||||
grafanaHome,
|
||||
grafanaAdminEnv,
|
||||
grafanaLoginLabel,
|
||||
binariesReady,
|
||||
} = require("./paths");
|
||||
const {
|
||||
readPid,
|
||||
isRunning,
|
||||
writePid,
|
||||
writeGrafanaProvisioning,
|
||||
spawnDetached,
|
||||
spawnForeground,
|
||||
waitForHttp,
|
||||
killProcess,
|
||||
PROMETHEUS_PID,
|
||||
GRAFANA_PID,
|
||||
PROMETHEUS_LOG,
|
||||
GRAFANA_LOG,
|
||||
} = require("./lib");
|
||||
|
||||
const detached = process.argv.includes("--detach") || process.argv.includes("-d");
|
||||
const foreground = process.argv.includes("--foreground") || process.argv.includes("-f");
|
||||
|
||||
async function ensureBinaries() {
|
||||
if (binariesReady()) return;
|
||||
console.log("Monitoring binaries not found — running setup…");
|
||||
const script = path.join(__dirname, "ensure-binaries.js");
|
||||
const result = spawnSync(process.execPath, [script], { stdio: "inherit" });
|
||||
if (result.status !== 0) process.exit(result.status || 1);
|
||||
}
|
||||
|
||||
function assertNotRunning() {
|
||||
for (const [file, label] of [
|
||||
[PROMETHEUS_PID, "Prometheus"],
|
||||
[GRAFANA_PID, "Grafana"],
|
||||
]) {
|
||||
const pid = readPid(file);
|
||||
if (isRunning(pid)) {
|
||||
console.error(`${label} is already running (pid ${pid}). Run: npm run monitoring:down`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPrometheus() {
|
||||
fs.mkdirSync(PROMETHEUS_DATA, { recursive: true });
|
||||
const args = prometheusServerArgs(PROMETHEUS_CONFIG);
|
||||
const binary = prometheusBinary();
|
||||
if (detached || (!foreground && !process.stdout.isTTY)) {
|
||||
const pid = spawnDetached(binary, args, {}, PROMETHEUS_LOG);
|
||||
writePid(PROMETHEUS_PID, pid);
|
||||
return pid;
|
||||
}
|
||||
return spawnForeground(binary, args, {});
|
||||
}
|
||||
|
||||
function startGrafana() {
|
||||
fs.mkdirSync(GRAFANA_DATA, { recursive: true });
|
||||
writeGrafanaProvisioning();
|
||||
const env = {
|
||||
GF_PATHS_HOME: grafanaHome(),
|
||||
GF_PATHS_DATA: GRAFANA_DATA,
|
||||
GF_PATHS_PROVISIONING: RUNTIME_PROVISIONING,
|
||||
...grafanaAdminEnv(),
|
||||
};
|
||||
const args = ["server", "--homepath", grafanaHome()];
|
||||
const binary = grafanaBinary();
|
||||
if (detached || (!foreground && !process.stdout.isTTY)) {
|
||||
const pid = spawnDetached(binary, args, env, GRAFANA_LOG);
|
||||
writePid(GRAFANA_PID, pid);
|
||||
return pid;
|
||||
}
|
||||
return spawnForeground(binary, args, env);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureBinaries();
|
||||
assertNotRunning();
|
||||
|
||||
console.log("Starting CCAM monitoring stack…");
|
||||
console.log(` Prometheus config: ${PROMETHEUS_CONFIG}`);
|
||||
console.log(` Grafana home: ${grafanaHome()}`);
|
||||
|
||||
if (detached || (!foreground && !process.stdout.isTTY)) {
|
||||
const promPid = startPrometheus();
|
||||
const grafPid = startGrafana();
|
||||
const promOk = await waitForHttp("http://127.0.0.1:9090/-/ready");
|
||||
const grafOk = await waitForHttp("http://127.0.0.1:3000/api/health");
|
||||
console.log("");
|
||||
console.log(
|
||||
`Prometheus http://localhost:9090 (pid ${promPid})${promOk ? "" : " [still starting]"}`
|
||||
);
|
||||
console.log(` CCAM console: ${PROMETHEUS_CONSOLES_URL}`);
|
||||
console.log(
|
||||
`Grafana http://localhost:3000 (pid ${grafPid}) ${grafanaLoginLabel()}${grafOk ? "" : " [still starting]"}`
|
||||
);
|
||||
console.log(
|
||||
"CCAM dashboards auto-provisioned (Overview, Sessions & Agents, Tokens & Events, Platform Health)."
|
||||
);
|
||||
console.log("Stop with: npm run monitoring:down");
|
||||
if (!promOk || !grafOk) {
|
||||
console.log(`Logs: ${PROMETHEUS_LOG} ${GRAFANA_LOG}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Foreground: Prometheus in background, Grafana in foreground (Grafana owns the tty).
|
||||
const promPid = spawnDetached(
|
||||
prometheusBinary(),
|
||||
prometheusServerArgs(PROMETHEUS_CONFIG),
|
||||
{},
|
||||
PROMETHEUS_LOG
|
||||
);
|
||||
writePid(PROMETHEUS_PID, promPid);
|
||||
console.log(`Prometheus running at http://localhost:9090 (pid ${promPid})`);
|
||||
console.log(` CCAM console: ${PROMETHEUS_CONSOLES_URL}`);
|
||||
console.log(
|
||||
`Grafana starting at http://localhost:3000 (${grafanaLoginLabel()}) — Ctrl+C stops both`
|
||||
);
|
||||
const graf = startGrafana();
|
||||
const shutdown = () => {
|
||||
killProcess(promPid);
|
||||
process.exit(0);
|
||||
};
|
||||
process.on("SIGINT", shutdown);
|
||||
process.on("SIGTERM", shutdown);
|
||||
graf.on("exit", (code) => {
|
||||
shutdown();
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`monitoring:start failed: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Stops the npm-managed Prometheus + Grafana stack started by start.js.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { stopPid, PROMETHEUS_PID, GRAFANA_PID } = require("./lib");
|
||||
|
||||
const stoppedGrafana = stopPid(GRAFANA_PID, "Grafana");
|
||||
const stoppedPrometheus = stopPid(PROMETHEUS_PID, "Prometheus");
|
||||
|
||||
if (!stoppedGrafana && !stoppedPrometheus) {
|
||||
console.log("Monitoring stack is not running.");
|
||||
} else {
|
||||
console.log("Monitoring stack stopped.");
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file Verifies the CCAM dashboard and optional Prometheus/Grafana stack are up.
|
||||
* Used after `monitoring:up` or `monitoring:docker:up` to confirm scrape health.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
const { GRAFANA_ADMIN_USER, GRAFANA_ADMIN_PASSWORD } = require("./paths");
|
||||
|
||||
const DASHBOARD_URL = process.env.CCAM_DASHBOARD_URL || "http://127.0.0.1:4820";
|
||||
const PROMETHEUS_URL = process.env.CCAM_PROMETHEUS_URL || "http://127.0.0.1:9090";
|
||||
const GRAFANA_URL = process.env.CCAM_GRAFANA_URL || "http://127.0.0.1:3000";
|
||||
const JSON_MODE = process.argv.includes("--json");
|
||||
|
||||
async function checkDashboardHealth() {
|
||||
try {
|
||||
const res = await fetch(`${DASHBOARD_URL}/api/health`);
|
||||
if (!res.ok) {
|
||||
return { name: "Dashboard /api/health", ok: false, detail: `HTTP ${res.status}` };
|
||||
}
|
||||
const body = await res.json();
|
||||
const detail = body.version ? `v${body.version}` : undefined;
|
||||
return { name: "Dashboard /api/health", ok: true, detail, version: body.version };
|
||||
} catch (err) {
|
||||
return { name: "Dashboard /api/health", ok: false, detail: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function check(name, url, ok = (res) => res.ok) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!ok(res)) {
|
||||
return { name, ok: false, detail: `HTTP ${res.status}` };
|
||||
}
|
||||
return { name, ok: true };
|
||||
} catch (err) {
|
||||
return { name, ok: false, detail: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPrometheusTarget(attempts = 12, intervalMs = 2500) {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
const result = await checkPrometheusTargetOnce();
|
||||
if (result.ok) return result;
|
||||
if (i < attempts - 1) await new Promise((r) => setTimeout(r, intervalMs));
|
||||
if (i === attempts - 1) return result;
|
||||
}
|
||||
return { name: "Prometheus ccam target", ok: false, detail: "timeout" };
|
||||
}
|
||||
|
||||
async function checkPrometheusTargetOnce() {
|
||||
try {
|
||||
const res = await fetch(`${PROMETHEUS_URL}/api/v1/targets`);
|
||||
if (!res.ok) return { name: "Prometheus ccam target", ok: false, detail: `HTTP ${res.status}` };
|
||||
const body = await res.json();
|
||||
const target = body?.data?.activeTargets?.find((t) => t.labels?.job === "ccam");
|
||||
if (!target) return { name: "Prometheus ccam target", ok: false, detail: "job not found" };
|
||||
if (target.health !== "up") {
|
||||
return {
|
||||
name: "Prometheus ccam target",
|
||||
ok: false,
|
||||
detail: target.lastError || `health=${target.health}`,
|
||||
};
|
||||
}
|
||||
return { name: "Prometheus ccam target", ok: true, detail: target.scrapeUrl };
|
||||
} catch (err) {
|
||||
return { name: "Prometheus ccam target", ok: false, detail: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkGrafanaLogin() {
|
||||
try {
|
||||
const res = await fetch(`${GRAFANA_URL}/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user: GRAFANA_ADMIN_USER,
|
||||
password: GRAFANA_ADMIN_PASSWORD,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { name: "Grafana admin login", ok: false, detail: `HTTP ${res.status}` };
|
||||
}
|
||||
const body = await res.json();
|
||||
if (body?.message !== "Logged in") {
|
||||
return { name: "Grafana admin login", ok: false, detail: body?.message || "login rejected" };
|
||||
}
|
||||
return { name: "Grafana admin login", ok: true, detail: grafanaLoginDetail() };
|
||||
} catch (err) {
|
||||
return { name: "Grafana admin login", ok: false, detail: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
function grafanaLoginDetail() {
|
||||
return `${GRAFANA_ADMIN_USER} / ${GRAFANA_ADMIN_PASSWORD}`;
|
||||
}
|
||||
|
||||
async function checkPrometheusMetrics() {
|
||||
try {
|
||||
const queries = [
|
||||
{ name: "Prometheus ccam_up", q: "ccam_up" },
|
||||
{ name: "Prometheus total sessions", q: "sum(ccam_sessions)" },
|
||||
];
|
||||
const results = [];
|
||||
for (const { name, q } of queries) {
|
||||
const res = await fetch(
|
||||
`${PROMETHEUS_URL}/api/v1/query?${new URLSearchParams({ query: q })}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
results.push({ name, ok: false, detail: `HTTP ${res.status}` });
|
||||
continue;
|
||||
}
|
||||
const body = await res.json();
|
||||
const series = body?.data?.result;
|
||||
if (!Array.isArray(series) || series.length === 0) {
|
||||
results.push({ name, ok: false, detail: "no series (is CCAM scraping?)" });
|
||||
continue;
|
||||
}
|
||||
const sample = series[0]?.value?.[1];
|
||||
results.push({ name, ok: true, detail: `${q} = ${sample}` });
|
||||
}
|
||||
return results;
|
||||
} catch (err) {
|
||||
return [{ name: "Prometheus ccam metrics", ok: false, detail: err.message }];
|
||||
}
|
||||
}
|
||||
|
||||
async function checkPrometheusConsole() {
|
||||
try {
|
||||
const res = await fetch(`${PROMETHEUS_URL}/consoles/index.html`);
|
||||
if (!res.ok) {
|
||||
return { name: "Prometheus CCAM console", ok: false, detail: `HTTP ${res.status}` };
|
||||
}
|
||||
const html = await res.text();
|
||||
if (!html.includes("CCAM")) {
|
||||
return { name: "Prometheus CCAM console", ok: false, detail: "unexpected page body" };
|
||||
}
|
||||
return { name: "Prometheus CCAM console", ok: true, detail: "/consoles/index.html" };
|
||||
} catch (err) {
|
||||
return { name: "Prometheus CCAM console", ok: false, detail: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const checks = [
|
||||
await checkDashboardHealth(),
|
||||
await check(
|
||||
"Dashboard /api/metrics",
|
||||
`${DASHBOARD_URL}/api/metrics`,
|
||||
(res) => res.ok && res.headers.get("content-type")?.includes("text/plain")
|
||||
),
|
||||
await check("Prometheus /-/ready", `${PROMETHEUS_URL}/-/ready`),
|
||||
await checkPrometheusConsole(),
|
||||
await check("Grafana /api/health", `${GRAFANA_URL}/api/health`),
|
||||
await checkGrafanaLogin(),
|
||||
await checkPrometheusTarget(),
|
||||
...(await checkPrometheusMetrics()),
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
for (const c of checks) {
|
||||
if (c.ok) {
|
||||
if (!JSON_MODE) {
|
||||
console.log(`✔ ${c.name}${c.detail ? ` (${c.detail})` : ""}`);
|
||||
}
|
||||
} else {
|
||||
failed += 1;
|
||||
if (!JSON_MODE) {
|
||||
console.error(`✖ ${c.name}: ${c.detail || "failed"}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (JSON_MODE) {
|
||||
const payload = {
|
||||
ok: failed === 0,
|
||||
checks,
|
||||
urls: {
|
||||
dashboard: DASHBOARD_URL,
|
||||
prometheus: PROMETHEUS_URL,
|
||||
grafana: GRAFANA_URL,
|
||||
metrics: `${DASHBOARD_URL}/api/metrics`,
|
||||
},
|
||||
};
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`\n${failed} check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nMonitoring stack OK.");
|
||||
console.log(` Grafana: ${GRAFANA_URL} (${grafanaLoginDetail()})`);
|
||||
console.log(` Console: ${PROMETHEUS_URL}/consoles/index.html`);
|
||||
console.log(` Graph: ${PROMETHEUS_URL}/graph`);
|
||||
console.log(` Metrics: ${DASHBOARD_URL}/api/metrics`);
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user