From bd5cb9364c3c2b5c416170a596d1c50769e00f4f Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Thu, 25 Jun 2026 01:00:06 +0200 Subject: [PATCH] asdasd --- config/gitea.php | 13 + config/prod/gitea.php | 7 + config/staging/gitea.php | 7 + docs/README.md | 2 + partials/desktop/shell.php | 3 +- public/api/system/deploy-status/index.php | 39 +++ public/assets/desktop/desktop.css | 21 ++ public/assets/desktop/desktop.js | 64 ++++ src/App/App.php | 8 + src/App/GiteaDeployStatusService.php | 374 ++++++++++++++++++++++ 10 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 config/gitea.php create mode 100644 config/prod/gitea.php create mode 100644 config/staging/gitea.php create mode 100644 public/api/system/deploy-status/index.php create mode 100644 src/App/GiteaDeployStatusService.php diff --git a/config/gitea.php b/config/gitea.php new file mode 100644 index 00000000..55e8b2d0 --- /dev/null +++ b/config/gitea.php @@ -0,0 +1,13 @@ + 'https://gitea.int.kusche.berlin/api/v1', + 'token' => '', + 'owner' => 'private', + 'repositories' => [], + 'poll_idle_seconds' => 5, + 'poll_running_seconds' => 1, + 'visibility' => 'admins', +]; diff --git a/config/prod/gitea.php b/config/prod/gitea.php new file mode 100644 index 00000000..9b09a6ff --- /dev/null +++ b/config/prod/gitea.php @@ -0,0 +1,7 @@ + '', +]; diff --git a/config/staging/gitea.php b/config/staging/gitea.php new file mode 100644 index 00000000..1ca09a0f --- /dev/null +++ b/config/staging/gitea.php @@ -0,0 +1,7 @@ + 'cf22682c47cc40f820c47d6c1226dd48910b9add', +]; diff --git a/docs/README.md b/docs/README.md index 62dc0171..771217dd 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,6 +29,7 @@ Zentraler Dokumentationsindex fuer das Projekt. - `system/apps/` Zielort fuer nicht installierbare System-Tools - `system/shell/` Zielort fuer globale Shell-Werkzeuge und shell-nahe Desktop-Helfer - `temp/nexus-module-import/` Rohbasis fuer importierte Nexus-Module +- `config/gitea.php` Basis fuer serverseitige Gitea-API-Anbindung wie Deploy-Status im Tray ## Fachliche Hinweise @@ -60,6 +61,7 @@ Aktuell wichtig: - die API bleibt vorerst auf demselben Host und wird nicht auf `api.desktop.kusche.berlin` ausgelagert - das gemeinsame Admin-Debug-Widget neben der Uhr ist der Standard fuer Live-Debugging in Desktop und Native-Apps - der Waehrungs-Checker stellt zusaetzlich ein Desktop-Widget fuer manuelle Kursaktualisierung bereit +- der Gitea-Deploy-Status kann als Tray-Status unten rechts serverseitig ueber `config/gitea.php` angebunden werden - das Oeffnen des Waehrungs-Checkers darf keinen externen Kursabruf ausloesen; Refreshes laufen nur nach Altersregel oder mit `force` - `Systemtools` und installierbare `Module` werden getrennt behandelt; Systemtools sind nicht Teil der Benutzer-Installationsauswahl - das `Cron Tool` ist als globales Systemtool vorhanden und sammelt Modul-Cronjobs automatisch ueber Manifest-Metadaten diff --git a/partials/desktop/shell.php b/partials/desktop/shell.php index 3992be1b..f85bd459 100644 --- a/partials/desktop/shell.php +++ b/partials/desktop/shell.php @@ -161,10 +161,11 @@ $renderStartIcon = static function (array $profile): string {
diff --git a/public/api/system/deploy-status/index.php b/public/api/system/deploy-status/index.php new file mode 100644 index 00000000..55fe5404 --- /dev/null +++ b/public/api/system/deploy-status/index.php @@ -0,0 +1,39 @@ +shouldShowInTray($isAdmin)) { + respond([ + 'error' => 'forbidden', + 'message' => 'Kein Zugriff auf den Gitea-Deploy-Status.', + ], 403); +} + +respond([ + 'data' => $service->status(), +]); + +/** + * @param array $payload + */ +function respond(array $payload, int $statusCode = 200): never +{ + http_response_code($statusCode); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; +} diff --git a/public/assets/desktop/desktop.css b/public/assets/desktop/desktop.css index 2bbaf5ae..6cfa01f7 100644 --- a/public/assets/desktop/desktop.css +++ b/public/assets/desktop/desktop.css @@ -1400,6 +1400,27 @@ h1 { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18); } +.tray-pill-status { + min-width: 34px; + padding: 8px 9px; + text-align: center; + font-size: 18px; + line-height: 1; +} + +.tray-pill-status--idle { + color: #22c55e; +} + +.tray-pill-status--running { + color: #facc15; +} + +.tray-pill-status--error, +.tray-pill-status--unconfigured { + color: #f97316; +} + .tray-menu { position: absolute; right: 16px; diff --git a/public/assets/desktop/desktop.js b/public/assets/desktop/desktop.js index 12dbf2ac..bb0a2199 100644 --- a/public/assets/desktop/desktop.js +++ b/public/assets/desktop/desktop.js @@ -40,6 +40,7 @@ if (payloadNode) { const accountMenu = document.getElementById('tray-account-menu'); const fxTrayMenu = document.getElementById('tray-fx-menu'); const fxTrayStatusNode = document.getElementById('tray-fx-status'); + const giteaStatusNode = document.querySelector('.tray-pill[data-tray-id="gitea-deploy-status"]'); const clockNode = document.getElementById('clock'); const clockTopNode = document.getElementById('clock-top'); const debugToggleNode = document.getElementById('debug-toggle'); @@ -71,6 +72,7 @@ if (payloadNode) { let activeFunctionArea = 'programs'; const windowControlsMode = document.body.dataset.windowControls || 'windows'; let wallpaperImagePromise = null; + let giteaStatusTimer = null; const loadDebugState = () => { try { @@ -469,6 +471,63 @@ if (payloadNode) { } }; + const updateGiteaTrayUi = (state, message) => { + if (!(giteaStatusNode instanceof HTMLElement)) { + return; + } + + giteaStatusNode.classList.remove( + 'tray-pill-status--idle', + 'tray-pill-status--running', + 'tray-pill-status--error', + 'tray-pill-status--unconfigured', + ); + + const normalizedState = typeof state === 'string' && state !== '' ? state : 'idle'; + giteaStatusNode.classList.add(`tray-pill-status--${normalizedState}`); + giteaStatusNode.setAttribute('title', typeof message === 'string' && message !== '' ? message : 'Gitea-Deploy-Status'); + giteaStatusNode.textContent = '●'; + }; + + const scheduleGiteaStatusPoll = (seconds) => { + if (giteaStatusTimer) { + window.clearTimeout(giteaStatusTimer); + } + + const delayMs = Math.max(1, Number(seconds || 5)) * 1000; + giteaStatusTimer = window.setTimeout(() => { + void loadGiteaDeployStatus(); + }, delayMs); + }; + + const loadGiteaDeployStatus = async () => { + if (!(giteaStatusNode instanceof HTMLElement)) { + return null; + } + + try { + const response = await fetch('/api/system/deploy-status/index.php', { + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }); + const responsePayload = await response.json().catch(() => ({})); + const data = responsePayload && typeof responsePayload === 'object' ? (responsePayload.data || {}) : {}; + const state = typeof data.state === 'string' ? data.state : (response.ok ? 'idle' : 'error'); + const message = typeof data.message === 'string' && data.message !== '' + ? data.message + : 'Gitea-Deploy-Status ist unbekannt.'; + const nextPoll = Number(data.poll_seconds || 5); + + updateGiteaTrayUi(state, message); + scheduleGiteaStatusPoll(nextPoll); + return data; + } catch (_error) { + updateGiteaTrayUi('error', 'Gitea-Deploy-Status konnte nicht geladen werden.'); + scheduleGiteaStatusPoll(5); + return null; + } + }; + const runFxTrayRefresh = async (force) => { if (!(fxTrayStatusNode instanceof HTMLElement)) { return; @@ -2042,7 +2101,12 @@ if (payloadNode) { } }); + giteaStatusNode?.addEventListener('click', () => { + void loadGiteaDeployStatus(); + }); + void loadFxTrayStatus(); + void loadGiteaDeployStatus(); debugToggleNode?.addEventListener('click', () => { toggleDebugWindow(); diff --git a/src/App/App.php b/src/App/App.php index c9af9602..05a79ba3 100644 --- a/src/App/App.php +++ b/src/App/App.php @@ -32,6 +32,7 @@ final class App $registry = new AppRegistry($this->projectRoot . '/config/apps.php', $moduleRegistry->desktopApps()); $widgetRegistry = new WidgetRegistry($this->projectRoot . '/config/widgets.php', $moduleRegistry->widgets()); $shellRegistry = new ShellAppRegistry(AppPaths::systemShellRoot($this->projectRoot)); + $giteaStatus = new GiteaDeployStatusService(ConfigLoader::load($this->projectRoot, 'gitea')); $skins = SkinResolver::all(); $isAuthenticated = isset($_SESSION['desktop_auth']) && is_array($_SESSION['desktop_auth']); $authUser = $isAuthenticated && is_array($_SESSION['desktop_auth']['user'] ?? null) @@ -76,6 +77,13 @@ final class App 'title' => (string) ($app['title'] ?? ''), ]; } + if ($giteaStatus->shouldShowInTray($isAdmin)) { + $trayItems[] = [ + 'id' => 'gitea-deploy-status', + 'label' => '●', + 'title' => 'Gitea-Deploy-Status', + ]; + } $displayName = (string) ($authUser['name'] ?? $authUser['username'] ?? 'Gast'); $storageScope = $isAuthenticated ? strtolower((string) ($authUser['username'] ?? $authUser['sub'] ?? 'user')) diff --git a/src/App/GiteaDeployStatusService.php b/src/App/GiteaDeployStatusService.php new file mode 100644 index 00000000..f76cd3ce --- /dev/null +++ b/src/App/GiteaDeployStatusService.php @@ -0,0 +1,374 @@ + $config + */ + public function __construct( + private readonly array $config, + ) { + } + + public function isConfigured(): bool + { + return $this->baseUrl() !== '' && $this->token() !== ''; + } + + public function visibility(): string + { + $visibility = strtolower(trim((string) ($this->config['visibility'] ?? 'admins'))); + + return match ($visibility) { + 'all', 'admins', 'disabled' => $visibility, + default => 'admins', + }; + } + + public function shouldShowInTray(bool $isAdmin): bool + { + return match ($this->visibility()) { + 'all' => true, + 'admins' => $isAdmin, + default => false, + }; + } + + /** + * @return array + */ + public function status(): array + { + $idleSeconds = max(1, (int) ($this->config['poll_idle_seconds'] ?? 5)); + $runningSeconds = max(1, (int) ($this->config['poll_running_seconds'] ?? 1)); + + if (!$this->isConfigured()) { + return [ + 'state' => 'unconfigured', + 'poll_seconds' => $idleSeconds, + 'message' => 'Gitea-Status ist noch nicht konfiguriert.', + 'running_jobs' => [], + 'repositories_checked' => 0, + ]; + } + + try { + $repositories = $this->resolveRepositories(); + $runningJobs = []; + + foreach ($repositories as $repository) { + $runs = $this->listActionRuns((string) $repository['owner'], (string) $repository['repo']); + + foreach ($runs as $run) { + if (!$this->isRunActive($run)) { + continue; + } + + $runningJobs[] = [ + 'repository' => (string) $repository['full_name'], + 'workflow' => (string) ($run['workflow_name'] ?? $run['name'] ?? 'Workflow'), + 'status' => (string) ($run['status'] ?? 'running'), + 'branch' => (string) ($run['head_branch'] ?? $run['ref'] ?? ''), + 'event' => (string) ($run['event'] ?? ''), + 'started_at' => (string) ($run['created_at'] ?? $run['run_started_at'] ?? ''), + 'updated_at' => (string) ($run['updated_at'] ?? ''), + 'url' => (string) ($run['html_url'] ?? $run['url'] ?? ''), + ]; + } + } + + if ($runningJobs !== []) { + $first = $runningJobs[0]; + $message = sprintf( + 'Deploy laeuft: %s%s%s', + $first['repository'], + $first['branch'] !== '' ? ' · ' . $first['branch'] : '', + count($runningJobs) > 1 ? ' · +' . (count($runningJobs) - 1) . ' weitere' : '' + ); + + return [ + 'state' => 'running', + 'poll_seconds' => $runningSeconds, + 'message' => $message, + 'running_jobs' => $runningJobs, + 'repositories_checked' => count($repositories), + ]; + } + + return [ + 'state' => 'idle', + 'poll_seconds' => $idleSeconds, + 'message' => sprintf('Kein laufendes Gitea-Deployment in %d Repositories.', count($repositories)), + 'running_jobs' => [], + 'repositories_checked' => count($repositories), + ]; + } catch (\Throwable $exception) { + return [ + 'state' => 'error', + 'poll_seconds' => $idleSeconds, + 'message' => 'Gitea-Status konnte nicht geladen werden: ' . $exception->getMessage(), + 'running_jobs' => [], + 'repositories_checked' => 0, + ]; + } + } + + private function baseUrl(): string + { + return rtrim(trim((string) ($this->config['base_url'] ?? '')), '/'); + } + + private function token(): string + { + return trim((string) ($this->config['token'] ?? '')); + } + + /** + * @return array + */ + private function resolveRepositories(): array + { + $configured = []; + foreach ((array) ($this->config['repositories'] ?? []) as $entry) { + $normalized = $this->normalizeRepositoryEntry($entry); + if ($normalized !== null) { + $configured[$normalized['full_name']] = $normalized; + } + } + + if ($configured !== []) { + return array_values($configured); + } + + $owner = trim((string) ($this->config['owner'] ?? '')); + + if ($owner !== '') { + foreach ([ + '/orgs/' . rawurlencode($owner) . '/repos?limit=100&page=1', + '/users/' . rawurlencode($owner) . '/repos?limit=100&page=1', + ] as $path) { + try { + $payload = $this->requestJson($path); + $repositories = $this->normalizeRepositoriesPayload($payload); + if ($repositories !== []) { + return $repositories; + } + } catch (\Throwable) { + continue; + } + } + } + + $payload = $this->requestJson('/user/repos?limit=100&page=1'); + $repositories = $this->normalizeRepositoriesPayload($payload); + + if ($repositories === []) { + throw new \RuntimeException('Keine Repositories fuer den Gitea-Status gefunden.'); + } + + return $repositories; + } + + /** + * @param mixed $entry + * @return array{owner: string, repo: string, full_name: string}|null + */ + private function normalizeRepositoryEntry(mixed $entry): ?array + { + if (is_string($entry)) { + $trimmed = trim($entry); + if ($trimmed === '' || !str_contains($trimmed, '/')) { + return null; + } + + [$owner, $repo] = array_map('trim', explode('/', $trimmed, 2)); + if ($owner === '' || $repo === '') { + return null; + } + + return [ + 'owner' => $owner, + 'repo' => $repo, + 'full_name' => $owner . '/' . $repo, + ]; + } + + if (!is_array($entry)) { + return null; + } + + $owner = trim((string) ($entry['owner'] ?? ($entry['owner_name'] ?? ''))); + $repo = trim((string) ($entry['repo'] ?? ($entry['name'] ?? ''))); + $fullName = trim((string) ($entry['full_name'] ?? '')); + + if ($fullName !== '' && str_contains($fullName, '/')) { + [$ownerFromFull, $repoFromFull] = array_map('trim', explode('/', $fullName, 2)); + $owner = $owner !== '' ? $owner : $ownerFromFull; + $repo = $repo !== '' ? $repo : $repoFromFull; + } + + if ($owner === '' || $repo === '') { + return null; + } + + return [ + 'owner' => $owner, + 'repo' => $repo, + 'full_name' => $owner . '/' . $repo, + ]; + } + + /** + * @param array $payload + * @return array + */ + private function normalizeRepositoriesPayload(array $payload): array + { + $rows = array_is_list($payload) ? $payload : (array) ($payload['data'] ?? []); + $repositories = []; + + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $owner = trim((string) (($row['owner']['login'] ?? $row['owner_name'] ?? ''))); + $repo = trim((string) ($row['name'] ?? '')); + $fullName = trim((string) ($row['full_name'] ?? '')); + + if ($fullName !== '' && str_contains($fullName, '/')) { + [$ownerFromFull, $repoFromFull] = array_map('trim', explode('/', $fullName, 2)); + $owner = $owner !== '' ? $owner : $ownerFromFull; + $repo = $repo !== '' ? $repo : $repoFromFull; + } + + if ($owner === '' || $repo === '') { + continue; + } + + $repositories[$owner . '/' . $repo] = [ + 'owner' => $owner, + 'repo' => $repo, + 'full_name' => $owner . '/' . $repo, + ]; + } + + return array_values($repositories); + } + + /** + * @return array> + */ + private function listActionRuns(string $owner, string $repo): array + { + $payload = $this->requestJson('/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/actions/runs?limit=10&page=1'); + + if (is_array($payload['workflow_runs'] ?? null)) { + return array_values(array_filter($payload['workflow_runs'], 'is_array')); + } + + if (is_array($payload['runs'] ?? null)) { + return array_values(array_filter($payload['runs'], 'is_array')); + } + + if (is_array($payload['data'] ?? null)) { + return array_values(array_filter($payload['data'], 'is_array')); + } + + if (array_is_list($payload)) { + return array_values(array_filter($payload, 'is_array')); + } + + return []; + } + + /** + * @param array $run + */ + private function isRunActive(array $run): bool + { + $status = strtolower(trim((string) ($run['status'] ?? ''))); + $conclusion = strtolower(trim((string) ($run['conclusion'] ?? ''))); + + if (in_array($status, ['queued', 'waiting', 'pending', 'requested', 'running', 'in_progress'], true)) { + return true; + } + + if ($status === 'completed') { + return false; + } + + if ($status === '' && $conclusion === '') { + return false; + } + + return $conclusion === ''; + } + + /** + * @return array + */ + private function requestJson(string $path): array + { + $url = $this->baseUrl() . '/' . ltrim($path, '/'); + $headers = [ + 'Accept: application/json', + 'Authorization: token ' . $this->token(), + 'User-Agent: desktop-kusche-berlin/deploy-status', + ]; + + if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_HTTPHEADER => $headers, + ]); + + $raw = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $error = curl_error($ch); + curl_close($ch); + + if (!is_string($raw)) { + throw new \RuntimeException($error !== '' ? $error : 'Leere Gitea-Antwort.'); + } + + if ($status >= 400) { + throw new \RuntimeException('Gitea-API antwortet mit HTTP ' . $status . '.'); + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new \RuntimeException('Gitea-API liefert kein JSON.'); + } + + return $decoded; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'GET', + 'timeout' => 10, + 'header' => implode("\r\n", $headers) . "\r\n", + ], + ]); + + $raw = @file_get_contents($url, false, $context); + if (!is_string($raw)) { + throw new \RuntimeException('Gitea-API konnte nicht geladen werden.'); + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + throw new \RuntimeException('Gitea-API liefert kein JSON.'); + } + + return $decoded; + } +}