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;
+ }
+}