5 Commits
Author SHA1 Message Date
Florian WildeandClaude Opus 5 f55e5ff2fe docs: Abgleich dokumentieren und Roadmap anlegen
README und FEATURES beschreiben den automatischen Abgleich samt
Zurueckhalten nach Fehlschlaegen und dem Weg ueber einen externen Zeitgeber.

Neu ist ROADMAP.md: die bisherige Liste "Ideen fuer spaeter" war eine lose
Aufzaehlung ohne Reihenfolge und ohne Aufwandsabschaetzung. Jetzt nach
Prioritaet gegliedert, jeder Punkt mit Aufwand und - wo es welche gibt -
offenen Fragen. Ganz oben steht Export und Import in vier Stufen, inklusive
der Frage, ob Git-Tokens mitgehen sollen (Vorschlag: nein).

Am Ende steht bewusst auch, was nicht kommt und warum - das erspart
spaetere Diskussionen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00
Florian WildeandClaude Opus 5 6f0d6e1ca9 feat(admin): Abgleich einstellbar und Schalter je Projekt
Adminbereich, Abschnitt "Automatischer Abgleich":
- ein- und ausschalten
- Abstand von 15 Minuten bis taeglich
- Projekte je Durchlauf
- fertige und archivierte auslassen
- Zustand auf einen Blick: verknuepfte Projekte, letzter und naechster
  Durchlauf, Kurzbericht des letzten Laufs
- Knopf "Jetzt abgleichen" fuer einen sofortigen Durchlauf

Der Endpunkt POST /api/admin/sync/run dient zugleich als Einstieg fuer einen
externen Zeitgeber, falls jemand lieber von aussen taktet als den eingebauten
Automatismus zu nutzen.

Im Projektdialog laesst sich ein einzelnes Projekt vom Abgleich ausnehmen -
nuetzlich bei einem Repo, dessen Token staendig Fehler wirft. Manuell
synchronisieren geht unabhaengig davon immer.

Die Zahl der in Frage kommenden Projekte wird ueber alle Konten gezaehlt: der
Abgleich ist ein Serverauftrag, keine Dateneinsicht. Ein Administrator sieht
dadurch weiterhin keine fremden Projekte.

Nachgewiesen: Durchlauf 1 aktualisiert das echte Repo und meldet das kaputte
als Fehlschlag, Durchlauf 2 stellt das kaputte zurueck, das ausgenommene
Projekt bleibt in beiden unberuehrt. Der Zeitplan loeste eigenstaendig aus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:38:19 +02:00
Florian WildeandClaude Opus 5 8e566d06e0 feat(sync): automatischer Abgleich nach Zeitplan
Bisher musste jedes Projekt einzeln von Hand synchronisiert werden. Jetzt
gibt es einen Zeitgeber im Serverprozess, angestossen von
instrumentation.ts. Fuer eine Einzelinstanz ist das die einfachste
tragfaehige Loesung: kein zusaetzlicher Container, kein externer Cron, keine
zweite Konfiguration.

Ruecksicht auf fremde API-Kontingente war der bestimmende Gedanke:

- standardmaessig abgeschaltet - der Abgleich verbraucht fremdes Kontingent,
  das soll eine bewusste Entscheidung sein
- je Durchlauf nur eine begrenzte Zahl Projekte, die am laengsten nicht
  abgeglichenen zuerst; der Rest kommt beim naechsten Mal
- nach jedem Fehlschlag verdoppelt sich der Abstand bis zum naechsten
  Versuch, gedeckelt beim Sechzehnfachen. Ein Repo mit abgelaufenem Token
  blockiert damit nicht dauerhaft das Kontingent.
- kurze Pause zwischen zwei Projekten
- zwei Durchlaeufe ueberschneiden sich nie
- fertige und archivierte Projekte lassen sich auslassen
- einzelne Projekte lassen sich ausnehmen

Der Zaehler fuer Fehlschlaege wandert in syncProject: bei Erfolg zurueck auf
null, bei Fehlschlag hoch. So stimmt er auch nach einem manuellen Abgleich.

sync.ts wird zu sync/index.ts, damit Modul und Ordner nicht nebeneinander
stehen. Alle Importpfade bleiben unveraendert.

Zur Form der Abfrage in instrumentation.ts: Next uebersetzt die Datei auch
fuer den Edge-Runtime. Der Import muss deshalb *in* der positiven Bedingung
stehen, sonst bleibt er im Edge-Bundle stehen und der Build scheitert an
node:crypto. Mit einem frueh abbrechenden return passiert genau das.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 23:37:54 +02:00
Florian WildeandClaude Opus 5 4ba53a239f fix(ui): Anzeigefehler aus der Browser-Pruefung beheben
Erstmals mit echtem Browser nachgesehen (headless Chrome ueber Playwright,
Screenshots aller Ansichten). Dabei fuenf Dinge gefunden:

1. Menue klebte am Fensterrand statt am Ausloeser. Bei einem Ausloeser weit
   links wurde die bevorzugte rechtsbuendige Ausrichtung negativ und dann auf
   den Rand geklemmt - das Menue stand quer ueber der Seite. Jetzt wird bei
   Ueberlauf auf die andere Ausrichtung gewechselt und erst danach geklemmt.

2. .popover war mit 97 % nicht ganz deckend, wodurch "Bearbeiten" und
   "Loeschen" schwach durchschienen. Jetzt vollstaendig deckend.

3. Der Schieberegler zeichnete die Spur rechts vom Griff in einem hellen
   Standardton - ein grelles Band im dunklen Layout. Ersetzt durch eine
   selbst gezeichnete Variante mit Farbverlauf und sichtbarem Griff. Auf der
   Projektseite ersetzt sie zugleich das bisherige unsichtbare Eingabefeld
   ueber einem gezeichneten Balken, das gar keinen Griff hatte.

4. Der Hinweis der Kachel "Ideen" wurde mitten im Wort abgeschnitten
   ("Noch ein Gedanke - nichts ge..."). Kurzer Text statt der langen
   Statusbeschreibung.

5. Nach der Einrichtung stand im Adminbereich "noch nie angemeldet", obwohl
   gerade eine Sitzung lief. Einrichtung und Registrierung setzen lastLoginAt
   jetzt mit.

Die Konsole blieb ueber alle Ansichten hinweg frei von Fehlern und Warnungen
- die strikte CSP mit Nonce und strict-dynamic bricht also nichts.

Geprueft an einer separaten Wegwerf-Datenbank auf Port 3100, damit die
laufende Instanz unberuehrt blieb.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:46:00 +02:00
Florian WildeandClaude Opus 5 dcf9b0d58d fix(ui): Menues und Dialoge ueber Portal ausgeben
Das Statusmenue auf der Projektseite wurde von den Schaltflaechen
"Bearbeiten" und "Loeschen" ueberdeckt, war unten abgeschnitten und der
Fortschrittswert schien hindurch. Drei Ursachen, alle mit derselben Wurzel:

1. Die Karten setzen overflow-hidden fuer den Akzentstreifen und den
   Farbverlaufsrand. Ein absolut positioniertes Kind wird daran
   abgeschnitten.
2. .glass setzt backdrop-filter, die Projektkarte bekommt beim Hovern
   zusaetzlich ein transform. Beides erzeugt einen Stacking-Context: das
   z-index des Menues galt nur noch innerhalb der Karte und verlor gegen
   spaeter gezeichnete Geschwister. Aus demselben Grund bezog sich das
   "position: fixed" der Klickflaeche zum Schliessen auf die Karte statt
   auf das Fenster - ein Klick daneben schloss das Menue nicht.
3. .glass hat 3,5 % Deckkraft. Als Kartenflaeche gut, als Overlay
   unbrauchbar. Der eigene backdrop-filter half nicht, weil er innerhalb
   eines Vorfahren mit backdrop-filter wirkungslos bleibt.

