5f7e6a9b1a
Generic: Repo Health / Site Health (push) Has been skipped
Generic: Repo Health / Access control (push) Successful in 1s
Generic: Repo Health / Release configuration (push) Successful in 3s
Generic: Repo Health / Scripts governance (push) Successful in 3s
Generic: Repo Health / Repository health (push) Successful in 11s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Successful in 5s
Universal: PR Check / Build RC Package (pull_request) Successful in 1s
Generic: Repo Health / Release configuration (pull_request) Successful in 5s
Platform: moko-platform CI / Gate 1: Code Quality (push) Successful in 50s
Generic: Repo Health / Scripts governance (pull_request) Successful in 5s
Generic: Repo Health / Repository health (pull_request) Successful in 14s
Platform: moko-platform CI / Gate 1: Code Quality (pull_request) Successful in 54s
Platform: moko-platform CI / Gate 5: Template Integrity (pull_request) Failing after 4s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (pull_request) Failing after 45s
Platform: moko-platform CI / Gate 3: Self-Health Check (pull_request) Failing after 47s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (pull_request) Failing after 49s
Platform: moko-platform CI / Gate 4: Governance (pull_request) Successful in 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (pull_request) Failing after 55s
Platform: moko-platform CI / Gate 5: Template Integrity (push) Failing after 6s
Platform: moko-platform CI / Gate 2: Unit Tests (8.1) (push) Failing after 53s
Platform: moko-platform CI / Gate 2: Unit Tests (8.2) (push) Failing after 54s
Platform: moko-platform CI / Gate 3: Self-Health Check (push) Failing after 55s
Platform: moko-platform CI / Gate 2: Unit Tests (8.3) (push) Failing after 1m12s
Platform: moko-platform CI / Gate 4: Governance (push) Successful in 46s
Platform: moko-platform CI / CI Summary (push) Has been cancelled
Platform: moko-platform CI / CI Summary (pull_request) Has been cancelled
- 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) <noreply@anthropic.com>
530 lines
17 KiB
PHP
530 lines
17 KiB
PHP
#!/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 — 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());
|