diff --git a/backend/src/routes/admin.routes.ts b/backend/src/routes/admin.routes.ts index 0963d60..a8aeed5 100644 --- a/backend/src/routes/admin.routes.ts +++ b/backend/src/routes/admin.routes.ts @@ -13,6 +13,7 @@ import { Router, Request, Response } from 'express'; import { z } from 'zod'; +import axios from 'axios'; import { authenticate } from '../middleware/auth.middleware'; import { requirePermission } from '../middleware/rbac.middleware'; import { auditExport } from '../middleware/audit.middleware'; @@ -167,3 +168,49 @@ router.get( ); export default router; + +// --------------------------------------------------------------------------- +// FDISK Sync proxy — forwards to the fdisk-sync sidecar service +// --------------------------------------------------------------------------- + +const FDISK_SYNC_URL = process.env.FDISK_SYNC_URL ?? ''; + +router.get( + '/fdisk-sync/logs', + authenticate, + requirePermission('admin:access'), + async (_req: Request, res: Response): Promise => { + if (!FDISK_SYNC_URL) { + res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); + return; + } + try { + const response = await axios.get(`${FDISK_SYNC_URL}/logs`, { timeout: 5000 }); + res.json({ success: true, data: response.data }); + } catch { + res.status(502).json({ success: false, message: 'Could not reach sync service' }); + } + } +); + +router.post( + '/fdisk-sync/trigger', + authenticate, + requirePermission('admin:access'), + async (_req: Request, res: Response): Promise => { + if (!FDISK_SYNC_URL) { + res.status(503).json({ success: false, message: 'FDISK sync service not configured' }); + return; + } + try { + const response = await axios.post(`${FDISK_SYNC_URL}/trigger`, {}, { timeout: 5000 }); + res.json({ success: true, data: response.data }); + } catch (err: unknown) { + if (axios.isAxiosError(err) && err.response?.status === 409) { + res.status(409).json({ success: false, message: 'Sync already in progress' }); + return; + } + res.status(502).json({ success: false, message: 'Could not reach sync service' }); + } + } +); diff --git a/docker-compose.yml b/docker-compose.yml index c9f01c7..fce0650 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,6 +45,7 @@ services: BOOKSTACK_URL: ${BOOKSTACK_URL:-} BOOKSTACK_TOKEN_ID: ${BOOKSTACK_TOKEN_ID:-} BOOKSTACK_TOKEN_SECRET: ${BOOKSTACK_TOKEN_SECRET:-} + FDISK_SYNC_URL: http://fdisk-sync:3001 ports: - "${BACKEND_PORT:-3000}:3000" depends_on: @@ -92,6 +93,27 @@ services: start_period: 30s restart: unless-stopped + fdisk-sync: + build: + context: ./sync + dockerfile: Dockerfile + container_name: feuerwehr_fdisk_sync + environment: + FDISK_USERNAME: ${FDISK_USERNAME:?FDISK_USERNAME is required} + FDISK_PASSWORD: ${FDISK_PASSWORD:?FDISK_PASSWORD is required} + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${POSTGRES_DB:-feuerwehr_prod} + DB_USER: ${POSTGRES_USER:-prod_user} + DB_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + SYNC_HTTP_PORT: 3001 + depends_on: + postgres: + condition: service_healthy + networks: + - dashboard-backend + restart: unless-stopped + volumes: postgres_data_prod: driver: local diff --git a/frontend/src/components/admin/FdiskSyncTab.tsx b/frontend/src/components/admin/FdiskSyncTab.tsx new file mode 100644 index 0000000..ff0e436 --- /dev/null +++ b/frontend/src/components/admin/FdiskSyncTab.tsx @@ -0,0 +1,129 @@ +import { useRef, useEffect } from 'react'; +import { + Box, Button, Card, CardContent, Chip, CircularProgress, Typography, +} from '@mui/material'; +import SyncIcon from '@mui/icons-material/Sync'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { adminApi } from '../../services/admin'; +import { useNotification } from '../../contexts/NotificationContext'; + +function FdiskSyncTab() { + const queryClient = useQueryClient(); + const { showSuccess, showError } = useNotification(); + const logBoxRef = useRef(null); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['admin', 'fdisk-sync', 'logs'], + queryFn: adminApi.fdiskSyncLogs, + refetchInterval: 5000, + }); + + // Auto-scroll log box to bottom when new lines arrive + useEffect(() => { + if (logBoxRef.current) { + logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight; + } + }, [data?.logs.length]); + + const triggerMutation = useMutation({ + mutationFn: adminApi.fdiskSyncTrigger, + onSuccess: () => { + showSuccess('Sync gestartet'); + queryClient.invalidateQueries({ queryKey: ['admin', 'fdisk-sync', 'logs'] }); + }, + onError: (err: unknown) => { + const msg = (err as { response?: { status?: number } })?.response?.status === 409 + ? 'Sync läuft bereits' + : 'Sync konnte nicht gestartet werden'; + showError(msg); + }, + }); + + const running = data?.running ?? false; + + return ( + + + + + FDISK Mitglieder-Sync + + Synchronisiert Mitgliederdaten und Ausbildungen aus FDISK in die Datenbank. + Läuft automatisch täglich um Mitternacht. + + + + : undefined} + /> + + + + + + + + Protokoll (letzte 500 Zeilen) + {isLoading && ( + + + + )} + {isError && ( + + Sync-Dienst nicht erreichbar. Läuft der fdisk-sync Container? + + )} + {!isLoading && !isError && ( + + {(data?.logs ?? []).length === 0 ? ( + Noch keine Logs vorhanden. + ) : ( + (data?.logs ?? []).map((entry, i) => ( + + {entry.line} + + )) + )} + + )} + + + + ); +} + +export default FdiskSyncTab; diff --git a/frontend/src/pages/AdminDashboard.tsx b/frontend/src/pages/AdminDashboard.tsx index 842522d..5d14254 100644 --- a/frontend/src/pages/AdminDashboard.tsx +++ b/frontend/src/pages/AdminDashboard.tsx @@ -8,6 +8,7 @@ import UserOverviewTab from '../components/admin/UserOverviewTab'; import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab'; import BannerManagementTab from '../components/admin/BannerManagementTab'; import ServiceModeTab from '../components/admin/ServiceModeTab'; +import FdiskSyncTab from '../components/admin/FdiskSyncTab'; import { useAuth } from '../contexts/AuthContext'; interface TabPanelProps { @@ -21,7 +22,7 @@ function TabPanel({ children, value, index }: TabPanelProps) { return {children}; } -const ADMIN_TAB_COUNT = 6; +const ADMIN_TAB_COUNT = 7; function AdminDashboard() { const navigate = useNavigate(); @@ -55,6 +56,7 @@ function AdminDashboard() { + @@ -76,6 +78,9 @@ function AdminDashboard() { + + + ); } diff --git a/frontend/src/services/admin.ts b/frontend/src/services/admin.ts index c52dad0..8a01a20 100644 --- a/frontend/src/services/admin.ts +++ b/frontend/src/services/admin.ts @@ -6,6 +6,16 @@ interface ApiResponse { data: T; } +export interface FdiskSyncLogEntry { + ts: string; + line: string; +} + +export interface FdiskSyncLogsResponse { + running: boolean; + logs: FdiskSyncLogEntry[]; +} + export const adminApi = { getServices: () => api.get>('/api/admin/services').then(r => r.data.data), createService: (data: { name: string; url: string }) => api.post>('/api/admin/services', data).then(r => r.data.data), @@ -17,4 +27,6 @@ export const adminApi = { getUsers: () => api.get>('/api/admin/users').then(r => r.data.data), broadcast: (data: BroadcastPayload) => api.post>('/api/admin/notifications/broadcast', data).then(r => r.data.data), getPingHistory: (serviceId: string) => api.get>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data), + fdiskSyncLogs: () => api.get>('/api/admin/fdisk-sync/logs').then(r => r.data.data), + fdiskSyncTrigger: () => api.post>('/api/admin/fdisk-sync/trigger').then(r => r.data.data), }; diff --git a/sync/src/index.ts b/sync/src/index.ts index d31ae2e..4989068 100644 --- a/sync/src/index.ts +++ b/sync/src/index.ts @@ -1,12 +1,36 @@ import 'dotenv/config'; +import * as http from 'http'; import { Pool } from 'pg'; import { scrapeAll } from './scraper'; import { syncToDatabase } from './db'; +// In-memory log ring buffer — last 500 lines captured from all modules +const LOG_BUFFER_MAX = 500; +const logBuffer: Array<{ ts: string; line: string }> = []; + +const _origLog = console.log; +const _origErr = console.error; +function captureToBuffer(line: string) { + logBuffer.push({ ts: new Date().toISOString(), line }); + if (logBuffer.length > LOG_BUFFER_MAX) logBuffer.shift(); +} +console.log = (...args: unknown[]) => { + const line = args.map(String).join(' '); + _origLog(line); + captureToBuffer(line); +}; +console.error = (...args: unknown[]) => { + const line = args.map(String).join(' '); + _origErr(line); + captureToBuffer(line); +}; + function log(msg: string) { console.log(`[sync] ${new Date().toISOString()} ${msg}`); } +let syncRunning = false; + function requireEnv(name: string): string { const val = process.env[name]; if (!val) throw new Error(`Missing required environment variable: ${name}`); @@ -23,6 +47,11 @@ function msUntilMidnight(): number { } async function runSync(): Promise { + if (syncRunning) { + log('Sync already in progress, skipping'); + return; + } + syncRunning = true; const username = requireEnv('FDISK_USERNAME'); const password = requireEnv('FDISK_PASSWORD'); @@ -40,13 +69,41 @@ async function runSync(): Promise { await syncToDatabase(pool, members, ausbildungen); log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`); } finally { + syncRunning = false; await pool.end(); } } +function startHttpServer(port: number) { + const server = http.createServer((req, res) => { + res.setHeader('Content-Type', 'application/json'); + + if (req.method === 'GET' && req.url === '/logs') { + res.writeHead(200); + res.end(JSON.stringify({ running: syncRunning, logs: logBuffer })); + } else if (req.method === 'POST' && req.url === '/trigger') { + if (syncRunning) { + res.writeHead(409); + res.end(JSON.stringify({ running: true, message: 'Sync already in progress' })); + return; + } + res.writeHead(200); + res.end(JSON.stringify({ started: true })); + runSync().catch(err => log(`ERROR during manual sync: ${err.message}`)); + } else { + res.writeHead(404); + res.end(JSON.stringify({ message: 'Not found' })); + } + }); + server.listen(port, () => log(`HTTP control server listening on port ${port}`)); +} + async function main(): Promise { log('FDISK sync service started'); + const httpPort = parseInt(process.env.SYNC_HTTP_PORT ?? '3001', 10); + startHttpServer(httpPort); + // Run once immediately on startup so the first sync doesn't wait until midnight await runSync().catch(err => log(`ERROR during initial sync: ${err.message}`));