update FDISK sync
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import { Router, Request, Response } from 'express';
|
import { Router, Request, Response } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import axios from 'axios';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import { requirePermission } from '../middleware/rbac.middleware';
|
import { requirePermission } from '../middleware/rbac.middleware';
|
||||||
import { auditExport } from '../middleware/audit.middleware';
|
import { auditExport } from '../middleware/audit.middleware';
|
||||||
@@ -167,3 +168,49 @@ router.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export default router;
|
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<void> => {
|
||||||
|
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<void> => {
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ services:
|
|||||||
BOOKSTACK_URL: ${BOOKSTACK_URL:-}
|
BOOKSTACK_URL: ${BOOKSTACK_URL:-}
|
||||||
BOOKSTACK_TOKEN_ID: ${BOOKSTACK_TOKEN_ID:-}
|
BOOKSTACK_TOKEN_ID: ${BOOKSTACK_TOKEN_ID:-}
|
||||||
BOOKSTACK_TOKEN_SECRET: ${BOOKSTACK_TOKEN_SECRET:-}
|
BOOKSTACK_TOKEN_SECRET: ${BOOKSTACK_TOKEN_SECRET:-}
|
||||||
|
FDISK_SYNC_URL: http://fdisk-sync:3001
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3000}:3000"
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -92,6 +93,27 @@ services:
|
|||||||
start_period: 30s
|
start_period: 30s
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_data_prod:
|
postgres_data_prod:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
129
frontend/src/components/admin/FdiskSyncTab.tsx
Normal file
129
frontend/src/components/admin/FdiskSyncTab.tsx
Normal file
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||||
|
<Box sx={{ flex: 1 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>FDISK Mitglieder-Sync</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Synchronisiert Mitgliederdaten und Ausbildungen aus FDISK in die Datenbank.
|
||||||
|
Läuft automatisch täglich um Mitternacht.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||||
|
<Chip
|
||||||
|
label={running ? 'Läuft…' : 'Bereit'}
|
||||||
|
color={running ? 'warning' : 'success'}
|
||||||
|
size="small"
|
||||||
|
icon={running ? <CircularProgress size={12} color="inherit" /> : undefined}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<SyncIcon />}
|
||||||
|
onClick={() => triggerMutation.mutate()}
|
||||||
|
disabled={running || triggerMutation.isPending}
|
||||||
|
>
|
||||||
|
Jetzt synchronisieren
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="subtitle2" gutterBottom>Protokoll (letzte 500 Zeilen)</Typography>
|
||||||
|
{isLoading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<Typography color="error" variant="body2">
|
||||||
|
Sync-Dienst nicht erreichbar. Läuft der fdisk-sync Container?
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<Box
|
||||||
|
ref={logBoxRef}
|
||||||
|
sx={{
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
bgcolor: 'grey.900',
|
||||||
|
color: 'grey.100',
|
||||||
|
borderRadius: 1,
|
||||||
|
p: 1.5,
|
||||||
|
maxHeight: 500,
|
||||||
|
overflowY: 'auto',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(data?.logs ?? []).length === 0 ? (
|
||||||
|
<Typography variant="caption" color="grey.500">Noch keine Logs vorhanden.</Typography>
|
||||||
|
) : (
|
||||||
|
(data?.logs ?? []).map((entry, i) => (
|
||||||
|
<Box
|
||||||
|
key={i}
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: 'block',
|
||||||
|
color: entry.line.includes('ERROR') || entry.line.includes('WARN')
|
||||||
|
? (entry.line.includes('ERROR') ? 'error.light' : 'warning.light')
|
||||||
|
: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entry.line}
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FdiskSyncTab;
|
||||||
@@ -8,6 +8,7 @@ import UserOverviewTab from '../components/admin/UserOverviewTab';
|
|||||||
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
|
||||||
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
import BannerManagementTab from '../components/admin/BannerManagementTab';
|
||||||
import ServiceModeTab from '../components/admin/ServiceModeTab';
|
import ServiceModeTab from '../components/admin/ServiceModeTab';
|
||||||
|
import FdiskSyncTab from '../components/admin/FdiskSyncTab';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
|
||||||
interface TabPanelProps {
|
interface TabPanelProps {
|
||||||
@@ -21,7 +22,7 @@ function TabPanel({ children, value, index }: TabPanelProps) {
|
|||||||
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
return <Box sx={{ pt: 3 }}>{children}</Box>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ADMIN_TAB_COUNT = 6;
|
const ADMIN_TAB_COUNT = 7;
|
||||||
|
|
||||||
function AdminDashboard() {
|
function AdminDashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -55,6 +56,7 @@ function AdminDashboard() {
|
|||||||
<Tab label="Broadcast" />
|
<Tab label="Broadcast" />
|
||||||
<Tab label="Banner" />
|
<Tab label="Banner" />
|
||||||
<Tab label="Wartung" />
|
<Tab label="Wartung" />
|
||||||
|
<Tab label="FDISK Sync" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -76,6 +78,9 @@ function AdminDashboard() {
|
|||||||
<TabPanel value={tab} index={5}>
|
<TabPanel value={tab} index={5}>
|
||||||
<ServiceModeTab />
|
<ServiceModeTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={tab} index={6}>
|
||||||
|
<FdiskSyncTab />
|
||||||
|
</TabPanel>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ interface ApiResponse<T> {
|
|||||||
data: T;
|
data: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FdiskSyncLogEntry {
|
||||||
|
ts: string;
|
||||||
|
line: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FdiskSyncLogsResponse {
|
||||||
|
running: boolean;
|
||||||
|
logs: FdiskSyncLogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
getServices: () => api.get<ApiResponse<MonitoredService[]>>('/api/admin/services').then(r => r.data.data),
|
getServices: () => api.get<ApiResponse<MonitoredService[]>>('/api/admin/services').then(r => r.data.data),
|
||||||
createService: (data: { name: string; url: string }) => api.post<ApiResponse<MonitoredService>>('/api/admin/services', data).then(r => r.data.data),
|
createService: (data: { name: string; url: string }) => api.post<ApiResponse<MonitoredService>>('/api/admin/services', data).then(r => r.data.data),
|
||||||
@@ -17,4 +27,6 @@ export const adminApi = {
|
|||||||
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
|
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
|
||||||
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
|
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
|
||||||
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
getPingHistory: (serviceId: string) => api.get<ApiResponse<PingHistoryEntry[]>>(`/api/admin/services/${serviceId}/ping-history`).then(r => r.data.data),
|
||||||
|
fdiskSyncLogs: () => api.get<ApiResponse<FdiskSyncLogsResponse>>('/api/admin/fdisk-sync/logs').then(r => r.data.data),
|
||||||
|
fdiskSyncTrigger: () => api.post<ApiResponse<{ started: boolean }>>('/api/admin/fdisk-sync/trigger').then(r => r.data.data),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,36 @@
|
|||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
|
import * as http from 'http';
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { scrapeAll } from './scraper';
|
import { scrapeAll } from './scraper';
|
||||||
import { syncToDatabase } from './db';
|
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) {
|
function log(msg: string) {
|
||||||
console.log(`[sync] ${new Date().toISOString()} ${msg}`);
|
console.log(`[sync] ${new Date().toISOString()} ${msg}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let syncRunning = false;
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const val = process.env[name];
|
const val = process.env[name];
|
||||||
if (!val) throw new Error(`Missing required environment variable: ${name}`);
|
if (!val) throw new Error(`Missing required environment variable: ${name}`);
|
||||||
@@ -23,6 +47,11 @@ function msUntilMidnight(): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function runSync(): Promise<void> {
|
async function runSync(): Promise<void> {
|
||||||
|
if (syncRunning) {
|
||||||
|
log('Sync already in progress, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncRunning = true;
|
||||||
const username = requireEnv('FDISK_USERNAME');
|
const username = requireEnv('FDISK_USERNAME');
|
||||||
const password = requireEnv('FDISK_PASSWORD');
|
const password = requireEnv('FDISK_PASSWORD');
|
||||||
|
|
||||||
@@ -40,13 +69,41 @@ async function runSync(): Promise<void> {
|
|||||||
await syncToDatabase(pool, members, ausbildungen);
|
await syncToDatabase(pool, members, ausbildungen);
|
||||||
log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`);
|
log(`Sync complete — ${members.length} members, ${ausbildungen.length} Ausbildungen`);
|
||||||
} finally {
|
} finally {
|
||||||
|
syncRunning = false;
|
||||||
await pool.end();
|
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<void> {
|
async function main(): Promise<void> {
|
||||||
log('FDISK sync service started');
|
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
|
// 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}`));
|
await runSync().catch(err => log(`ERROR during initial sync: ${err.message}`));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user