From 5f7e6a9b1a4786d808f32698904be8e67f919168 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Mon, 25 May 2026 19:03:40 -0500 Subject: [PATCH] feat: add client_dashboard.php + fix release_cascade for RC - client_dashboard.php: generates self-contained HTML dashboard showing all client-waas repos with uptime, SSL expiry, release status, and infrastructure config status. Addresses #3. - release_cascade.php: accept "release-candidate" as stability value. Previously only "rc" was mapped, so cascade silently skipped when the pre-release workflow passed "release-candidate". Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 6 +- cli/client_dashboard.php | 529 +++++++++++++++++++++++++++++++++++++++ cli/release_cascade.php | 9 +- 3 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 cli/client_dashboard.php diff --git a/CHANGELOG.md b/CHANGELOG.md index afa2db4..8139870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,13 @@ Version format: `XX.YY.ZZ` (zero-padded semver). ## [Unreleased] ### Added -- `cli/client_provision.php` — end-to-end client onboarding: repo creation, secrets/variables injection, Grafana dashboard, monitoring endpoints (addresses #4) +- `cli/client_provision.php` — end-to-end client onboarding (addresses #4) +- `cli/client_dashboard.php` — unified client dashboard: health, SSL, uptime, releases (closes #3) - `templates/client-provision-example.json` — example config for client provisioning +### Fixed +- `release_cascade.php`: accept `release-candidate` as stability value (was only accepting `rc`, causing cascade to silently skip) + ## [06.00.00] - 2026-05-25 ### Added diff --git a/cli/client_dashboard.php b/cli/client_dashboard.php new file mode 100644 index 0000000..ed3f902 --- /dev/null +++ b/cli/client_dashboard.php @@ -0,0 +1,529 @@ +#!/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/client_dashboard.php + * VERSION: 01.00.00 + * BRIEF: Generate unified client dashboard HTML + */ + +declare(strict_types=1); + +final class ClientDashboard +{ + private string $giteaUrl = 'https://git.mokoconsulting.tech'; + private string $token = ''; + private string $org = 'MokoConsulting'; + private string $outputFile = ''; + private bool $checkSsl = true; + private bool $checkUptime = true; + private int $sslWarnDays = 30; + private int $httpTimeout = 10; + + public function run(): int + { + $this->parseArgs(); + + if ($this->token === '') { + $this->token = getenv('GA_TOKEN') ?: ''; + } + + if ($this->token === '') { + $this->log('ERROR: --token or GA_TOKEN required.'); + $this->printUsage(); + return 1; + } + + $this->log('Gathering client data...'); + $clients = $this->discoverClients(); + + if ($clients === null) { + $this->log('ERROR: Could not fetch client repos.'); + return 1; + } + + $this->log('Found ' . count($clients) . ' client(s).'); + + foreach ($clients as &$client) { + $this->enrichClient($client); + } + + unset($client); + + $html = $this->renderDashboard($clients); + + if ($this->outputFile !== '') { + file_put_contents($this->outputFile, $html); + $this->log("Dashboard: {$this->outputFile}"); + } else { + fwrite(STDOUT, $html); + } + + return 0; + } + + /** @return array>|null */ + private function discoverClients(): ?array + { + $clients = []; + $orgs = $this->fetchAllOrgs(); + + if (!in_array($this->org, $orgs, true)) { + array_unshift($orgs, $this->org); + } + + foreach ($orgs as $orgName) { + $page = 1; + + while (true) { + $resp = $this->api( + 'GET', + "/api/v1/orgs/{$orgName}/repos" + . "?limit=50&page={$page}" + ); + + if ($resp['code'] !== 200) { + break; + } + + $repos = json_decode($resp['body'], true); + + if (!is_array($repos) || empty($repos)) { + break; + } + + foreach ($repos as $repo) { + $name = $repo['name'] ?? ''; + + if ( + !str_starts_with($name, 'client-waas-') + || !empty($repo['archived']) + ) { + continue; + } + + $clients[] = [ + 'repo' => $repo['full_name'] ?? '', + 'name' => str_replace('client-waas-', '', $name), + 'description' => $repo['description'] ?? '', + 'updated' => $repo['updated_at'] ?? '', + 'url' => $repo['html_url'] ?? '', + ]; + } + + $page++; + } + } + + usort($clients, fn($a, $b) => strcasecmp($a['name'], $b['name'])); + + return $clients; + } + + /** @return string[] */ + private function fetchAllOrgs(): array + { + $resp = $this->api('GET', '/api/v1/user/orgs?limit=50'); + + if ($resp['code'] !== 200) { + return [$this->org]; + } + + $orgs = json_decode($resp['body'], true); + + if (!is_array($orgs)) { + return [$this->org]; + } + + return array_map(fn($o) => $o['username'] ?? '', $orgs); + } + + /** @param array $client */ + private function enrichClient(array &$client): void + { + $repo = $client['repo']; + $this->log(" Checking {$client['name']}..."); + + // Fetch variables + $resp = $this->api('GET', "/api/v1/repos/{$repo}/actions/variables"); + $vars = []; + + if ($resp['code'] === 200) { + $varList = json_decode($resp['body'], true); + + if (is_array($varList)) { + foreach ($varList as $v) { + $vars[$v['name'] ?? ''] = $v['data'] ?? ''; + } + } + } + + $client['vars'] = $vars; + $client['dev_url'] = $vars['DEV_SITE_URL'] ?? ''; + $client['live_url'] = $vars['LIVE_SITE_URL'] ?? ''; + $client['has_dev'] = isset($vars['DEV_SYNC_HOST']); + $client['has_live'] = isset($vars['LIVE_SSH_HOST']); + $client['dev_status'] = 'unknown'; + $client['live_status'] = 'unknown'; + + if ($this->checkUptime) { + if ($client['dev_url'] !== '') { + $client['dev_status'] = $this->checkHttp($client['dev_url']); + } + + if ($client['live_url'] !== '') { + $client['live_status'] = $this->checkHttp($client['live_url']); + } + } + + // SSL + $client['ssl_expiry'] = null; + $client['ssl_days'] = null; + $client['ssl_status'] = 'unknown'; + $domain = $vars['MONITORED_DOMAINS'] ?? ''; + + if ($domain === '' && $client['live_url'] !== '') { + $parsed = parse_url($client['live_url']); + $domain = $parsed['host'] ?? ''; + } + + if ($this->checkSsl && $domain !== '') { + $domain = trim(explode("\n", $domain)[0]); + $ssl = $this->checkSslCert($domain); + $client['ssl_domain'] = $domain; + $client['ssl_expiry'] = $ssl['expiry']; + $client['ssl_days'] = $ssl['days']; + + if ($ssl['days'] === null) { + $client['ssl_status'] = 'error'; + } elseif ($ssl['days'] < $this->sslWarnDays) { + $client['ssl_status'] = 'warning'; + } else { + $client['ssl_status'] = 'ok'; + } + } + + // Last release + $client['last_release'] = ''; + $client['last_release_date'] = ''; + $relResp = $this->api('GET', "/api/v1/repos/{$repo}/releases?limit=1"); + + if ($relResp['code'] === 200) { + $rels = json_decode($relResp['body'], true); + + if (is_array($rels) && !empty($rels)) { + $client['last_release'] = $rels[0]['name'] ?? ''; + $client['last_release_date'] = substr($rels[0]['created_at'] ?? '', 0, 10); + } + } + } + + private function checkHttp(string $url): string + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_NOBODY, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $this->httpTimeout); + curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($code === 0) { + return 'down'; + } + + return ($code >= 200 && $code < 400) ? 'up' : "http-{$code}"; + } + + /** @return array{expiry: ?string, days: ?int} */ + private function checkSslCert(string $domain): array + { + $ctx = stream_context_create([ + 'ssl' => [ + 'capture_peer_cert' => true, + 'verify_peer' => false, + 'verify_peer_name' => false, + ], + ]); + + $client = @stream_socket_client( + "ssl://{$domain}:443", + $errno, + $errstr, + $this->httpTimeout, + STREAM_CLIENT_CONNECT, + $ctx + ); + + if (!$client) { + return ['expiry' => null, 'days' => null]; + } + + $params = stream_context_get_params($client); + fclose($client); + + $cert = $params['options']['ssl']['peer_certificate'] ?? null; + + if ($cert === null) { + return ['expiry' => null, 'days' => null]; + } + + $info = openssl_x509_parse($cert); + $validTo = $info['validTo_time_t'] ?? 0; + + if ($validTo === 0) { + return ['expiry' => null, 'days' => null]; + } + + $expiry = date('Y-m-d', $validTo); + $days = (int) round(($validTo - time()) / 86400); + + return ['expiry' => $expiry, 'days' => $days]; + } + + /** @param array> $clients */ + private function renderDashboard(array $clients): string + { + $generated = date('Y-m-d H:i:s T'); + $total = count($clients); + $up = 0; + $sslWarn = 0; + + foreach ($clients as $c) { + if ($c['live_status'] === 'up' || $c['dev_status'] === 'up') { + $up++; + } + + if ($c['ssl_status'] === 'warning') { + $sslWarn++; + } + } + + $cards = ''; + + foreach ($clients as $c) { + $cards .= $this->renderCard($c); + } + + $warnCls = $sslWarn > 0 ? 'stat-warn' : 'stat-ok'; + + return << + + + + +Moko Client Dashboard + + + +

