feat: client dashboard + fix release cascade for RC #90

Merged
jmiller merged 2 commits from dev into main 2026-05-26 00:04:40 +00:00
3 changed files with 539 additions and 5 deletions
+5 -1
View File
@@ -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
+529
View File
@@ -0,0 +1,529 @@
#!/usr/bin/env php
<?php
/* Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
*
* 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<int, array<string, mixed>>|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<string, mixed> $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<int, array<string, mixed>> $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 <<<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Moko Client Dashboard</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#0f172a;color:#e2e8f0;padding:24px}
h1{font-size:1.5rem;font-weight:600;margin-bottom:4px}
.sub{color:#94a3b8;font-size:.875rem;margin-bottom:24px}
.stats{display:flex;gap:16px;margin-bottom:24px;flex-wrap:wrap}
.st{background:#1e293b;border-radius:8px;padding:16px 20px;min-width:140px}
.sv{font-size:1.5rem;font-weight:700}
.sl{color:#94a3b8;font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}
.stat-ok .sv{color:#4ade80}
.stat-warn .sv{color:#fbbf24}
.g{display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px}
.c{background:#1e293b;border-radius:8px;padding:20px;border:1px solid #334155;transition:border-color .2s}
.c:hover{border-color:#475569}
.ch{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.cn{font-size:1.1rem;font-weight:600;text-transform:capitalize}
.cn a{color:#e2e8f0;text-decoration:none}
.cn a:hover{color:#60a5fa}
.b{font-size:.7rem;padding:2px 8px;border-radius:999px;font-weight:600;text-transform:uppercase}
.b-up{background:#064e3b;color:#4ade80}
.b-dn{background:#7f1d1d;color:#fca5a5}
.b-un{background:#374151;color:#9ca3af}
.rs{display:flex;flex-direction:column;gap:8px}
.r{display:flex;justify-content:space-between;font-size:.85rem}
.rl{color:#94a3b8}
.rv{color:#e2e8f0;text-align:right;max-width:60%}
.rv a{color:#60a5fa;text-decoration:none}
.rv a:hover{text-decoration:underline}
.ok{color:#4ade80}.wn{color:#fbbf24}.er{color:#f87171}
.st2{font-size:.7rem;text-transform:uppercase;letter-spacing:.08em;color:#64748b;
margin-top:8px;margin-bottom:4px;padding-top:8px;border-top:1px solid #334155}
footer{margin-top:32px;text-align:center;color:#64748b;font-size:.75rem}
</style>
</head>
<body>
<h1>Moko Client Dashboard</h1>
<p class="sub">Generated {$generated}</p>
<div class="stats">
<div class="st"><div class="sv">{$total}</div><div class="sl">Clients</div></div>
<div class="st stat-ok"><div class="sv">{$up}</div><div class="sl">Sites Up</div></div>
<div class="st {$warnCls}"><div class="sv">{$sslWarn}</div><div class="sl">SSL Warnings</div></div>
</div>
<div class="g">{$cards}</div>
<footer>Moko Consulting &mdash; client_dashboard.php</footer>
</body>
</html>
HTML;
}
/** @param array<string, mixed> $c */
private function renderCard(array $c): string
{
$name = htmlspecialchars($c['name']);
$repoUrl = htmlspecialchars($c['url']);
$ls = $c['live_status'];
if ($ls === 'up') {
$badge = '<span class="b b-up">UP</span>';
} elseif ($ls === 'down') {
$badge = '<span class="b b-dn">DOWN</span>';
} else {
$badge = '<span class="b b-un">' . htmlspecialchars($ls) . '</span>';
}
$rows = '';
if ($c['live_url'] !== '') {
$u = htmlspecialchars($c['live_url']);
$rows .= "<div class=\"r\"><span class=\"rl\">Live</span>"
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a></span></div>";
}
if ($c['dev_url'] !== '') {
$u = htmlspecialchars($c['dev_url']);
$ds = $c['dev_status'] === 'up' ? ' (up)' : '';
$rows .= "<div class=\"r\"><span class=\"rl\">Dev</span>"
. "<span class=\"rv\"><a href=\"{$u}\" target=\"_blank\">{$u}</a>{$ds}</span></div>";
}
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 .= "<div class=\"r\"><span class=\"rl\">SSL</span>"
. "<span class=\"rv {$cls}\">{$stxt}</span></div>";
}
if ($c['last_release'] !== '') {
$rel = htmlspecialchars($c['last_release']);
$rd = htmlspecialchars($c['last_release_date']);
$rows .= "<div class=\"r\"><span class=\"rl\">Release</span>"
. "<span class=\"rv\">{$rel} ({$rd})</span></div>";
}
$dc = $c['has_dev'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
$lc = $c['has_live'] ? '<span class="ok">configured</span>' : '<span class="er">missing</span>';
$upd = substr($c['updated'], 0, 10);
return <<<CARD
<div class="c">
<div class="ch"><span class="cn"><a href="{$repoUrl}" target="_blank">{$name}</a></span>{$badge}</div>
<div class="rs">{$rows}
<div class="st2">Infrastructure</div>
<div class="r"><span class="rl">Dev Server</span><span class="rv">{$dc}</span></div>
<div class="r"><span class="rl">Live Server</span><span class="rv">{$lc}</span></div>
<div class="r"><span class="rl">Last Push</span><span class="rv">{$upd}</span></div>
</div></div>
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 <token> Gitea token (or GA_TOKEN)');
$this->log(' --gitea-url <url> Gitea URL');
$this->log(' --org <org> Primary org (default: MokoConsulting)');
$this->log(' -o, --output <file> 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 <n> 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());
+5 -4
View File
@@ -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])) {