Compare commits
5
Commits
ca26b9d0ce
...
f55e5ff2fe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f55e5ff2fe | ||
|
|
6f0d6e1ca9 | ||
|
|
8e566d06e0 | ||
|
|
4ba53a239f | ||
|
|
dcf9b0d58d |
+34
-11
@@ -172,6 +172,38 @@ Firmen-Gitea nebeneinander.
|
||||
|
||||
- **Snapshot-Prinzip**: Alle Daten werden in der Datenbank abgelegt. Das Dashboard bleibt
|
||||
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
|
||||
mit `/-/tree/…`; verschachtelte Gruppenpfade bleiben erhalten.
|
||||
- **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
|
||||
|
||||
Bewusst nicht umgesetzt, aber vorgemerkt:
|
||||
|
||||
- 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)
|
||||
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.
|
||||
|
||||
@@ -30,7 +30,8 @@ den Admin-Einstellungen.
|
||||
Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst dahinter,
|
||||
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.
|
||||
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
|
||||
@@ -250,7 +266,8 @@ ausschließlich Daten des angemeldeten Kontos.
|
||||
| `POST` | `/api/account/password` | Eigenes Passwort setzen |
|
||||
| `GET` `DELETE` | `/api/account/sessions` | Eigene Sitzungen / überall abmelden |
|
||||
| `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) |
|
||||
| `PATCH` `DELETE` | `/api/admin/users/:id` | Konto ändern / löschen (Admin) |
|
||||
| `GET` `POST` | `/api/projects` | Projekte auflisten / anlegen |
|
||||
|
||||
+178
@@ -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 Zwischentabelle
|
||||
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;
|
||||
+25
-1
@@ -137,7 +137,25 @@ model AppSettings {
|
||||
allowRegistration Boolean @default(false)
|
||||
/// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys.
|
||||
rpName String @default("Nebula")
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// ── 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
|
||||
}
|
||||
|
||||
/// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token.
|
||||
@@ -207,6 +225,12 @@ model Project {
|
||||
|
||||
lastSyncedAt DateTime?
|
||||
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
|
||||
/// Einrichten des ersten Kontos uebernommen werden koennen.
|
||||
|
||||
+27
-3
@@ -1,5 +1,9 @@
|
||||
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 { getAppSettings } from "@/lib/auth/settings";
|
||||
import { userSafeSelect } from "@/lib/auth/users";
|
||||
@@ -15,12 +19,23 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function AdminPage() {
|
||||
const admin = await requireAdminPage();
|
||||
|
||||
const [settings, users] = await Promise.all([
|
||||
const [settings, users, eligible] = await Promise.all([
|
||||
getAppSettings(),
|
||||
prisma.user.findMany({
|
||||
select: userSafeSelect,
|
||||
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 = {
|
||||
@@ -30,6 +45,15 @@ export default async function AdminPage() {
|
||||
currentUserId: admin.id,
|
||||
appUrl: APP_URL,
|
||||
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 (
|
||||
@@ -42,7 +66,7 @@ export default async function AdminPage() {
|
||||
Admin-Einstellungen
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Betriebsart, Registrierung und Benutzerkonten.
|
||||
Betriebsart, automatischer Abgleich und Benutzerkonten.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -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 –
|
||||
// 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({
|
||||
where: { id: "singleton" },
|
||||
create: {
|
||||
id: "singleton",
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
...autoSyncFields,
|
||||
},
|
||||
update: {
|
||||
...(mode ? { mode } : {}),
|
||||
...(allowRegistration !== undefined ? { allowRegistration } : {}),
|
||||
// Im Einzelbetrieb ergibt eine offene Registrierung keinen Sinn.
|
||||
...(mode === "SINGLE_USER" ? { allowRegistration: false } : {}),
|
||||
...autoSyncFields,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -69,6 +69,8 @@ export async function POST(request: Request) {
|
||||
email: email ?? null,
|
||||
passwordHash: await hashPassword(password),
|
||||
role: "USER",
|
||||
// Die Registrierung meldet direkt an.
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ export async function POST(request: Request) {
|
||||
email: email ?? null,
|
||||
passwordHash: await hashPassword(password),
|
||||
role: "ADMIN",
|
||||
// Die Einrichtung meldet direkt an – sonst stuende im Adminbereich
|
||||
// "noch nie angemeldet", obwohl gerade eine Sitzung laeuft.
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -74,6 +74,7 @@ export async function PATCH(request: Request, { params }: Params) {
|
||||
"accent",
|
||||
"tags",
|
||||
"favorite",
|
||||
"autoSync",
|
||||
"connectionId",
|
||||
] as const) {
|
||||
if (data[key] !== undefined) update[key] = data[key];
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function POST(request: Request) {
|
||||
accent: data.accent,
|
||||
tags: data.tags,
|
||||
favorite: data.favorite,
|
||||
autoSync: data.autoSync,
|
||||
repoUrl: data.repoUrl ?? null,
|
||||
repoOwner,
|
||||
repoName,
|
||||
|
||||
@@ -69,6 +69,26 @@
|
||||
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-gradient {
|
||||
@@ -190,4 +210,61 @@
|
||||
background: #0a0c1a;
|
||||
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
@@ -63,7 +63,9 @@ export default async function DashboardPage() {
|
||||
<StatCard
|
||||
label="Ideen"
|
||||
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}
|
||||
color={STATUS_META.IDEA.color}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ interface FormState {
|
||||
repoUrl: string;
|
||||
repoOwner: string;
|
||||
repoName: string;
|
||||
autoSync: boolean;
|
||||
}
|
||||
|
||||
const EMPTY: FormState = {
|
||||
@@ -47,6 +48,7 @@ const EMPTY: FormState = {
|
||||
repoUrl: "",
|
||||
repoOwner: "",
|
||||
repoName: "",
|
||||
autoSync: true,
|
||||
};
|
||||
|
||||
export function ProjectDialog({ open, onClose, connections, project }: ProjectDialogProps) {
|
||||
@@ -87,6 +89,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
repoUrl: project.repoUrl ?? "",
|
||||
repoOwner: project.repoOwner ?? "",
|
||||
repoName: project.repoName ?? "",
|
||||
autoSync: project.autoSync,
|
||||
}
|
||||
: EMPTY,
|
||||
);
|
||||
@@ -154,6 +157,7 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
repoUrl: form.repoUrl,
|
||||
repoOwner: form.repoOwner,
|
||||
repoName: form.repoName,
|
||||
autoSync: form.autoSync,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -287,7 +291,8 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
step={5}
|
||||
value={form.progress}
|
||||
onChange={(event) => update("progress", Number(event.target.value))}
|
||||
className="w-full accent-violet-500"
|
||||
className="range"
|
||||
style={{ "--fill": `${form.progress}%` } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -413,6 +418,22 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi
|
||||
</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}
|
||||
|
||||
{repoPickerOpen ? (
|
||||
|
||||
@@ -193,27 +193,22 @@ export function ProjectHeader({
|
||||
<span>Fortschritt</span>
|
||||
<span className="tabular-nums text-slate-300">{progress}%</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-white/6">
|
||||
<div
|
||||
className="accent-bar h-full rounded-full transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(1, progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={progress}
|
||||
onChange={(event) => setProgress(Number(event.target.value))}
|
||||
onMouseUp={() => patch({ progress })}
|
||||
onTouchEnd={() => patch({ progress })}
|
||||
onKeyUp={() => patch({ progress })}
|
||||
aria-label="Fortschritt anpassen"
|
||||
className="absolute inset-0 h-2 w-full cursor-pointer opacity-0"
|
||||
/>
|
||||
</div>
|
||||
{/* Eigener Regler statt eines unsichtbaren Eingabefelds ueber einem
|
||||
gezeichneten Balken: so gibt es einen sichtbaren Griff. */}
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={progress}
|
||||
onChange={(event) => setProgress(Number(event.target.value))}
|
||||
onMouseUp={() => patch({ progress })}
|
||||
onTouchEnd={() => patch({ progress })}
|
||||
onKeyUp={() => patch({ progress })}
|
||||
aria-label="Fortschritt anpassen"
|
||||
className="range"
|
||||
style={{ "--fill": `${progress}%` } as React.CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProjectDialog
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Check, ChevronDown, Loader2 } from "lucide-react";
|
||||
import type { ProjectStatus } from "@prisma/client";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import { STATUS_META, STATUS_ORDER, statusOf } from "@/lib/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,6 +23,7 @@ export function StatusSelect({
|
||||
onChanged,
|
||||
}: StatusSelectProps) {
|
||||
const router = useRouter();
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [current, setCurrent] = useState<ProjectStatus>(status);
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -54,10 +56,12 @@ export function StatusSelect({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
// Die Karte im Board ist ein Link – der darf hier nicht ausloesen.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen((value) => !value);
|
||||
@@ -82,60 +86,49 @@ export function StatusSelect({
|
||||
<ChevronDown className="h-3 w-3 opacity-60" />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Auswahl schliessen"
|
||||
className="fixed inset-0 z-30 cursor-default"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
role="listbox"
|
||||
className="glass fade-up absolute right-0 z-40 mt-2 w-60 overflow-hidden p-1.5"
|
||||
>
|
||||
{STATUS_ORDER.map((value) => {
|
||||
const option = STATUS_META[value];
|
||||
const active = value === current;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void select(value);
|
||||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-100">
|
||||
{option.label}
|
||||
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Popover
|
||||
anchorRef={triggerRef}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
role="listbox"
|
||||
width={264}
|
||||
>
|
||||
{STATUS_ORDER.map((value) => {
|
||||
const option = STATUS_META[value];
|
||||
const active = value === current;
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void select(value);
|
||||
}}
|
||||
className={cn(
|
||||
"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/6",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="mt-1.5 h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-sm text-slate-100">
|
||||
{option.label}
|
||||
{active ? <Check className="h-3 w-3 text-violet-300" /> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[11px] leading-snug text-slate-500">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+41
-60
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Sparkles,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { Popover } from "@/components/ui/Popover";
|
||||
import type { SessionUser } from "@/lib/auth/session";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -85,23 +86,7 @@ function UserMenu({ user }: { user: SessionUser }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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]);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
async function logout() {
|
||||
setBusy(true);
|
||||
@@ -115,8 +100,9 @@ function UserMenu({ user }: { user: SessionUser }) {
|
||||
const initials = label.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={containerRef}>
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
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"
|
||||
@@ -132,53 +118,48 @@ function UserMenu({ user }: { user: SessionUser }) {
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<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">
|
||||
<p className="truncate text-sm text-slate-100">{label}</p>
|
||||
<p className="truncate text-[11px] text-slate-500">
|
||||
@{user.username}
|
||||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Popover anchorRef={triggerRef} open={open} onClose={() => setOpen(false)} width={240}>
|
||||
<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-[11px] text-slate-500">
|
||||
@{user.username}
|
||||
{user.role === "ADMIN" ? " · Administrator" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/account"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Mein Konto
|
||||
</Link>
|
||||
|
||||
{user.role === "ADMIN" ? (
|
||||
<Link
|
||||
href="/account"
|
||||
href="/admin"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="mt-1 flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
Mein Konto
|
||||
<Settings className="h-4 w-4" />
|
||||
Admin-Einstellungen
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{user.role === "ADMIN" ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-white/6 hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Admin-Einstellungen
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={logout}
|
||||
disabled={busy}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-sm text-rose-200 transition-colors hover:bg-rose-500/10 disabled:opacity-50"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Abmelden
|
||||
</button>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
Lock,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
User,
|
||||
@@ -36,6 +37,16 @@ export interface AdminUser {
|
||||
_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 {
|
||||
mode: Mode;
|
||||
allowRegistration: boolean;
|
||||
@@ -43,6 +54,16 @@ export interface AdminData {
|
||||
currentUserId: string;
|
||||
appUrl: string;
|
||||
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;
|
||||
@@ -162,6 +183,8 @@ export function AdminManager({ data }: { data: AdminData }) {
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<AutoSyncSection data={data.autoSync} />
|
||||
|
||||
{/* ── Instanz ─────────────────────────────────────── */}
|
||||
<section className="glass p-5">
|
||||
<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({
|
||||
active,
|
||||
disabled,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -14,6 +15,14 @@ interface ModalProps {
|
||||
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({
|
||||
open,
|
||||
title,
|
||||
@@ -23,6 +32,10 @@ export function Modal({
|
||||
footer,
|
||||
width = "lg",
|
||||
}: ModalProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
@@ -37,22 +50,22 @@ export function Modal({
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[120] flex items-start justify-center overflow-y-auto p-4 sm:p-8">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dialog schliessen"
|
||||
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
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
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 === "lg" && "max-w-2xl",
|
||||
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}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise<SyncResult> {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await prisma.project.update({
|
||||
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 };
|
||||
}
|
||||
@@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) {
|
||||
data: {
|
||||
lastSyncedAt: new Date(),
|
||||
syncError: null,
|
||||
syncFailureCount: 0,
|
||||
defaultBranch: data.defaultBranch,
|
||||
repoUrl: data.htmlUrl ?? undefined,
|
||||
},
|
||||
@@ -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);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export const projectCreateSchema = z.object({
|
||||
repoOwner: 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()),
|
||||
/** Am automatischen Abgleich teilnehmen. */
|
||||
autoSync: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const projectUpdateSchema = projectCreateSchema.partial();
|
||||
@@ -107,6 +109,11 @@ export const profileUpdateSchema = z.object({
|
||||
export const adminSettingsSchema = z.object({
|
||||
mode: z.enum(["SINGLE_USER", "MULTI_USER"]).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({
|
||||
|
||||
Reference in New Issue
Block a user