From bb0ee435e8db9cc92baf453ed3d51c0e5bc2fc52 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 25 May 2026 16:40:04 -0500 Subject: [PATCH] feat: add cli/grafana_dashboard.php for Grafana dashboard management Four commands for dashboard lifecycle management via the Grafana API: - push: create/update dashboard from JSON file (with folder support) - delete: remove dashboard by UID - list: list dashboards (optionally filtered by folder) - export: download dashboard JSON by UID Supports GRAFANA_URL and GRAFANA_TOKEN env vars for CI integration. Closes #53 Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 1 + cli/grafana_dashboard.php | 444 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 cli/grafana_dashboard.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7f109..2781b16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Version format: `XX.YY.ZZ` (zero-padded semver). ### Added - `cli/bulk_workflow_push.php` — push a workflow file to all governed repos via Gitea Contents API (closes #52) +- `cli/grafana_dashboard.php` — manage Grafana dashboards: push, delete, list, export (closes #53) ### Fixed - `version_read.php` / `version_bump.php`: handle suffixed versions in XML manifests (e.g. `01.00.00-dev`) diff --git a/cli/grafana_dashboard.php b/cli/grafana_dashboard.php new file mode 100644 index 0000000..d67d0f0 --- /dev/null +++ b/cli/grafana_dashboard.php @@ -0,0 +1,444 @@ +#!/usr/bin/env php + + * + * This file is part of a Moko Consulting project. + * + * SPDX-License-Identifier: GPL-3.0-or-later + * + * FILE INFORMATION + * DEFGROUP: moko-platform.CLI + * INGROUP: moko-platform + * REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform + * PATH: /cli/grafana_dashboard.php + * VERSION: 01.00.00 + * BRIEF: Manage Grafana dashboards via API + */ + +declare(strict_types=1); + +final class GrafanaDashboard +{ + private string $grafanaUrl = ''; + private string $token = ''; + private string $command = ''; + private string $uid = ''; + private string $file = ''; + private int $folderId = 0; + private string $folderTitle = ''; + private bool $overwrite = true; + + public function run(): int + { + $this->parseArgs(); + + if ($this->grafanaUrl === '') { + $this->grafanaUrl = getenv('GRAFANA_URL') ?: ''; + } + + if ($this->token === '') { + $this->token = getenv('GRAFANA_TOKEN') ?: ''; + } + + if ($this->grafanaUrl === '' || $this->token === '') { + $this->log( + 'ERROR: --url and --token are required ' + . '(or set GRAFANA_URL / GRAFANA_TOKEN env vars).' + ); + $this->printUsage(); + return 1; + } + + return match ($this->command) { + 'push' => $this->pushDashboard(), + 'delete' => $this->deleteDashboard(), + 'list' => $this->listDashboards(), + 'export' => $this->exportDashboard(), + default => $this->noCommand(), + }; + } + + private function pushDashboard(): int + { + if ($this->file === '') { + $this->log('ERROR: --file is required for push.'); + return 1; + } + + if (!file_exists($this->file)) { + $this->log("ERROR: File not found: {$this->file}"); + return 1; + } + + $json = file_get_contents($this->file); + $dashboard = json_decode($json, true); + + if (!is_array($dashboard)) { + $this->log('ERROR: Invalid JSON in dashboard file.'); + return 1; + } + + if ($this->folderTitle !== '' && $this->folderId === 0) { + $this->folderId = $this->resolveFolderId( + $this->folderTitle + ); + + if ($this->folderId < 0) { + return 1; + } + } + + $dashboard['id'] = null; + + $payload = json_encode([ + 'dashboard' => $dashboard, + 'folderId' => $this->folderId, + 'overwrite' => $this->overwrite, + ]); + + $response = $this->apiRequest( + 'POST', + '/api/dashboards/db', + $payload + ); + + if ($response['code'] === 200) { + $data = json_decode($response['body'], true); + $uid = $data['uid'] ?? '?'; + $url = $data['url'] ?? ''; + $status = $data['status'] ?? 'success'; + $this->log("OK: {$status} (uid: {$uid})"); + + if ($url !== '') { + $this->log("URL: {$this->grafanaUrl}{$url}"); + } + + return 0; + } + + $this->log( + "ERROR: Push failed (HTTP {$response['code']})" + ); + $this->logApiError($response['body']); + + return 1; + } + + private function deleteDashboard(): int + { + if ($this->uid === '') { + $this->log('ERROR: --uid is required for delete.'); + return 1; + } + + $response = $this->apiRequest( + 'DELETE', + "/api/dashboards/uid/{$this->uid}" + ); + + if ($response['code'] === 200) { + $this->log("OK: Deleted dashboard {$this->uid}"); + return 0; + } + + if ($response['code'] === 404) { + $this->log( + "WARN: Dashboard {$this->uid} not found." + ); + return 0; + } + + $this->log( + "ERROR: Delete failed (HTTP {$response['code']})" + ); + $this->logApiError($response['body']); + + return 1; + } + + private function listDashboards(): int + { + $query = '/api/search?type=dash-db'; + + if ($this->folderId > 0) { + $query .= "&folderIds={$this->folderId}"; + } + + if ($this->folderTitle !== '' && $this->folderId === 0) { + $fid = $this->resolveFolderId($this->folderTitle); + + if ($fid > 0) { + $query .= "&folderIds={$fid}"; + } + } + + $response = $this->apiRequest('GET', $query); + + if ($response['code'] !== 200) { + $this->log( + "ERROR: List failed (HTTP {$response['code']})" + ); + $this->logApiError($response['body']); + return 1; + } + + $dashboards = json_decode($response['body'], true); + + if ( + !is_array($dashboards) + || count($dashboards) === 0 + ) { + $this->log('No dashboards found.'); + return 0; + } + + $this->log(sprintf( + '%-30s | %-20s | %s', + 'Title', + 'UID', + 'Folder' + )); + $this->log(str_repeat('-', 75)); + + foreach ($dashboards as $d) { + $this->log(sprintf( + '%-30s | %-20s | %s', + substr($d['title'] ?? '', 0, 30), + $d['uid'] ?? '', + $d['folderTitle'] ?? 'General' + )); + } + + $this->log(''); + $this->log(count($dashboards) . ' dashboard(s).'); + + return 0; + } + + private function exportDashboard(): int + { + if ($this->uid === '') { + $this->log('ERROR: --uid is required for export.'); + return 1; + } + + $response = $this->apiRequest( + 'GET', + "/api/dashboards/uid/{$this->uid}" + ); + + if ($response['code'] !== 200) { + $this->log( + "ERROR: Export failed " + . "(HTTP {$response['code']})" + ); + $this->logApiError($response['body']); + return 1; + } + + $data = json_decode($response['body'], true); + $dashboard = $data['dashboard'] ?? null; + + if ($dashboard === null) { + $this->log( + 'ERROR: No dashboard data in response.' + ); + return 1; + } + + $output = json_encode( + $dashboard, + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES + ) . "\n"; + + if ($this->file !== '') { + file_put_contents($this->file, $output); + $this->log( + "Exported {$this->uid} to {$this->file}" + ); + } else { + fwrite(STDOUT, $output); + } + + return 0; + } + + private function resolveFolderId(string $title): int + { + $response = $this->apiRequest('GET', '/api/folders'); + + if ($response['code'] !== 200) { + $this->log( + "ERROR: Could not fetch folders " + . "(HTTP {$response['code']})" + ); + return -1; + } + + $folders = json_decode($response['body'], true); + + if (!is_array($folders)) { + return -1; + } + + foreach ($folders as $f) { + if ( + strcasecmp( + $f['title'] ?? '', + $title + ) === 0 + ) { + return (int) ($f['id'] ?? 0); + } + } + + $this->log( + "WARN: Folder \"{$title}\" not found, " + . "using General." + ); + + return 0; + } + + private function noCommand(): int + { + $this->log('ERROR: No command specified.'); + $this->printUsage(); + return 1; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case 'push': + case 'delete': + case 'list': + case 'export': + $this->command = $args[$i]; + break; + case '--url': + $this->grafanaUrl = rtrim( + $args[++$i] ?? '', + '/' + ); + break; + case '--token': + $this->token = $args[++$i] ?? ''; + break; + case '--uid': + $this->uid = $args[++$i] ?? ''; + break; + case '--file': + $this->file = $args[++$i] ?? ''; + break; + case '--folder-id': + $this->folderId = (int) ( + $args[++$i] ?? 0 + ); + break; + case '--folder': + $this->folderTitle = $args[++$i] ?? ''; + break; + case '--no-overwrite': + $this->overwrite = false; + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log( + "WARNING: Unknown arg: {$args[$i]}" + ); + break; + } + } + } + + private function printUsage(): void + { + $u = 'Usage: grafana_dashboard.php ' + . '--url --token [options]'; + $this->log($u); + $this->log(''); + $this->log('Commands:'); + $this->log(' push Create/update dashboard from JSON'); + $this->log(' delete Delete a dashboard by UID'); + $this->log(' list List dashboards (optionally by folder)'); + $this->log(' export Export dashboard JSON by UID'); + $this->log(''); + $this->log('Options:'); + $this->log(' --url Grafana URL (or GRAFANA_URL)'); + $this->log(' --token API token (or GRAFANA_TOKEN)'); + $this->log(' --uid Dashboard UID (delete/export)'); + $this->log(' --file JSON file (push/export)'); + $this->log(' --folder Folder name (push/list)'); + $this->log(' --folder-id Folder ID (push/list)'); + $this->log(' --no-overwrite Fail if dashboard exists'); + $this->log(' --help, -h Show this help'); + } + + private function apiRequest( + string $method, + string $endpoint, + ?string $body = null + ): array { + $url = $this->grafanaUrl . $endpoint; + + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, [ + 'Content-Type: application/json', + 'Accept: application/json', + "Authorization: Bearer {$this->token}", + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $httpCode = (int) curl_getinfo( + $ch, + CURLINFO_HTTP_CODE + ); + + if (curl_errno($ch)) { + $error = curl_error($ch); + curl_close($ch); + + return [ + 'code' => 0, + 'body' => "cURL error: {$error}", + ]; + } + + curl_close($ch); + + return ['code' => $httpCode, 'body' => $responseBody]; + } + + private function logApiError(string $body): void + { + $data = json_decode($body, true); + + if (is_array($data) && isset($data['message'])) { + $this->log(" Grafana: {$data['message']}"); + } + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new GrafanaDashboard(); +exit($app->run()); -- 2.52.0