Behoben durch eine neue Popover-Komponente, die per createPortal am body
haengt, ihre Position aus dem Ankerelement berechnet und beim Scrollen und
bei Groessenaenderungen nachfuehrt. Reicht der Platz nach unten nicht, klappt
sie nach oben. Dazu die Klasse .popover mit nahezu deckendem Hintergrund.

Betrifft Statusauswahl, Benutzermenue und - aus demselben Grund - den
Modal-Dialog: der wird aus Karten heraus geoeffnet und haette sich sonst an
der Karte statt am Fenster ausgerichtet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 22:29:11 +02:00
25 changed files with 1206 additions and 166 deletions
+34 -11
View File
@@ -172,6 +172,38 @@ Firmen-Gitea nebeneinander.
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt - **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar. ohne Netzverbindung oder bei erreichtem Rate-Limit vollständig nutzbar.
### 4.4 Automatischer Abgleich
Standardmäßig **aus** — jeder Durchlauf verbraucht fremde API-Kontingente, das soll eine
bewusste Entscheidung sein. Einschalten in den Admin-Einstellungen.
| Einstellung | Bedeutung |
| --- | --- |
| Abstand | 15 Minuten bis täglich |
| Projekte je Durchlauf | 1–100, Voreinstellung 25 |
| Fertige und archivierte auslassen | Dort ändert sich selten noch etwas |
Wie der Durchlauf arbeitet:
- Der Zeitgeber läuft im Serverprozess selbst (`instrumentation.ts`), prüft minütlich, ob
ein Durchlauf fällig ist, und startet ihn. Kein zusätzlicher Container, kein externer
Cron. Zwei Durchläufe überschneiden sich nie.
- **Am längsten nicht abgeglichene zuerst.** Was nicht in den Durchlauf passt, kommt beim
nächsten Mal.
- **Zurückhalten nach Fehlschlägen:** Nach jedem Fehlversuch verdoppelt sich der Abstand
bis zum nächsten Versuch (gedeckelt beim 16-fachen). Ein Repository mit abgelaufenem
Token blockiert damit nicht dauerhaft das Kontingent.
- Kurze Pause zwischen zwei Projekten, damit kein Schwall entsteht.
- **Pro Projekt abschaltbar** über einen Schalter im Projektdialog. Manuell
synchronisieren geht unabhängig davon immer.
- Der Adminbereich zeigt letzten und nächsten Durchlauf sowie einen Kurzbericht
(aktualisiert / fehlgeschlagen / zurückgestellt) und hat einen Knopf **Jetzt abgleichen**.
- Wer lieber von außen taktet, lässt den Automatismus aus und ruft
`POST /api/admin/sync/run` per eigenem Cron auf.
Der Abgleich läuft über alle Konten hinweg — er ist ein Serverauftrag, keine
Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte.
- **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs - **URL-Erkennung** für `https://…`, `git@host:owner/repo.git` und GitLab-Weboberflächen-URLs
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten. mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
- **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit. - **Ohne Token** funktionieren öffentliche Repositories, mit deutlich strengerem Rate-Limit.
@@ -270,14 +302,5 @@ Alles, was die Oberfläche kann, ist damit auch scriptbar. Übersicht in der
## 7. Ideen für später ## 7. Ideen für später
Bewusst nicht umgesetzt, aber vorgemerkt: Was als Nächstes kommen könnte, steht mit Aufwandsschätzung und offenen Fragen in
**[ROADMAP.md](ROADMAP.md)** — gedacht zum Durchsprechen und schrittweisen Abarbeiten.
- Kanban-Board mit Drag & Drop zwischen den Statusspalten
- Webhooks, damit Pushes den Snapshot ohne Knopfdruck aktualisieren
- Markdown-Rendering in Beschreibung und Notizen
- Volltextsuche über Notizen hinweg
- Export eines Projekts als Markdown
- Automatischer Sync im Hintergrund (Intervall pro Verbindung)
- Passwort-Zurücksetzen per E-Mail (aktuell setzt ein Administrator das Passwort neu)
- Zwei-Faktor per TOTP als Alternative zum Passkey
- Geteilte Projekte zwischen Konten (heute ist die Trennung absichtlich hart)
+19 -2
View File
@@ -30,7 +30,8 @@ den Admin-Einstellungen.
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter, Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
Access Tokens werden verschlüsselt abgelegt. Access Tokens werden verschlüsselt abgelegt.
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**. Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**.
--- ---
@@ -181,6 +182,21 @@ Nebula auffindbare Credentials nutzt.
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline. Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit. Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
### Automatisch statt auf Knopfdruck
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
Hintergrund verlegen: Abstand von 15 Minuten bis täglich, Anzahl Projekte je Durchlauf,
und wahlweise ohne fertige und archivierte Projekte. Standardmäßig ist er **aus**, weil
jeder Durchlauf API-Kontingent verbraucht.
Der Zeitgeber läuft im Serverprozess — kein zusätzlicher Container, kein externer Cron.
Nach Fehlschlägen wird ein Repository mit wachsendem Abstand zurückgehalten, damit ein
abgelaufener Token nicht dauerhaft Kontingent verbrennt. Einzelne Projekte lassen sich im
Projektdialog ausnehmen; manuell synchronisieren geht immer.
Wer lieber von außen taktet, lässt den Automatismus aus und ruft
`POST /api/admin/sync/run` per eigenem Cron auf.
--- ---
## Projektstruktur ## Projektstruktur
@@ -250,7 +266,8 @@ ausschließlich Daten des angemeldeten Kontos.
| `POST` | `/api/account/password` | Eigenes Passwort setzen | | `POST` | `/api/account/password` | Eigenes Passwort setzen |
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden | | `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
| `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen | | `PATCH` `DELETE` | `/api/account/passkeys/:id` | Passkey umbenennen / entfernen |
| `GET` `PATCH` | `/api/admin/settings` | Betriebsart und Registrierung (Admin) | | `GET` `PATCH` | `/api/admin/settings` | Betriebsart, Registrierung, Abgleich (Admin) |
| `POST` | `/api/admin/sync/run` | Abgleich sofort anstoßen (Admin) |
| `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) | | `GET` `POST` | `/api/admin/users` | Konten auflisten / anlegen (Admin) |
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) | | `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen | | `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
+178
View File
@@ -0,0 +1,178 @@
# Roadmap
Sammlung dessen, was als Nächstes kommen könnte — zum Durchsprechen und
Stück für Stück abarbeiten. Reihenfolge ist ein Vorschlag, keine Festlegung.
**Aufwand:** `S` = überschaubar · `M` = halber Tag · `L` = größerer Brocken
Was bereits steht, listet [FEATURES.md](FEATURES.md).
---
## 1 · Als Nächstes empfohlen
### 1.1 Export und Import `M`
Dein Vorschlag, in vier Stufen — jede für sich nutzbar:
| Stufe | Inhalt |
| --- | --- |
| **Projekt-Export** | Ein Projekt oder alle als JSON: Stammdaten, Notizen, Repo-Verknüpfung. Ein Klick auf der Projektseite, einer im Dashboard für alles. |
| **Projekt-Import** | JSON hochladen, Vorschau, dann übernehmen. Konfliktstrategie wählbar: überspringen · umbenennen · überschreiben. |
| **Markdown-Export** | Ein Projekt als lesbares `.md` — Beschreibung, Notizen, aktueller Git-Stand. Gut zum Archivieren oder für ein README. |
| **Voll-Backup** | `pg_dump` aus dem Container heraus, per Knopf im Adminbereich als Datei. Plus dokumentierter Rückweg. |
**Offene Fragen, die wir klären sollten:**
- **Git-Tokens mitexportieren?** Mein Vorschlag: **nein**. Die Verbindung wird
mitexportiert (Name, System, Basis-URL), das Token muss beim Import neu
hinterlegt werden. Ein Export-JSON landet sonst schnell irgendwo, wo es
nicht hingehört.
- **Reichweite:** Export der eigenen Daten für jeden — Voll-Backup nur für
Administratoren?
- **Snapshots mitnehmen?** Ich würde sie weglassen: sie sind reproduzierbar
und blähen die Datei auf. Ein Sync nach dem Import holt sie zurück.
### 1.2 Markdown in Beschreibung und Notizen `S`
Heute wird alles als reiner Text dargestellt. Mit Markdown werden aus
`- [ ] Aufgabe` echte Checklisten, Codeblöcke bekommen Hervorhebung, Links
werden klickbar.
Zu beachten: HTML muss beim Rendern entfernt werden, sonst reißt es ein
XSS-Loch. Mit `react-markdown` plus `rehype-sanitize` ist das sauber lösbar.
### 1.3 Volltextsuche über Notizen `S`
Die Suche im Board deckt Namen, Kurzbeschreibung und Tags ab — Notizen nicht.
Postgres kann das nativ (`tsvector` mit deutscher Konfiguration), also ohne
zusätzlichen Dienst.
### 1.4 Kanban-Board mit Drag & Drop `M`
Die gruppierte Ansicht gibt es schon, aber Statuswechsel geht nur über das
Menü. Spalten mit Ziehen und Ablegen wären der natürlichere Weg.
Bibliothek: `@dnd-kit` — leichtgewichtig, tastaturbedienbar. Auf Mobilgeräten
bleibt die Listenansicht.
---
## 2 · Danach
### 2.1 Offene Pull Requests und Issues als Liste `S`
Aktuell werden sie nur gezählt. Titel, Autor, Alter und Link wären deutlich
nützlicher — die Daten holen wir ohnehin schon ab.
### 2.2 CI-Status je Branch `M`
Grün oder rot direkt an der Projektkarte, gespeist aus GitHub Actions,
GitLab Pipelines und Gitea Actions. Erfordert je Provider einen weiteren
Endpunkt und einen zusätzlichen Token-Scope.
### 2.3 Webhooks statt Polling `M`
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit
Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen
erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
### 2.4 Commit-Aktivität als Verlauf `S`
Kleine Sparkline auf der Karte, Heatmap auf der Projektseite. Datenbasis ist
da, es fehlt nur die Darstellung.
### 2.5 Aufgaben und Checklisten `M`
Eigene Entität statt Freitext-Notiz, mit Häkchen und automatisch
berechnetem Fortschritt. Ersetzt das manuelle Schieben des Reglers.
### 2.6 Benachrichtigungen `M`
Bei Sync-Fehlern, neuen Releases oder Projekten, die lange stillstehen.
Kanäle: E-Mail (SMTP), ntfy, generischer Webhook. Zusammen mit dem
automatischen Abgleich ergibt das ein brauchbares Frühwarnsystem.
---
## 3 · Größere Brocken
### 3.1 Automatisierte Tests und CI `L`
Aktuell prüfe ich jede Änderung von Hand — Typecheck, Build, API-Durchläufe,
Browser-Screenshots. Das gehört in eine Pipeline:
- **Vitest** für die Logik: URL-Parsing, SSRF-Regeln, Passwort-Hashing,
Backoff-Berechnung
- **Playwright** für die Wege durch die App: Einrichtung, Anmeldung,
Datentrennung
- **GitHub Actions** oder Gitea Actions, je nachdem wohin du das Repo legst
Aus meiner Sicht der wertvollste Punkt der ganzen Liste, sobald das Projekt
länger lebt: er macht alles Weitere billiger und sicherer.
### 3.2 Mehrere Repositories je Projekt `M`
Frontend und Backend getrennt, aber ein Projekt. Erfordert eine Zwischen­tabelle
und Anpassungen in der Anzeige.
### 3.3 Zwei-Faktor per TOTP `M`
Als Alternative zum Passkey — nützlich, wenn die Instanz aus dem Internet
erreichbar ist und nicht jedes Gerät Passkeys kann.
### 3.4 Passwort-Zurücksetzen per E-Mail `M`
Heute setzt ein Administrator das Passwort neu. Selbstbedienung braucht SMTP
und damit eine weitere Konfiguration — lohnt erst im Mehrbenutzerbetrieb.
### 3.5 Mehrsprachigkeit `L`
Die Oberfläche ist durchgängig deutsch verdrahtet. Englisch dazu wäre eine
Voraussetzung, falls du das Projekt veröffentlichen willst.
---
## 4 · Kleinigkeiten
| Idee | Aufwand | Nutzen |
| --- | --- | --- |
| Schnellsuche per `Strg+K` | `S` | Springt direkt zu Projekt oder Aktion |
| Mehrfachauswahl im Board | `S` | Status oder Tags für mehrere auf einmal |
| Aktivitätsverlauf je Projekt | `S` | Wer hat wann was geändert |
| Projektvorlagen | `S` | Neues Projekt mit vorgefüllten Tags und Notizen |
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
| PWA-Manifest | `S` | Auf dem Handy als App installierbar |
| Barrierefreiheits-Durchsicht | `M` | Fokusreihenfolge, Screenreader, Kontraste |
| `prisma.config.ts` | `S` | Beseitigt die Deprecation-Warnung vor Prisma 7 |
| Audit-Log für Adminaktionen | `M` | Nachvollziehbar, wer Konten geändert hat |
---
## 5 · Bewusst nicht
Damit klar ist, was **nicht** kommt und warum:
- **Geteilte Projekte zwischen Konten.** Die Trennung ist derzeit hart und
einfach zu prüfen. Freigaben bringen eine Rechteverwaltung mit sich — nur
sinnvoll, wenn wirklich mehrere Leute zusammenarbeiten.
- **Eigene Git-Operationen** (klonen, committen, pushen). Nebula ist eine
Übersicht, keine Entwicklungsumgebung.
- **Zeiterfassung, Rechnungen, Kundenverwaltung.** Andere Baustelle.
- **Cloud-Synchronisation.** Die Daten bleiben in deiner Datenbank; das ist
der Punkt.
---
## Erledigt
| | |
| --- | --- |
| Dashboard mit Status, Notizen, Git-Anbindung | ✓ |
| GitHub, GitLab und Gitea | ✓ |
| Benutzerverwaltung, Passwort und Passkeys | ✓ |
| Einzel- und Mehrbenutzerbetrieb | ✓ |
| Härtung: CSRF, CSP, SSRF, Ratenbegrenzung, Datentrennung | ✓ |
| Docker- und Standalone-Betrieb | ✓ |
| Automatischer Abgleich mit Zeitplan | ✓ |
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25,
ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60,
ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3),
ADD COLUMN "lastAutoSyncReport" JSONB;
-- AlterTable
ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0;
+24
View File
@@ -137,6 +137,24 @@ model AppSettings {
allowRegistration Boolean @default(false) allowRegistration Boolean @default(false)
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys. /// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
rpName String @default("Nebula") rpName String @default("Nebula")
// ── Automatischer Abgleich mit den Git-Systemen ────────────
/// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente,
/// das soll eine bewusste Entscheidung sein.
autoSyncEnabled Boolean @default(false)
/// Abstand zwischen zwei Durchlaeufen in Minuten.
autoSyncIntervalMinutes Int @default(60)
/// Fertige und archivierte Projekte auslassen – dort aendert sich selten
/// noch etwas.
autoSyncActiveOnly Boolean @default(true)
/// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf
/// einen Schlag in ein Rate-Limit laeuft.
autoSyncBatchSize Int @default(25)
lastAutoSyncAt DateTime?
/// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms }
lastAutoSyncReport Json?
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -207,6 +225,12 @@ model Project {
lastSyncedAt DateTime? lastSyncedAt DateTime?
syncError String? syncError String?
/// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei
/// einem Repo, dessen Token staendig Fehler wirft.
autoSync Boolean @default(true)
/// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach
/// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert.
syncFailureCount Int @default(0)
/// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim
/// Einrichten des ersten Kontos uebernommen werden koennen. /// Einrichten des ersten Kontos uebernommen werden koennen.
+27 -3
View File
@@ -1,5 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { AdminManager, type AdminData } from "@/components/admin/AdminManager"; import {
AdminManager,
type AdminData,
type AutoSyncReport,
} from "@/components/admin/AdminManager";
import { requireAdminPage } from "@/lib/auth/guard"; import { requireAdminPage } from "@/lib/auth/guard";
import { getAppSettings } from "@/lib/auth/settings"; import { getAppSettings } from "@/lib/auth/settings";
import { userSafeSelect } from "@/lib/auth/users"; import { userSafeSelect } from "@/lib/auth/users";
@@ -15,12 +19,23 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function AdminPage() { export default async function AdminPage() {
const admin = await requireAdminPage(); const admin = await requireAdminPage();
const [settings, users] = await Promise.all([ const [settings, users, eligible] = await Promise.all([
getAppSettings(), getAppSettings(),
prisma.user.findMany({ prisma.user.findMany({
select: userSafeSelect, select: userSafeSelect,
orderBy: [{ role: "asc" }, { username: "asc" }], orderBy: [{ role: "asc" }, { username: "asc" }],
}), }),
// Bewusst ueber alle Konten: der Abgleich ist ein Serverauftrag, keine
// Dateneinsicht. Gezaehlt wird nur, nicht gelesen.
prisma.project.count({
where: {
autoSync: true,
OR: [
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
{ repoUrl: { not: null } },
],
},
}),
]); ]);
const data: AdminData = { const data: AdminData = {
@@ -30,6 +45,15 @@ export default async function AdminPage() {
currentUserId: admin.id, currentUserId: admin.id,
appUrl: APP_URL, appUrl: APP_URL,
passkeysAvailable: WEBAUTHN_AVAILABLE, passkeysAvailable: WEBAUTHN_AVAILABLE,
autoSync: {
enabled: settings.autoSyncEnabled,
intervalMinutes: settings.autoSyncIntervalMinutes,
activeOnly: settings.autoSyncActiveOnly,
batchSize: settings.autoSyncBatchSize,
lastRunAt: settings.lastAutoSyncAt,
lastReport: (settings.lastAutoSyncReport as AutoSyncReport | null) ?? null,
eligible,
},
}; };
return ( return (
@@ -42,7 +66,7 @@ export default async function AdminPage() {
Admin-Einstellungen Admin-Einstellungen
</h1> </h1>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-1 text-sm text-slate-400">
Betriebsart, Registrierung und Benutzerkonten. Betriebsart, automatischer Abgleich und Benutzerkonten.
</p> </p>
</header> </header>
+17 -1
View File
@@ -30,7 +30,14 @@ export async function PATCH(request: Request) {
); );
} }
const { mode, allowRegistration } = parsed.data; const {
mode,
allowRegistration,
autoSyncEnabled,
autoSyncIntervalMinutes,
autoSyncActiveOnly,
autoSyncBatchSize,
} = parsed.data;
// Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren – // Der Wechsel in den Einzelbetrieb wuerde weitere Konten aussperren –
// deshalb nur erlauben, wenn es wirklich nur ein Konto gibt. // deshalb nur erlauben, wenn es wirklich nur ein Konto gibt.
@@ -45,18 +52,27 @@ export async function PATCH(request: Request) {
} }
} }
const autoSyncFields = {
...(autoSyncEnabled !== undefined ? { autoSyncEnabled } : {}),
...(autoSyncIntervalMinutes !== undefined ? { autoSyncIntervalMinutes } : {}),
...(autoSyncActiveOnly !== undefined ? { autoSyncActiveOnly } : {}),
...(autoSyncBatchSize !== undefined ? { autoSyncBatchSize } : {}),
};
const settings = await prisma.appSettings.upsert({ const settings = await prisma.appSettings.upsert({
where: { id: "singleton" }, where: { id: "singleton" },
create: { create: {
id: "singleton", id: "singleton",
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}), ...(allowRegistration !== undefined ? { allowRegistration } : {}),
...autoSyncFields,
}, },
update: { update: {
...(mode ? { mode } : {}), ...(mode ? { mode } : {}),
...(allowRegistration !== undefined ? { allowRegistration } : {}), ...(allowRegistration !== undefined ? { allowRegistration } : {}),
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn. // Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}), ...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
...autoSyncFields,
}, },
}); });
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { requireAdminApi } from "@/lib/auth/guard";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, tooManyRequests } from "@/lib/security/request";
import { isAutoSyncRunning, runAutoSyncPass } from "@/lib/sync/scheduler";
export const dynamic = "force-dynamic";
/**
* Stoesst einen Abgleich sofort an – unabhaengig vom Zeitplan.
*
* Dient zwei Zwecken: dem Knopf "Jetzt abgleichen" im Adminbereich und
* einem externen Zeitgeber, falls jemand lieber von aussen taktet als den
* eingebauten Automatismus zu nutzen.
*/
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) return auth.response;
const limited = rateLimit(
`sync-run:${clientIp(request)}`,
LIMITS.outbound.limit,
LIMITS.outbound.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
if (isAutoSyncRunning()) {
return jsonError("Es laeuft bereits ein Abgleich.", 409);
}
const report = await runAutoSyncPass("manuell");
if (!report) {
return jsonError("Es laeuft bereits ein Abgleich.", 409);
}
return NextResponse.json({ report });
}
+2
View File
@@ -69,6 +69,8 @@ export async function POST(request: Request) {
email: email ?? null, email: email ?? null,
passwordHash: await hashPassword(password), passwordHash: await hashPassword(password),
role: "USER", role: "USER",
// Die Registrierung meldet direkt an.
lastLoginAt: new Date(),
}, },
}); });
+3
View File
@@ -49,6 +49,9 @@ export async function POST(request: Request) {
email: email ?? null, email: email ?? null,
passwordHash: await hashPassword(password), passwordHash: await hashPassword(password),
role: "ADMIN", role: "ADMIN",
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
lastLoginAt: new Date(),
}, },
}); });
+1
View File
@@ -74,6 +74,7 @@ export async function PATCH(request: Request, { params }: Params) {
"accent", "accent",
"tags", "tags",
"favorite", "favorite",
"autoSync",
"connectionId", "connectionId",
] as const) { ] as const) {
if (data[key] !== undefined) update[key] = data[key]; if (data[key] !== undefined) update[key] = data[key];
+1
View File
@@ -96,6 +96,7 @@ export async function POST(request: Request) {
accent: data.accent, accent: data.accent,
tags: data.tags, tags: data.tags,
favorite: data.favorite, favorite: data.favorite,
autoSync: data.autoSync,
repoUrl: data.repoUrl ?? null, repoUrl: data.repoUrl ?? null,
repoOwner, repoOwner,
repoName, repoName,
+77
View File
@@ -69,6 +69,26 @@
opacity: 0.85; opacity: 0.85;
} }
/**
* Flaeche fuer alles, was ueber dem Inhalt schwebt: Menues, Dialoge.
*
* Bewusst nahezu deckend statt Glas. Ein Overlay muss lesbar sein, egal
* was darunter liegt - bei 3,5 % Deckkraft schien der Inhalt dahinter
* durch. backdrop-filter hilft hier nicht: liegt das Element in einem
* Vorfahren, der selbst backdrop-filter setzt (unsere Karten), bleibt die
* Weichzeichnung wirkungslos.
*/
.popover {
/* Vollstaendig deckend: bei 97 % schimmerte darunterliegender Text
(die Schaltflaechen "Bearbeiten" und "Loeschen") noch durch. */
background: #090b18;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: 0.9rem;
box-shadow:
0 1px 0 0 rgba(255, 255, 255, 0.07) inset,
0 28px 70px -24px rgba(0, 0, 0, 0.95);
}
/* ── Text & Akzente ───────────────────────────────────────── */ /* ── Text & Akzente ───────────────────────────────────────── */
.text-gradient { .text-gradient {
@@ -190,4 +210,61 @@
background: #0a0c1a; background: #0a0c1a;
color: #e6e8f5; color: #e6e8f5;
} }
/**
* Schieberegler. Der native Regler zeichnet die Spur rechts vom Griff in
* einem hellen Standardton - im dunklen Layout ein grelles Band. Deshalb
* vollstaendig selbst gezeichnet: der gefuellte Anteil kommt aus der
* Variablen --fill, die die Komponente aus dem Wert setzt.
*/
.range {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 9999px;
cursor: pointer;
background: linear-gradient(
90deg,
#8b5cf6 0%,
#d946ef var(--fill, 0%),
rgba(255, 255, 255, 0.1) var(--fill, 0%),
rgba(255, 255, 255, 0.1) 100%
);
}
.range:focus-visible {
outline: 2px solid rgba(167, 139, 250, 0.8);
outline-offset: 4px;
}
.range::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
height: 16px;
width: 16px;
border-radius: 9999px;
background: #f5f3ff;
border: 2px solid #7c3aed;
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
cursor: grab;
}
.range::-webkit-slider-thumb:active {
cursor: grabbing;
}
.range::-moz-range-thumb {
height: 14px;
width: 14px;
border-radius: 9999px;
background: #f5f3ff;
border: 2px solid #7c3aed;
box-shadow: 0 0 14px -2px rgba(168, 85, 247, 0.9);
cursor: grab;
}
.range::-moz-range-track {
background: transparent;
}
} }
+3 -1
View File
@@ -63,7 +63,9 @@ export default async function DashboardPage() {
<StatCard <StatCard
label="Ideen" label="Ideen"
value={ideas} value={ideas}
hint={STATUS_META.IDEA.description} // Kurzer Hinweis statt der langen Statusbeschreibung – die wurde in
// der schmalen Kachel mitten im Wort abgeschnitten.
hint="Noch nichts gebaut"
icon={Lightbulb} icon={Lightbulb}
color={STATUS_META.IDEA.color} color={STATUS_META.IDEA.color}
/> />
+22 -1
View File
@@ -31,6 +31,7 @@ interface FormState {
repoUrl: string; repoUrl: string;
repoOwner: string; repoOwner: string;
repoName: string; repoName: string;
autoSync: boolean;
} }
const EMPTY: FormState = { const EMPTY: FormState = {
@@ -47,6 +48,7 @@ const EMPTY: FormState = {
repoUrl: "", repoUrl: "",
repoOwner: "", repoOwner: "",
repoName: "", repoName: "",
autoSync: true,
}; };
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) { export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
@@ -87,6 +89,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoUrl: project.repoUrl ?? "", repoUrl: project.repoUrl ?? "",
repoOwner: project.repoOwner ?? "", repoOwner: project.repoOwner ?? "",
repoName: project.repoName ?? "", repoName: project.repoName ?? "",
autoSync: project.autoSync,
} }
: EMPTY, : EMPTY,
); );
@@ -154,6 +157,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
repoUrl: form.repoUrl, repoUrl: form.repoUrl,
repoOwner: form.repoOwner, repoOwner: form.repoOwner,
repoName: form.repoName, repoName: form.repoName,
autoSync: form.autoSync,
}; };
try { try {
@@ -287,7 +291,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
step={5} step={5}
value={form.progress} value={form.progress}
onChange={(event) => update("progress", Number(event.target.value))} onChange={(event) => update("progress", Number(event.target.value))}
className="w-full accent-violet-500" className="range"
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
/> />
</div> </div>
@@ -413,6 +418,22 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
</div> </div>
</div> </div>
<label className="flex cursor-pointer items-start gap-2.5 text-sm text-slate-400">
<input
type="checkbox"
checked={form.autoSync}
onChange={(event) => update("autoSync", event.target.checked)}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
Am automatischen Abgleich teilnehmen
<span className="mt-0.5 block text-[11px] text-slate-600">
Wirkt nur, wenn der Abgleich im Adminbereich eingeschaltet ist. Manuell
synchronisieren geht unabhaengig davon immer.
</span>
</span>
</label>
{repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null} {repoError ? <p className="text-xs text-rose-300">{repoError}</p> : null}
{repoPickerOpen ? ( {repoPickerOpen ? (
+4 -9
View File
@@ -193,13 +193,8 @@ export function ProjectHeader({
<span>Fortschritt</span> <span>Fortschritt</span>
<span className="tabular-nums text-slate-300">{progress}%</span> <span className="tabular-nums text-slate-300">{progress}%</span>
</div> </div>
<div className="relative"> {/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6"> gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
<div
className="accent-bar h-full rounded-full transition-[width] duration-300"
style={{ width: `${Math.max(1, progress)}%` }}
/>
</div>
<input <input
type="range" type="range"
min={0} min={0}
@@ -211,10 +206,10 @@ export function ProjectHeader({
onTouchEnd={() => patch({ progress })} onTouchEnd={() => patch({ progress })}
onKeyUp={() => patch({ progress })} onKeyUp={() => patch({ progress })}
aria-label="Fortschritt anpassen" aria-label="Fortschritt anpassen"
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0" className="range"
style={{ "--fill": `${progress}%` } as React.CSSProperties}
/> />
</div> </div>
</div>
<ProjectDialog <ProjectDialog
open={editOpen} open={editOpen}
+13 -20
View File
@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Check, ChevronDown, Loader2 } from "lucide-react"; import { Check, ChevronDown, Loader2 } from "lucide-react";
import type { ProjectStatus } from "@prisma/client"; import type { ProjectStatus } from "@prisma/client";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status"; import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -22,6 +23,7 @@ export function StatusSelect({
onChanged, onChanged,
}: StatusSelectProps) { }: StatusSelectProps) {
const router = useRouter(); const router = useRouter();
const triggerRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [current, setCurrent] = useState<ProjectStatus>(status); const [current, setCurrent] = useState<ProjectStatus>(status);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -54,10 +56,12 @@ export function StatusSelect({
} }
return ( return (
<div className="relative"> <>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={(event) => { onClick={(event) => {
// Die Karte im Board ist ein Link – der darf hier nicht ausloesen.
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
setOpen((value) => !value); setOpen((value) => !value);
@@ -82,21 +86,12 @@ export function StatusSelect({
<ChevronDown className="h-3 w-3 opacity-60" /> <ChevronDown className="h-3 w-3 opacity-60" />
</button> </button>
{open ? ( <Popover
<> anchorRef={triggerRef}
<button open={open}
type="button" onClose={() => setOpen(false)}
aria-label="Auswahl schliessen"
className="fixed inset-0 z-30 cursor-default"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setOpen(false);
}}
/>
<div
role="listbox" role="listbox"
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5" width={264}
> >
{STATUS_ORDER.map((value) => { {STATUS_ORDER.map((value) => {
const option = STATUS_META[value]; const option = STATUS_META[value];
@@ -114,7 +109,7 @@ export function StatusSelect({
}} }}
className={cn( className={cn(
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors", "flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
active ? "bg-white/8" : "hover:bg-white/5", active ? "bg-white/8" : "hover:bg-white/6",
)} )}
> >
<span <span
@@ -133,9 +128,7 @@ export function StatusSelect({
</button> </button>
); );
})} })}
</div> </Popover>
</> </>
) : null}
</div>
); );
} }
+8 -27
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { import {
@@ -12,6 +12,7 @@ import {
Sparkles, Sparkles,
UserRound, UserRound,
} from "lucide-react"; } from "lucide-react";
import { Popover } from "@/components/ui/Popover";
import type { SessionUser } from "@/lib/auth/session"; import type { SessionUser } from "@/lib/auth/session";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -85,23 +86,7 @@ function UserMenu({ user }: { user: SessionUser }) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const containerRef = useRef<HTMLDivElement>(null); const triggerRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!open) return;
function onPointerDown(event: MouseEvent) {
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
async function logout() { async function logout() {
setBusy(true); setBusy(true);
@@ -115,8 +100,9 @@ function UserMenu({ user }: { user: SessionUser }) {
const initials = label.slice(0, 2).toUpperCase(); const initials = label.slice(0, 2).toUpperCase();
return ( return (
<div className="relative" ref={containerRef}> <>
<button <button
ref={triggerRef}
type="button" type="button"
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8" className="flex items-center gap-2 rounded-xl border border-white/10 bg-white/4 py-1.5 pl-1.5 pr-3 text-sm text-slate-200 transition-colors hover:bg-white/8"
@@ -132,11 +118,7 @@ function UserMenu({ user }: { user: SessionUser }) {
) : null} ) : null}
</button> </button>
{open ? ( <Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
<div
role="menu"
className="glass fade-up absolute right-0 z-50 mt-2 w-60 overflow-hidden p-1.5"
>
<div className="border-b border-white/6 px-3 py-2"> <div className="border-b border-white/6 px-3 py-2">
<p className="truncate text-sm text-slate-100">{label}</p> <p className="truncate text-sm text-slate-100">{label}</p>
<p className="truncate text-[11px] text-slate-500"> <p className="truncate text-[11px] text-slate-500">
@@ -177,8 +159,7 @@ function UserMenu({ user }: { user: SessionUser }) {
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Abmelden Abmelden
</button> </button>
</div> </Popover>
) : null} </>
</div>
); );
} }
+273
View File
@@ -10,6 +10,7 @@ import {
Loader2, Loader2,
Lock, Lock,
Plus, Plus,
RefreshCw,
ShieldCheck, ShieldCheck,
Trash2, Trash2,
User, User,
@@ -36,6 +37,16 @@ export interface AdminUser {
_count: { credentials: number; projects: number; sessions: number }; _count: { credentials: number; projects: number; sessions: number };
} }
export interface AutoSyncReport {
at: string;
trigger: "zeitplan" | "manuell";
candidates: number;
synced: number;
failed: number;
skipped: number;
durationMs: number;
}
export interface AdminData { export interface AdminData {
mode: Mode; mode: Mode;
allowRegistration: boolean; allowRegistration: boolean;
@@ -43,6 +54,16 @@ export interface AdminData {
currentUserId: string; currentUserId: string;
appUrl: string; appUrl: string;
passkeysAvailable: boolean; passkeysAvailable: boolean;
autoSync: {
enabled: boolean;
intervalMinutes: number;
activeOnly: boolean;
batchSize: number;
lastRunAt: string | Date | null;
lastReport: AutoSyncReport | null;
/** Projekte mit Repository, die grundsaetzlich in Frage kommen. */
eligible: number;
};
} }
type Notice = { ok: boolean; text: string } | null; type Notice = { ok: boolean; text: string } | null;
@@ -162,6 +183,8 @@ export function AdminManager({ data }: { data: AdminData }) {
) : null} ) : null}
</section> </section>
<AutoSyncSection data={data.autoSync} />
{/* ── Instanz ─────────────────────────────────────── */} {/* ── Instanz ─────────────────────────────────────── */}
<section className="glass p-5"> <section className="glass p-5">
<div className="mb-4 flex items-start gap-3"> <div className="mb-4 flex items-start gap-3">
@@ -264,6 +287,256 @@ export function AdminManager({ data }: { data: AdminData }) {
); );
} }
const INTERVALS = [
{ value: 15, label: "alle 15 Minuten" },
{ value: 30, label: "alle 30 Minuten" },
{ value: 60, label: "stuendlich" },
{ value: 180, label: "alle 3 Stunden" },
{ value: 360, label: "alle 6 Stunden" },
{ value: 720, label: "alle 12 Stunden" },
{ value: 1440, label: "taeglich" },
];
function AutoSyncSection({ data }: { data: AdminData["autoSync"] }) {
const router = useRouter();
const [enabled, setEnabled] = useState(data.enabled);
const [interval, setIntervalMinutes] = useState(data.intervalMinutes);
const [activeOnly, setActiveOnly] = useState(data.activeOnly);
const [batchSize, setBatchSize] = useState(data.batchSize);
const [busy, setBusy] = useState(false);
const [running, setRunning] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
async function patch(payload: Record<string, unknown>) {
setBusy(true);
setNotice(null);
try {
const response = await fetch("/api/admin/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Speichern fehlgeschlagen.");
setNotice({ ok: true, text: "Gespeichert." });
router.refresh();
} catch (caught) {
setEnabled(data.enabled);
setIntervalMinutes(data.intervalMinutes);
setActiveOnly(data.activeOnly);
setBatchSize(data.batchSize);
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setBusy(false);
}
}
async function runNow() {
setRunning(true);
setNotice(null);
try {
const response = await fetch("/api/admin/sync/run", { method: "POST" });
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error ?? "Abgleich fehlgeschlagen.");
const report = body.report as AutoSyncReport;
setNotice({
ok: true,
text:
`${report.synced} aktualisiert, ${report.failed} fehlgeschlagen, ` +
`${report.skipped} zurueckgestellt (${Math.round(report.durationMs / 1000)} s).`,
});
router.refresh();
} catch (caught) {
setNotice({ ok: false, text: caught instanceof Error ? caught.message : String(caught) });
} finally {
setRunning(false);
}
}
const nextRun =
enabled && data.lastRunAt
? new Date(new Date(data.lastRunAt).getTime() + interval * 60_000)
: null;
return (
<section className="glass p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="flex items-start gap-3">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-xl bg-cyan-500/12 text-cyan-300">
<RefreshCw className="h-4 w-4" />
</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-200">
Automatischer Abgleich
</h2>
<p className="mt-1 max-w-xl text-sm text-slate-400">
Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle
Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was
ohnehin verknuepft ist.
</p>
</div>
</div>
<button
type="button"
className="btn btn-ghost px-3 py-1.5 text-xs"
onClick={runNow}
disabled={running || data.eligible === 0}
title={data.eligible === 0 ? "Kein Projekt mit Repository verknuepft" : undefined}
>
{running ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
Jetzt abgleichen
</button>
</div>
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<input
type="checkbox"
checked={enabled}
disabled={busy}
onChange={(event) => {
setEnabled(event.target.checked);
void patch({ autoSyncEnabled: event.target.checked });
}}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">Regelmaessig abgleichen</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
Standardmaessig aus. Jeder Durchlauf verbraucht Kontingent bei GitHub,
GitLab und Gitea – ohne Token ist das schnell erschoepft.
</span>
</span>
</label>
{enabled ? (
<>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="field-label" htmlFor="autosync-interval">
Abstand
</label>
<select
id="autosync-interval"
className="field"
value={interval}
disabled={busy}
onChange={(event) => {
const value = Number(event.target.value);
setIntervalMinutes(value);
void patch({ autoSyncIntervalMinutes: value });
}}
>
{INTERVALS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label className="field-label" htmlFor="autosync-batch">
Projekte je Durchlauf: {batchSize}
</label>
<input
id="autosync-batch"
type="range"
min={1}
max={100}
step={1}
value={batchSize}
disabled={busy}
onChange={(event) => setBatchSize(Number(event.target.value))}
onMouseUp={() => patch({ autoSyncBatchSize: batchSize })}
onTouchEnd={() => patch({ autoSyncBatchSize: batchSize })}
onKeyUp={() => patch({ autoSyncBatchSize: batchSize })}
className="range mt-2"
style={{ "--fill": `${batchSize}%` } as React.CSSProperties}
/>
<p className="mt-1.5 text-[11px] text-slate-500">
Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal.
</p>
</div>
</div>
<label className="mt-3 flex cursor-pointer items-start gap-3 rounded-xl border border-white/8 bg-black/20 px-3 py-3">
<input
type="checkbox"
checked={activeOnly}
disabled={busy}
onChange={(event) => {
setActiveOnly(event.target.checked);
void patch({ autoSyncActiveOnly: event.target.checked });
}}
className="mt-0.5 h-4 w-4 accent-violet-500"
/>
<span>
<span className="block text-sm text-slate-200">
Fertige und archivierte Projekte auslassen
</span>
<span className="mt-0.5 block text-[11px] text-slate-500">
Dort aendert sich selten noch etwas.
</span>
</span>
</label>
</>
) : null}
{/* Zustand */}
<dl className="mt-4 grid gap-3 sm:grid-cols-3">
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Verknuepfte Projekte
</dt>
<dd className="mt-1 text-sm tabular-nums text-slate-200">{data.eligible}</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Letzter Durchlauf
</dt>
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
{data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"}
</dd>
</div>
<div className="glass-soft px-3 py-2.5">
<dt className="text-[11px] uppercase tracking-wider text-slate-500">
Naechster Durchlauf
</dt>
<dd className="mt-1 text-sm text-slate-200" suppressHydrationWarning>
{enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"}
</dd>
</div>
</dl>
{data.lastReport ? (
<p className="mt-3 text-[11px] text-slate-500" suppressHydrationWarning>
Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "}
{data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt
in {Math.round(data.lastReport.durationMs / 1000)} s.
</p>
) : null}
{notice ? (
<p
className={cn(
"mt-3 rounded-lg border px-3 py-2 text-sm",
notice.ok
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-rose-500/30 bg-rose-500/10 text-rose-200",
)}
>
{notice.text}
</p>
) : null}
</section>
);
}
function ModeCard({ function ModeCard({
active, active,
disabled, disabled,
+21 -7
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -14,6 +15,14 @@ interface ModalProps {
width?: "md" | "lg" | "xl"; width?: "md" | "lg" | "xl";
} }
/**
* Dialog per Portal am body.
*
* Ohne Portal waere "position: fixed" wirkungslos: Dialoge werden aus Karten
* heraus geoeffnet, und deren backdrop-filter erzeugt einen neuen
* Bezugsrahmen. Der Dialog wuerde sich dann an der Karte ausrichten statt am
* Fenster.
*/
export function Modal({ export function Modal({
open, open,
title, title,
@@ -23,6 +32,10 @@ export function Modal({
footer, footer,
width = "lg", width = "lg",
}: ModalProps) { }: ModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKey = (event: KeyboardEvent) => { const onKey = (event: KeyboardEvent) => {
@@ -37,22 +50,22 @@ export function Modal({
}; };
}, [open, onClose]); }, [open, onClose]);
if (!open) return null; if (!mounted || !open) return null;
return ( return createPortal(
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8"> <div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
<button <button
type="button" type="button"
aria-label="Dialog schliessen" aria-label="Dialog schliessen"
onClick={onClose} onClick={onClose}
className="fixed inset-0 cursor-default bg-[#03040c]/80 backdrop-blur-sm" className="fixed inset-0 cursor-default bg-[#03040c]/85 backdrop-blur-sm"
/> />
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={title} aria-label={title}
className={cn( className={cn(
"glass fade-up relative z-10 my-auto w-full p-6", "popover fade-up relative z-10 my-auto w-full rounded-2xl p-6",
width === "md" && "max-w-lg", width === "md" && "max-w-lg",
width === "lg" && "max-w-2xl", width === "lg" && "max-w-2xl",
width === "xl" && "max-w-4xl", width === "xl" && "max-w-4xl",
@@ -79,6 +92,7 @@ export function Modal({
{footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null} {footer ? <div className="mt-6 flex justify-end gap-2">{footer}</div> : null}
</div> </div>
</div> </div>,
document.body,
); );
} }
+141
View File
@@ -0,0 +1,141 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
/**
* Schwebendes Menue, das per Portal am body haengt.
*
* Warum nicht einfach ein absolut positioniertes Kind?
*
* 1. Unsere Karten setzen overflow-hidden (fuer den Akzentstreifen und den
* Farbverlaufsrand). Ein Kind wuerde daran abgeschnitten.
* 2. .glass setzt backdrop-filter, und die Projektkarte bekommt beim Hovern
* ein transform. Beides erzeugt einen Stacking-Context und einen neuen
* Bezugsrahmen: z-index wirkt dann nur noch innerhalb der Karte, und
* selbst position: fixed bezieht sich auf die Karte statt auf das
* Fenster.
*
* Am body haengend entfaellt beides. Die Position wird aus dem Ankerelement
* berechnet und beim Scrollen und bei Groessenaenderungen nachgefuehrt.
*/
export function Popover({
anchorRef,
open,
onClose,
children,
align = "end",
width = 240,
className,
labelledBy,
role = "menu",
}: {
anchorRef: React.RefObject<HTMLElement | null>;
open: boolean;
onClose: () => void;
children: React.ReactNode;
/** "end" richtet die rechte Kante am Anker aus. */
align?: "start" | "end";
width?: number;
className?: string;
labelledBy?: string;
role?: "menu" | "listbox" | "dialog";
}) {
const panelRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
useEffect(() => setMounted(true), []);
const place = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const margin = 8;
const gap = 6;
// Bevorzugte Ausrichtung, bei Ueberlauf auf die andere Seite wechseln.
// Nur Festklemmen am Fensterrand waere schlecht: bei einem Ausloeser weit
// links stuende das Menue sonst quer ueber der Seite statt an seinem
// Ausloeser.
let left = align === "end" ? rect.right - width : rect.left;
if (left < margin) left = rect.left;
if (left + width > window.innerWidth - margin) left = rect.right - width;
left = Math.min(Math.max(margin, left), window.innerWidth - width - margin);
const panelHeight = panelRef.current?.offsetHeight ?? 0;
let top = rect.bottom + gap;
// Nach oben klappen, wenn unten kein Platz ist, oben aber schon.
const fitsBelow = top + panelHeight <= window.innerHeight - margin;
const fitsAbove = rect.top - gap - panelHeight >= margin;
if (!fitsBelow && fitsAbove) top = rect.top - gap - panelHeight;
setPosition({ top, left });
}, [anchorRef, align, width]);
// Bewusst useEffect statt useLayoutEffect: Letzteres warnt beim
// Server-Rendering. Ein Aufblitzen an falscher Stelle gibt es trotzdem
// nicht, weil das Panel bis zur berechneten Position unsichtbar bleibt.
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
place();
}, [open, place]);
useEffect(() => {
if (!open) return;
// capture: true, damit auch das Scrollen innerer Container ankommt.
const onScroll = () => place();
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onScroll);
function onPointerDown(event: MouseEvent) {
const target = event.target as Node;
if (panelRef.current?.contains(target)) return;
if (anchorRef.current?.contains(target)) return;
onClose();
}
function onKey(event: KeyboardEvent) {
if (event.key === "Escape") onClose();
}
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onScroll);
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKey);
};
}, [open, place, onClose, anchorRef]);
if (!mounted || !open) return null;
return createPortal(
<div
ref={panelRef}
role={role}
aria-labelledby={labelledBy}
style={{
position: "fixed",
top: position?.top ?? -9999,
left: position?.left ?? -9999,
width,
// Bis die Position steht, unsichtbar halten – sonst blitzt das
// Menue kurz an der falschen Stelle auf.
visibility: position ? "visible" : "hidden",
}}
className={cn("popover fade-up z-[100] overflow-hidden p-1.5", className)}
>
{children}
</div>,
document.body,
);
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Wird von Next einmal beim Start des Servers ausgefuehrt.
*
* Hier haengt der Zeitgeber fuer den automatischen Abgleich.
*
* Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den
* Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto
* noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der
* positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim
* Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh
* abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der
* Build schluege fehl.
*/
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { describeConfig } = await import("@/lib/config");
console.log(`[nebula] Konfiguration: ${describeConfig()}`);
const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler");
startAutoSyncScheduler();
}
}
+7 -1
View File
@@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
await prisma.project.update({ await prisma.project.update({
where: { id: project.id }, where: { id: project.id },
data: { syncError: message, lastSyncedAt: new Date() }, data: {
syncError: message,
lastSyncedAt: new Date(),
// Zaehler fuers Zurueckhalten beim automatischen Abgleich.
syncFailureCount: { increment: 1 },
},
}); });
return { ok: false, message }; return { ok: false, message };
} }
@@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
data: { data: {
lastSyncedAt: new Date(), lastSyncedAt: new Date(),
syncError: null, syncError: null,
syncFailureCount: 0,
defaultBranch: data.defaultBranch, defaultBranch: data.defaultBranch,
repoUrl: data.htmlUrl ?? undefined, repoUrl: data.htmlUrl ?? undefined,
}, },
+171
View File
@@ -0,0 +1,171 @@
import type { Prisma } from "@prisma/client";
import { getAppSettings } from "@/lib/auth/settings";
import { prisma } from "@/lib/db";
import { syncProject } from "@/lib/sync";
/**
* Automatischer Abgleich mit den Git-Systemen.
*
* Laeuft im Serverprozess selbst, angestossen von instrumentation.ts. Fuer
* eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein
* zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration.
* Wer lieber von aussen taktet, ruft stattdessen POST /api/admin/sync/run
* auf und laesst den Automatismus aus.
*
* Ruecksicht auf fremde Kontingente:
* - je Durchlauf nur eine begrenzte Zahl Projekte
* - die am laengsten nicht abgeglichenen zuerst
* - nach Fehlschlaegen wird zurueckgehalten, mit wachsendem Abstand
* - kurze Pause zwischen den Projekten
*/
const TICK_MS = 60_000;
/** Kleine Verschnaufpause zwischen zwei Projekten. */
const PAUSE_MS = 400;
/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */
const MAX_BACKOFF_STEPS = 4;
export interface AutoSyncReport {
at: string;
trigger: "zeitplan" | "manuell";
candidates: number;
synced: number;
failed: number;
/** Wegen Zurueckhaltung nach Fehlschlaegen uebersprungen. */
skipped: number;
durationMs: number;
}
// Ueber globalThis, damit der Hot-Reload im Entwicklungsbetrieb nicht bei
// jedem Neuladen einen weiteren Zeitgeber startet.
const globalForScheduler = globalThis as unknown as {
nebulaAutoSyncTimer?: NodeJS.Timeout;
nebulaAutoSyncRunning?: boolean;
};
export function startAutoSyncScheduler() {
if (globalForScheduler.nebulaAutoSyncTimer) return;
const timer = setInterval(() => {
void tick();
}, TICK_MS);
// Der Zeitgeber darf den Prozess nicht am Beenden hindern.
timer.unref?.();
globalForScheduler.nebulaAutoSyncTimer = timer;
// Kurz nach dem Start einmal nachsehen, statt eine volle Minute zu warten.
const initial = setTimeout(() => void tick(), 20_000);
initial.unref?.();
console.log("[nebula] Zeitgeber fuer den automatischen Abgleich laeuft.");
}
/** Prueft, ob ein Durchlauf faellig ist, und startet ihn gegebenenfalls. */
async function tick() {
try {
const settings = await getAppSettings();
if (!settings.autoSyncEnabled) return;
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
if (Date.now() - last < intervalMs) return;
await runAutoSyncPass("zeitplan");
} catch (error) {
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
}
}
/**
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
* waehrend eines laufenden Durchlaufs bekommt null zurueck.
*/
export async function runAutoSyncPass(
trigger: AutoSyncReport["trigger"],
): Promise<AutoSyncReport | null> {
if (globalForScheduler.nebulaAutoSyncRunning) return null;
globalForScheduler.nebulaAutoSyncRunning = true;
const startedAt = Date.now();
let synced = 0;
let failed = 0;
let skipped = 0;
try {
const settings = await getAppSettings();
const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize));
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
const where: Prisma.ProjectWhereInput = {
autoSync: true,
// Ohne Repository gibt es nichts abzugleichen.
OR: [
{ AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] },
{ repoUrl: { not: null } },
],
...(settings.autoSyncActiveOnly
? { status: { notIn: ["DONE", "ARCHIVED"] } }
: {}),
};
// Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer.
const candidates = await prisma.project.findMany({
where,
orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }],
take: batchSize * 4,
select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true },
});
const due = candidates.filter((project) => {
if (project.syncFailureCount === 0) return true;
if (!project.lastSyncedAt) return true;
// Nach jedem Fehlschlag doppelt so lange warten, gedeckelt.
const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS);
const wait = intervalMs * 2 ** steps;
const ready = Date.now() - project.lastSyncedAt.getTime() >= wait;
if (!ready) skipped += 1;
return ready;
});
const batch = due.slice(0, batchSize);
for (const project of batch) {
const result = await syncProject(project.id);
if (result.ok) synced += 1;
else failed += 1;
if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS));
}
const report: AutoSyncReport = {
at: new Date().toISOString(),
trigger,
candidates: candidates.length,
synced,
failed,
skipped,
durationMs: Date.now() - startedAt,
};
await prisma.appSettings.update({
where: { id: "singleton" },
data: {
lastAutoSyncAt: new Date(),
lastAutoSyncReport: report as unknown as Prisma.InputJsonValue,
},
});
console.log(
`[nebula] Abgleich (${trigger}): ${synced} aktualisiert, ${failed} fehlgeschlagen, ` +
`${skipped} zurueckgestellt, ${report.durationMs} ms`,
);
return report;
} finally {
globalForScheduler.nebulaAutoSyncRunning = false;
}
}
export function isAutoSyncRunning(): boolean {
return Boolean(globalForScheduler.nebulaAutoSyncRunning);
}
+7
View File
@@ -21,6 +21,8 @@ export const projectCreateSchema = z.object({
repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), repoOwner: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()), repoName: z.preprocess(emptyToNull, z.string().trim().max(200).nullable().optional()),
connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()), connectionId: z.preprocess(emptyToNull, z.string().trim().nullable().optional()),
/** Am automatischen Abgleich teilnehmen. */
autoSync: z.boolean().default(true),
}); });
export const projectUpdateSchema = projectCreateSchema.partial(); export const projectUpdateSchema = projectCreateSchema.partial();
@@ -107,6 +109,11 @@ export const profileUpdateSchema = z.object({
export const adminSettingsSchema = z.object({ export const adminSettingsSchema = z.object({
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(), mode: z.enum(["SINGLE_USER", "MULTI_USER"]).optional(),
allowRegistration: z.boolean().optional(), allowRegistration: z.boolean().optional(),
autoSyncEnabled: z.boolean().optional(),
/** Untergrenze 5 Minuten – haeufiger waere den Git-APIs gegenueber unfair. */
autoSyncIntervalMinutes: z.coerce.number().int().min(5).max(10080).optional(),
autoSyncActiveOnly: z.boolean().optional(),
autoSyncBatchSize: z.coerce.number().int().min(1).max(200).optional(),
}); });
export const adminUserCreateSchema = z.object({ export const adminUserCreateSchema = z.object({