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({