Moko Client Dashboard

+

Generated {$generated}

+
+
{$total}
Clients
+
{$up}
Sites Up
+
{$sslWarn}
SSL Warnings
+
+
{$cards}
+
Moko Consulting — client_dashboard.php
+ + +HTML; + } + + /** @param array $c */ + private function renderCard(array $c): string + { + $name = htmlspecialchars($c['name']); + $repoUrl = htmlspecialchars($c['url']); + + $ls = $c['live_status']; + + if ($ls === 'up') { + $badge = 'UP'; + } elseif ($ls === 'down') { + $badge = 'DOWN'; + } else { + $badge = '' . htmlspecialchars($ls) . ''; + } + + $rows = ''; + + if ($c['live_url'] !== '') { + $u = htmlspecialchars($c['live_url']); + $rows .= "
Live" + . "{$u}
"; + } + + if ($c['dev_url'] !== '') { + $u = htmlspecialchars($c['dev_url']); + $ds = $c['dev_status'] === 'up' ? ' (up)' : ''; + $rows .= "
Dev" + . "{$u}{$ds}
"; + } + + if ($c['ssl_days'] !== null) { + $cls = match ($c['ssl_status']) { + 'ok' => 'ok', 'warning' => 'wn', default => 'er' + }; + $stxt = htmlspecialchars("{$c['ssl_expiry']} ({$c['ssl_days']}d)"); + $rows .= "
SSL" + . "{$stxt}
"; + } + + if ($c['last_release'] !== '') { + $rel = htmlspecialchars($c['last_release']); + $rd = htmlspecialchars($c['last_release_date']); + $rows .= "
Release" + . "{$rel} ({$rd})
"; + } + + $dc = $c['has_dev'] ? 'configured' : 'missing'; + $lc = $c['has_live'] ? 'configured' : 'missing'; + $upd = substr($c['updated'], 0, 10); + + return << +
{$name}{$badge}
+
{$rows} +
Infrastructure
+
Dev Server{$dc}
+
Live Server{$lc}
+
Last Push{$upd}
+
+CARD; + } + + /** @return array{code: int, body: string} */ + private function api(string $method, string $endpoint): array + { + $url = $this->giteaUrl . $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, [ + 'Accept: application/json', + "Authorization: token {$this->token}", + ]); + $body = curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if (curl_errno($ch)) { + curl_close($ch); + return ['code' => 0, 'body' => '']; + } + + curl_close($ch); + return ['code' => $code, 'body' => $body]; + } + + private function parseArgs(): void + { + $args = $_SERVER['argv'] ?? []; + $count = count($args); + + for ($i = 1; $i < $count; $i++) { + switch ($args[$i]) { + case '--token': + $this->token = $args[++$i] ?? ''; + break; + case '--gitea-url': + $this->giteaUrl = rtrim($args[++$i] ?? '', '/'); + break; + case '--org': + $this->org = $args[++$i] ?? ''; + break; + case '--output': + case '-o': + $this->outputFile = $args[++$i] ?? ''; + break; + case '--no-ssl': + $this->checkSsl = false; + break; + case '--no-uptime': + $this->checkUptime = false; + break; + case '--ssl-warn-days': + $this->sslWarnDays = (int) ($args[++$i] ?? 30); + break; + case '--help': + case '-h': + $this->printUsage(); + exit(0); + default: + $this->log("WARNING: Unknown arg: {$args[$i]}"); + break; + } + } + } + + private function printUsage(): void + { + $this->log('Usage: client_dashboard.php --token TOKEN [options]'); + $this->log(''); + $this->log('Generate unified client status dashboard (HTML).'); + $this->log(''); + $this->log('Options:'); + $this->log(' --token Gitea token (or GA_TOKEN)'); + $this->log(' --gitea-url Gitea URL'); + $this->log(' --org Primary org (default: MokoConsulting)'); + $this->log(' -o, --output Output HTML file (default: stdout)'); + $this->log(' --no-ssl Skip SSL checks'); + $this->log(' --no-uptime Skip HTTP uptime checks'); + $this->log(' --ssl-warn-days SSL warning days (default: 30)'); + $this->log(' --help, -h Show this help'); + } + + private function log(string $message): void + { + fwrite(STDERR, $message . PHP_EOL); + } +} + +$app = new ClientDashboard(); +exit($app->run()); diff --git a/cli/release_cascade.php b/cli/release_cascade.php index ad71d26..9ea510a 100644 --- a/cli/release_cascade.php +++ b/cli/release_cascade.php @@ -48,10 +48,11 @@ if ($stability === null || $token === null || $apiBase === null) { // Define cascade hierarchy $cascadeMap = [ - 'stable' => ['development', 'alpha', 'beta', 'release-candidate'], - 'rc' => ['development', 'alpha', 'beta'], - 'beta' => ['development', 'alpha'], - 'alpha' => ['development'], + 'stable' => ['development', 'alpha', 'beta', 'release-candidate'], + 'release-candidate' => ['development', 'alpha', 'beta'], + 'rc' => ['development', 'alpha', 'beta'], + 'beta' => ['development', 'alpha'], + 'alpha' => ['development'], ]; if (!isset($cascadeMap[$stability])) { -- 2.52.0