From 4f2c221db64d7665bb087bc8549f2d00b9974ee8 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 23:38:19 +0200 Subject: [PATCH] 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) --- src/app/admin/page.tsx | 30 ++- src/app/api/admin/settings/route.ts | 18 +- src/app/api/admin/sync/run/route.ts | 37 ++++ src/app/api/projects/[id]/route.ts | 1 + src/app/api/projects/route.ts | 1 + src/components/ProjectDialog.tsx | 20 ++ src/components/admin/AdminManager.tsx | 273 ++++++++++++++++++++++++++ src/lib/validation.ts | 7 + 8 files changed, 383 insertions(+), 4 deletions(-) create mode 100644 src/app/api/admin/sync/run/route.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f3858c2..5054736 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 { 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

- Betriebsart, Registrierung und Benutzerkonten. + Betriebsart, automatischer Abgleich und Benutzerkonten.

diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 8dbdd10..72a707d 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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, }, }); diff --git a/src/app/api/admin/sync/run/route.ts b/src/app/api/admin/sync/run/route.ts new file mode 100644 index 0000000..c53f756 --- /dev/null +++ b/src/app/api/admin/sync/run/route.ts @@ -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 }); +} diff --git a/src/app/api/projects/[id]/route.ts b/src/app/api/projects/[id]/route.ts index c3ca068..781a5cb 100644 --- a/src/app/api/projects/[id]/route.ts +++ b/src/app/api/projects/[id]/route.ts @@ -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]; diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index f57c4b6..dad912a 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -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, diff --git a/src/components/ProjectDialog.tsx b/src/components/ProjectDialog.tsx index 994d5e1..8d52aca 100644 --- a/src/components/ProjectDialog.tsx +++ b/src/components/ProjectDialog.tsx @@ -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 { @@ -414,6 +418,22 @@ export function ProjectDialog({ open, onClose, connections, project }: ProjectDi + + {repoError ?

{repoError}

: null} {repoPickerOpen ? ( diff --git a/src/components/admin/AdminManager.tsx b/src/components/admin/AdminManager.tsx index 748b360..0adb0d6 100644 --- a/src/components/admin/AdminManager.tsx +++ b/src/components/admin/AdminManager.tsx @@ -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} + + {/* ── Instanz ─────────────────────────────────────── */}
@@ -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(null); + + async function patch(payload: Record) { + 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 ( +
+
+
+ + + +
+

+ Automatischer Abgleich +

+

+ Holt Branches, Tags, Commits und Releases im Hintergrund. Gilt fuer alle + Konten – die Daten bleiben dabei getrennt, abgeglichen wird nur, was + ohnehin verknuepft ist. +

+
+
+ + +
+ + + + {enabled ? ( + <> +
+
+ + +
+ +
+ + 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} + /> +

+ Am laengsten nicht abgeglichene zuerst. Der Rest kommt beim naechsten Mal. +

+
+
+ + + + ) : null} + + {/* Zustand */} +
+
+
+ Verknuepfte Projekte +
+
{data.eligible}
+
+
+
+ Letzter Durchlauf +
+
+ {data.lastRunAt ? formatRelative(data.lastRunAt) : "noch keiner"} +
+
+
+
+ Naechster Durchlauf +
+
+ {enabled ? (nextRun ? formatDateTime(nextRun) : "in Kuerze") : "abgeschaltet"} +
+
+
+ + {data.lastReport ? ( +

+ Zuletzt ({data.lastReport.trigger}): {data.lastReport.synced} aktualisiert,{" "} + {data.lastReport.failed} fehlgeschlagen, {data.lastReport.skipped} zurueckgestellt + in {Math.round(data.lastReport.durationMs / 1000)} s. +

+ ) : null} + + {notice ? ( +

+ {notice.text} +

+ ) : null} +
+ ); +} + function ModeCard({ active, disabled, diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 4e6e89b..4c4440d 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -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({