feat: add centralized error logging system

- Implemented Error Log API to capture and store error logs from various services.
- Updated admin_ui to include Error Log Viewer component for inspecting logs.
- Enhanced error handling in auth_api, employee_api, and transit_api to report errors to the Error Log API.
- Modified existing services to utilize the new error logging functionality.
- Updated Docker Compose configuration to include the Error Log API service.
- Added error logging service in bus_terminal for Flutter application.
This commit is contained in:
Sven laptop
2026-05-17 23:58:23 +02:00
parent 73de0d713e
commit 0e19aa0521
31 changed files with 688 additions and 34 deletions
+43
View File
@@ -9,6 +9,7 @@ on:
- 'auth_api/**'
- 'employee_api/**'
- 'transit_api/**'
- 'error_log_api/**'
- 'admin_ui/**'
- '.github/workflows/docker-build.yml'
pull_request:
@@ -153,6 +154,48 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
build-error-log-api:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository }}/error_log_api
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: ./error_log_api
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-admin-ui:
runs-on: ubuntu-latest
permissions:
+1
View File
@@ -1,6 +1,7 @@
.env
/*.env
transit_api/.env
error_log_api/.env
# Local build / node_modules
admin_ui/node_modules/
+30
View File
@@ -5,6 +5,7 @@ Dit repository bevat een suite van drie Node.js microservices (APIs) en bijbehor
- `auth_api` — Authenticatie en token-uitgifte (standaard port 5000)
- `employee_api` — Employee onboarding / management (standaard port 4000)
- `transit_api` — Transit/schedule endpoints (standaard port 5001)
- `error_log_api` — Centrale foutlogging en viewer (standaard port 5003)
Er is ook een GitHub Actions workflow aanwezig die Docker images bouwt en pusht naar GitHub Container Registry (GHCR).
@@ -77,9 +78,14 @@ POSTGRES_USER_EMPLOYEE=employee_user
POSTGRES_PASSWORD_EMPLOYEE=strong_password_employee
POSTGRES_USER_TRANSIT=transit_user
POSTGRES_PASSWORD_TRANSIT=strong_password_transit
POSTGRES_USER_ERROR_LOG=error_logger
POSTGRES_PASSWORD_ERROR_LOG=strong_password_error_log
# JWT secret voor services die JWT gebruiken
JWT_SECRET=super_secret_jwt_key_here
# Optioneel: centraal error log endpoint voor clients/services
ERROR_LOG_API_URL=http://error_log_api:5003/error_log
```
Opmerking: de exacte variabelen worden gelezen door `docker-compose.yml`. Pas de namen/waarden aan naar jouw omgeving.
@@ -218,6 +224,30 @@ Database schema en migraties zitten niet in deze repository. Zorg dat je:
- de juiste schema's (`workers`, `routes`, etc.) aanmaakt
- gebruikers en rechten configureert voor de verschillende services
### Error logging schema
Voor de centrale foutlogging maak je schema `data` en tabel `data.errors` aan:
```sql
CREATE SCHEMA IF NOT EXISTS data;
CREATE TABLE IF NOT EXISTS data.errors (
id BIGSERIAL PRIMARY KEY,
"date" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
"service" TEXT NOT NULL,
"partOfService" TEXT,
"error" TEXT NOT NULL
);
```
Gebruik een aparte gebruiker met minimale rechten, bijvoorbeeld `error_logger`:
```sql
CREATE ROLE error_logger LOGIN PASSWORD 'strong_password_error_log';
GRANT USAGE ON SCHEMA data TO error_logger;
GRANT INSERT, SELECT ON data.errors TO error_logger;
```
Als je nog geen database hebt, kun je lokaal een Postgres container gebruiken (development):
```bash
+5
View File
@@ -10,6 +10,7 @@ Modern React + Vite web application for comprehensive transit system administrat
✅ **Stop Linking** - Assign stops to bus lines with order and ETA
✅ **JWT Authentication** - Secure authentication with role-based access
✅ **Production Ready** - Docker containerized with runtime env configuration
✅ **Error Log Viewer** - Inspect centralized error logs from all services
## Local Development
@@ -31,6 +32,7 @@ Create a `.env` file in the `admin_ui` directory:
```env
VITE_AUTH_API=http://localhost:5000
VITE_TRANSIT_API=http://localhost:5001
VITE_ERROR_LOG_API=http://localhost:5003
```
### Run Development Server
@@ -109,12 +111,14 @@ When running in Docker, environment variables are injected at runtime via `docke
- `BusLinesManager.jsx` - Create/list bus routes
- `FleetManager.jsx` - View fleet vehicles
- `LinkStop.jsx` - Link stops to bus lines
- `ErrorLogViewer.jsx` - Central error log viewer
### API Integration
The app communicates with:
- **Auth API** (/login, /me)
- **Transit API** (/admin/stops, /admin/bus-lines, /admin/fleet/buses)
- **Error Log API** (/error_log)
All requests include JWT Bearer token for authentication.
@@ -139,6 +143,7 @@ Users must have a `userCode` in their JWT that matches `ADMIN_USER_CODES` enviro
3. Configure proper JWT generation in auth_api
4. Set strong `JWT_SECRET` environment variable
5. Configure `ADMIN_USER_CODES` with actual admin user IDs
6. Configure `VITE_ERROR_LOG_API` if you use a custom logging endpoint
## Troubleshooting
+2 -1
View File
@@ -4,7 +4,8 @@ set -e
cat > /usr/share/nginx/html/env-config.js <<EOF
window.__ENV__ = {
AUTH_API: "${AUTH_API:-http://localhost:5000}",
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}"
TRANSIT_API: "${TRANSIT_API:-http://localhost:5001}",
ERROR_LOG_API: "${ERROR_LOG_API:-http://localhost:5003}"
}
EOF
+2
View File
@@ -5,6 +5,7 @@ import LinkStop from './components/LinkStop'
import BusLinesManager from './components/BusLinesManager'
import FleetManager from './components/FleetManager'
import ScheduleMaker from './components/ScheduleMaker'
import ErrorLogViewer from './components/ErrorLogViewer'
export default function App() {
const [token, setToken] = useState(localStorage.getItem('token'))
@@ -46,6 +47,7 @@ export default function App() {
<FleetManager token={token} user={user} />
<LinkStop token={token} user={user} />
<ScheduleMaker token={token} user={user} />
<ErrorLogViewer token={token} />
</main>
</div>
)
+41
View File
@@ -14,9 +14,38 @@ const normalizeApiBaseUrl = (value, fallbackPath) => {
const AUTH_API = normalizeApiBaseUrl(RUNTIME.AUTH_API || import.meta.env.VITE_AUTH_API, 'http://localhost:5000')
const TRANSIT_API = normalizeApiBaseUrl(RUNTIME.TRANSIT_API || import.meta.env.VITE_TRANSIT_API, 'http://localhost:5001')
const ERROR_LOG_API = normalizeApiBaseUrl(RUNTIME.ERROR_LOG_API || import.meta.env.VITE_ERROR_LOG_API, 'http://localhost:5003')
const reportClientError = async ({ service, partOfService, error }) => {
try {
await fetch(`${ERROR_LOG_API}/error_log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service,
partOfService,
error: typeof error === 'string' ? error : JSON.stringify(error),
}),
})
} catch (e) {
// ignore logging failures
}
}
export const authClient = axios.create({ baseURL: AUTH_API })
authClient.interceptors.response.use(
response => response,
error => {
void reportClientError({
service: 'admin_ui',
partOfService: 'authClient',
error: error?.response?.data?.error || error?.message || 'authClient_error',
})
return Promise.reject(error)
}
)
export const transitClient = (token) => {
const client = axios.create({
baseURL: TRANSIT_API,
@@ -35,6 +64,11 @@ export const transitClient = (token) => {
// reload to let App show the Login screen
if (typeof window !== 'undefined') window.location.reload()
}
void reportClientError({
service: 'admin_ui',
partOfService: 'transitClient',
error: error?.response?.data?.error || error?.message || 'transitClient_error',
})
} catch (e) {
// ignore
}
@@ -44,3 +78,10 @@ export const transitClient = (token) => {
return client
}
export const errorLogClient = (token) => axios.create({
baseURL: ERROR_LOG_API,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
export { reportClientError }
+1 -1
View File
@@ -11,7 +11,7 @@ export default function BusLinesManager({ token, user }) {
const [estimatedDurationMinutes, setEstimatedDurationMinutes] = useState('')
const [description, setDescription] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingLine, setEditingLine] = useState(null)
@@ -0,0 +1,74 @@
import React, { useEffect, useMemo, useState } from 'react'
import { errorLogClient } from '../api'
const formatDate = (value) => {
if (!value) return '—'
try {
return new Date(value).toLocaleString()
} catch (_) {
return String(value)
}
}
export default function ErrorLogViewer({ token }) {
const client = useMemo(() => errorLogClient(token), [token])
const [logs, setLogs] = useState([])
const [service, setService] = useState('')
const [partOfService, setPartOfService] = useState('')
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState(null)
const loadLogs = async () => {
setLoading(true)
setMessage(null)
try {
const params = { limit: 100 }
if (service) params.service = service
if (partOfService) params.partOfService = partOfService
const res = await client.get('/error_log', { params })
setLogs(res.data.errors || [])
} catch (err) {
setMessage(err.response?.data?.error || err.message || 'Kon fouten niet laden')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadLogs()
}, [service, partOfService])
return (
<section className="card">
<div className="section-header">
<div>
<h2>Error log</h2>
<p>Laatste foutmeldingen uit de centrale logging service.</p>
</div>
<button className="secondary" onClick={loadLogs} disabled={loading}>
{loading ? 'Laden...' : 'Ververs'}
</button>
</div>
<div className="form-row">
<input placeholder="Service filter" value={service} onChange={e => setService(e.target.value)} />
<input placeholder="Part of service filter" value={partOfService} onChange={e => setPartOfService(e.target.value)} />
</div>
{message && <div className="message">{message}</div>}
<div className="list-grid">
{logs.map(log => (
<article className="mini-card" key={log.id}>
<strong>{log.service}</strong>
<div>{formatDate(log.date)}</div>
<div className="muted" style={{ marginTop: 6 }}>{log.partOfService || '—'}</div>
<div style={{ marginTop: 8 }}>{log.error}</div>
</article>
))}
</div>
{!logs.length && !message && <div className="muted">Nog geen foutmeldingen gevonden.</div>}
</section>
)
}
@@ -172,7 +172,7 @@ const FixedScheduleEditor = ({
const [availableDrivers, setAvailableDrivers] = useState([])
const [pendingBatchData, setPendingBatchData] = useState(null)
const isAdmin = Boolean(user?.userCode)
const isAdmin = Boolean(user?.isAdmin)
const resetForm = () => {
setEditingScheduleId(null)
+1 -1
View File
@@ -9,7 +9,7 @@ export default function FleetManager({ token, user }) {
const [licensePlate, setLicensePlate] = useState('')
const [message, setMessage] = useState(null)
const [canCreateBus, setCanCreateBus] = useState(true)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [editingBus, setEditingBus] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
+1 -1
View File
@@ -13,7 +13,7 @@ export default function LinkStop({ token, user }) {
const [draggedItem, setDraggedItem] = useState(null)
const [deleteConfirm, setDeleteConfirm] = useState(null)
const isAdmin = Boolean(user?.userCode)
const isAdmin = Boolean(user?.isAdmin)
const nextOrder = useMemo(() => stops.length + 1, [stops])
+1 -1
View File
@@ -11,7 +11,7 @@ export default function ScheduleMaker({ token, user }) {
const [drivers, setDrivers] = useState([])
const [selectedDriverFilter, setSelectedDriverFilter] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [overviewDate, setOverviewDate] = useState(new Date().toISOString().split('T')[0])
const loadScheduleTemplates = async () => {
+1 -1
View File
@@ -8,7 +8,7 @@ export default function StopsManager({ token, user }) {
const [lat, setLat] = useState('')
const [lon, setLon] = useState('')
const [message, setMessage] = useState(null)
const [isAdmin] = useState(Boolean(user?.userCode))
const [isAdmin] = useState(Boolean(user?.isAdmin))
const [deleteConfirm, setDeleteConfirm] = useState(null)
const [editingStop, setEditingStop] = useState(null)
+51 -4
View File
@@ -18,7 +18,51 @@ app.use((req, res, next) => {
next();
});
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'auth_api',
partOfService,
error,
}),
});
} catch (_) {}
}
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
const JWT_SECRET = process.env.JWT_SECRET;
const ADMIN_USER_CODES = new Set(
String(process.env.ADMIN_USER_CODES || '')
.split(',')
.map(c => Number(c.trim()))
.filter(Number.isFinite)
);
app.post('/login', async (req, res) => {
@@ -33,13 +77,15 @@ app.post('/login', async (req, res) => {
const ok = await bcrypt.compare(password, user.password);
if (!ok) return res.status(401).json({ error: 'invalid_credentials' });
const payload = { userId: user.id, userCode: user.usercode };
const isAdmin = ADMIN_USER_CODES.has(Number(user.usercode));
const payload = { userId: user.id, userCode: user.usercode, isAdmin };
const token = jwt.sign(payload, JWT_SECRET, { expiresIn: '1h' });
res.json({ token, user: { id: user.id, userCode: user.userCode, job: user.job } });
res.json({ token, user: { id: user.id, userCode: user.usercode, job: user.job, isAdmin } });
} catch (err) {
await reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err);
res.status(500).json({ error: 'internal_error' });
res.status(500).json({ error: friendlyErrorMessage(500) });
}
});
@@ -51,7 +97,8 @@ app.get('/me', async (req, res) => {
const payload = jwt.verify(token, JWT_SECRET);
res.json({ user: payload });
} catch (err) {
res.status(401).json({ error: 'invalid_token' });
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
res.status(401).json({ error: friendlyErrorMessage(401) });
}
});
+36
View File
@@ -1,12 +1,48 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'screens/login_screen.dart';
import 'screens/schedule_overview_screen.dart';
import 'services/auth_service.dart';
import 'theme/app_theme.dart';
import 'services/app_navigator.dart';
import 'services/error_log_service.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
void report() => ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'FlutterError.onError',
error: details.exceptionAsString(),
);
// ignore: unawaited_futures
report();
};
PlatformDispatcher.instance.onError = (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'PlatformDispatcher.onError',
error: '$error\n$stack',
);
return true;
};
runZonedGuarded(() {
runApp(const MainApp());
}, (error, stack) {
// ignore: unawaited_futures
ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'runZonedGuarded',
error: '$error\n$stack',
);
});
}
class MainApp extends StatelessWidget {
+17 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import '../services/error_log_service.dart';
import '../widgets/custom_input_field.dart';
class LoginScreen extends StatefulWidget {
@@ -37,7 +38,17 @@ class _LoginScreenState extends State<LoginScreen> {
final input = _userCodeController.text.trim();
final parsedUserCode = int.tryParse(input);
if (parsedUserCode == null) {
throw Exception('User code must be a number');
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin',
error: 'User code must be a number',
);
if (mounted) {
setState(() {
_errorMessage = 'User code must be a number';
});
}
return;
}
await _authService.login(
@@ -50,6 +61,11 @@ class _LoginScreenState extends State<LoginScreen> {
Navigator.of(context).pushReplacementNamed('/schedule');
}
} catch (e) {
await ErrorLogService.report(
service: 'bus_terminal',
partOfService: 'LoginScreen._handleLogin.catch',
error: e.toString(),
);
if (mounted) {
setState(() {
_errorMessage = e.toString().replaceAll('Exception: ', '');
@@ -226,8 +226,6 @@ class _ScheduleOverviewScreenState extends State<ScheduleOverviewScreen> with Ti
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Diensten'),
@@ -323,7 +321,7 @@ class _ProgressTab extends StatelessWidget {
return Card(
child: ListTile(
title: Text('Lijn ${svc.lineNumber} — ${svc.busName}'),
subtitle: Text('${_formatTime(svc.startTime)}'),
subtitle: Text(_formatTime(svc.startTime)),
trailing: TextButton(
onPressed: () async {
try {
+12 -1
View File
@@ -2,14 +2,23 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import '../models/user.dart';
import 'error_log_service.dart';
/// Authentication Service
class AuthService {
static const String _apiBaseUrl = 'http://api.transit.local:5000';
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static const String _tokenKey = 'auth_token';
late SharedPreferences _prefs;
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance();
}
@@ -42,6 +51,7 @@ class AuthService {
return authResponse;
} catch (e) {
await _reportError('AuthService.login.parse', e);
// Surface the response body to help debug backend field names / formats
throw Exception('Login parse error: $e -- response body: $body');
}
@@ -51,6 +61,7 @@ class AuthService {
throw Exception('Login failed: ${response.statusCode}');
}
} catch (e) {
await _reportError('AuthService.login', e);
throw Exception('Login error: $e');
}
}
@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ErrorLogService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
static Future<void> report({
required String service,
required String partOfService,
required String error,
}) async {
try {
await http.post(
Uri.parse('$_apiBaseUrl/error_log'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'service': service,
'partOfService': partOfService,
'error': error,
}),
);
} catch (_) {
// Intentionally ignore logging failures.
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
/// Custom exception for authentication errors
// Custom exception for authentication errors
import 'app_navigator.dart';
class UnauthorizedException implements Exception {
@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:geolocator/geolocator.dart';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import 'error_log_service.dart';
import '../models/navigation.dart';
/// Navigation Service using OpenStreetMap
@@ -10,6 +11,13 @@ class NavigationService {
static const String _osrmBaseUrl = 'https://router.project-osrm.org/route/v1/driving';
/// Get current device location
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
Future<NavigationPoint?> getCurrentLocation() async {
try {
final permission = await Geolocator.checkPermission();
@@ -27,6 +35,7 @@ class NavigationService {
name: 'Current Location',
);
} catch (e) {
await _reportError('NavigationService.getCurrentLocation', e);
throw Exception('Failed to get location: $e');
}
}
@@ -88,6 +97,7 @@ class NavigationService {
throw Exception('Failed to get route: ${response.statusCode} -- response body: ${response.body}');
}
} catch (e) {
await _reportError('NavigationService.getRoute', e);
throw Exception('Navigation error: $e');
}
}
@@ -152,6 +162,7 @@ class NavigationService {
end: stops.last,
);
} catch (e) {
await _reportError('NavigationService.getRouteThroughStops', e);
throw Exception('Navigation error: $e');
}
}
@@ -252,6 +263,7 @@ class NavigationService {
orderedStops: orderedStops.isNotEmpty ? orderedStops : stops,
);
} catch (e) {
await _reportError('NavigationService.getOptimizedRouteThroughStops', e);
throw Exception('Navigation error: $e');
}
}
@@ -308,6 +320,7 @@ class NavigationService {
throw Exception('Failed to get routes: ${response.statusCode} -- response body: ${response.body}');
}
} catch (e) {
await _reportError('NavigationService.getMultipleRoutes', e);
throw Exception('Navigation error: $e');
}
}
@@ -358,6 +371,7 @@ class NavigationService {
return NavigationPoint(latitude: lat, longitude: lon, name: display);
} catch (e) {
await _reportError('NavigationService.geocodePlace', e);
throw Exception('Geocode error: $e');
}
}
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
import '../models/schedule.dart';
import '../models/navigation.dart';
import 'auth_service.dart';
import 'error_log_service.dart';
import 'exceptions.dart';
/// Schedule/Route Service
@@ -10,6 +11,14 @@ class ScheduleService {
static const String _apiBaseUrl = 'https://transit.xtransit.testinstance.nl';
final _authService = AuthService();
Future<void> _reportError(String partOfService, Object error) {
return ErrorLogService.report(
service: 'bus_terminal',
partOfService: partOfService,
error: error.toString(),
);
}
/// Get public schedule overview for a specific date.
Future<List<ServiceSchedule>> getSchedules(DateTime date, {int? lineId}) async {
try {
@@ -32,6 +41,7 @@ class ScheduleService {
throw Exception('Failed to load schedules: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getSchedules', e);
throw Exception('Schedule error: $e');
}
}
@@ -57,6 +67,7 @@ class ScheduleService {
throw Exception('Failed to load recurring schedules: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getRecurringSchedules', e);
throw Exception('Recurring schedule error: $e');
}
}
@@ -76,6 +87,7 @@ class ScheduleService {
throw Exception('Failed to load schedule stops: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getScheduleStops', e);
throw Exception('Schedule stops error: $e');
}
}
@@ -130,6 +142,7 @@ class ScheduleService {
throw Exception('Failed to load schedule: ${response.statusCode}');
}
} catch (e) {
await _reportError('ScheduleService.getDailySchedule', e);
throw Exception('Schedule error: $e');
}
}
@@ -152,6 +165,7 @@ class ScheduleService {
throw Exception('Failed to load progress: ${response.statusCode}');
} catch (e) {
await _reportError('ScheduleService.getScheduleProgress', e);
throw Exception('Schedule progress error: $e');
}
}
@@ -177,6 +191,7 @@ class ScheduleService {
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getRouteDetails', e);
throw Exception('Route error: $e');
}
}
@@ -200,6 +215,7 @@ class ScheduleService {
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateRouteStatus', e);
throw Exception('Status update error: $e');
}
}
@@ -228,6 +244,7 @@ class ScheduleService {
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.updateScheduleStatus', e);
throw Exception('Schedule status update error: $e');
}
}
@@ -266,6 +283,7 @@ class ScheduleService {
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.getStops', e);
throw Exception('Get stops error: $e');
}
}
@@ -298,6 +316,7 @@ class ScheduleService {
}
} catch (e) {
if (e is UnauthorizedException) rethrow;
await _reportError('ScheduleService.markScheduleStopPassed', e);
throw Exception('Mark stop passed error: $e');
}
}
+28 -2
View File
@@ -1,6 +1,21 @@
version: '3.9'
services:
error_log_api:
build:
context: ./error_log_api
container_name: error_log_api
ports:
- "5003:5003"
environment:
PORT: 5003
PG_HOST: ${POSTGRES_HOST}
PG_PORT: ${POSTGRES_PORT}
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_LOGGER}
PG_PASS: ${POSTGRES_PASSWORD_LOGGER}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
restart: unless-stopped
auth_api:
image: ghcr.io/sven491/xtransit/auth_api:latest
container_name: auth_api
@@ -14,7 +29,11 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_AUTH}
PG_PASS: ${POSTGRES_PASSWORD_AUTH}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_BYPASS: ${DEV_BYPASS:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped
employee_api:
@@ -29,6 +48,9 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_EMPLOYEE}
PG_PASS: ${POSTGRES_PASSWORD_EMPLOYEE}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped
transit_api:
@@ -44,8 +66,10 @@ services:
PG_DB: ${POSTGRES_DB}
PG_USER: ${POSTGRES_USER_TRANSIT}
PG_PASS: ${POSTGRES_PASSWORD_TRANSIT}
ADMIN_USER_CODES: ${ADMIN_USER_CODES}
DEV_MOCK: ${DEV_MOCK:-0}
ERROR_LOG_API_URL: ${ERROR_LOG_API_URL}
depends_on:
- error_log_api
restart: unless-stopped
admin_ui:
@@ -56,7 +80,9 @@ services:
environment:
AUTH_API: ${AUTH_API}
TRANSIT_API: ${TRANSIT_API}
ERROR_LOG_API: ${ERROR_LOG_API}
depends_on:
- auth_api
- transit_api
- error_log_api
restart: unless-stopped
+42 -2
View File
@@ -6,6 +6,44 @@ require('dotenv').config();
const app = express();
app.use(express.json());
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'employee_api',
partOfService,
error,
}),
});
} catch (_) {}
}
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
// Employee onboarding
app.post('/createemployee', async (req, res) => {
try {
@@ -20,8 +58,9 @@ app.post('/createemployee', async (req, res) => {
return res.status(201).json({ employee: firstname, code: newUser.usercoden });
}
catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err);
res.status(500).json({ error: 'internal_error' });
res.status(500).json({ error: friendlyErrorMessage(500) });
}
})
@@ -30,8 +69,9 @@ app.get('/health', async (_req, res, _next) => {
return res.status(200).json({endpoint: 'reachable'})
}
catch (err) {
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: err?.stack || err?.message || String(err) });
console.error(err);
res.status(500).json({endpoint: 'unreachable'})
res.status(500).json({ endpoint: 'unreachable', error: friendlyErrorMessage(500) })
}
})
+4
View File
@@ -0,0 +1,4 @@
.env
node_modules
npm-debug.log
.DS_Store
+7
View File
@@ -0,0 +1,7 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
EXPOSE 5003
CMD ["npm", "start"]
+26
View File
@@ -0,0 +1,26 @@
const { Pool } = require('pg');
require('dotenv').config();
const pool = new Pool({
host: process.env.PG_HOST,
port: process.env.PG_PORT,
user: process.env.PG_USER,
password: process.env.PG_PASS,
database: process.env.PG_DB,
options: '-c search_path=data,public',
max: 10,
idleTimeoutMillis: 30000,
});
pool.query('SELECT 1')
.then(() => console.log('PostgreSQL connection OK'))
.catch(err => {
console.error('PostgreSQL connection FAILED');
console.error(err);
process.exit(1);
});
module.exports = {
query: (text, params) => pool.query(text, params),
pool,
};
+139
View File
@@ -0,0 +1,139 @@
const express = require('express');
const cors = require('cors');
const jwt = require('jsonwebtoken');
require('dotenv').config();
const db = require('./db');
const app = express();
app.use(express.json({ limit: '64kb' }));
app.use(cors());
const PORT = Number(process.env.PORT || 5003);
const JWT_SECRET = process.env.JWT_SECRET || '';
// Admin verification is done by `auth_api` and carried in the JWT as `isAdmin`.
function toFriendlyError(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
function normalizeErrorText(value) {
if (!value) return 'Unknown error';
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch (_) {
return String(value);
}
}
async function insertErrorLog({ service, partOfService, error }) {
const serviceName = String(service || 'unknown_service').slice(0, 120);
const part = partOfService ? String(partOfService).slice(0, 240) : null;
const errorText = String(normalizeErrorText(error)).slice(0, 8000);
await db.query(
'INSERT INTO data.errors ("service", "partOfService", "error") VALUES ($1, $2, $3)',
[serviceName, part, errorText]
);
}
function verifyAdmin(req, res, next) {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
return res.status(401).json({ error: toFriendlyError(401) });
}
try {
const payload = jwt.verify(auth.slice(7), JWT_SECRET);
if (!payload?.isAdmin) {
return res.status(403).json({ error: toFriendlyError(403) });
}
req.user = payload;
return next();
} catch (err) {
return res.status(401).json({ error: toFriendlyError(401) });
}
}
app.get('/health', async (_req, res) => {
res.json({ status: 'ok', service: 'error_log_api' });
});
app.post('/error_log', async (req, res) => {
try {
const service = req.body?.service;
const partOfService = req.body?.partOfService || null;
const error = req.body?.error;
if (!service || !error) {
return res.status(400).json({ error: toFriendlyError(400) });
}
await insertErrorLog({ service, partOfService, error });
return res.status(201).json({ ok: true });
} catch (err) {
console.error('POST /error_log failed:', err);
return res.status(500).json({ error: toFriendlyError(500) });
}
});
app.get('/error_log', verifyAdmin, async (req, res) => {
try {
const limit = Math.min(Number(req.query.limit || 100), 500);
const service = req.query.service ? String(req.query.service) : null;
const partOfService = req.query.partOfService ? String(req.query.partOfService) : null;
const where = [];
const params = [];
if (service) {
params.push(service);
where.push(`"service" = $${params.length}`);
}
if (partOfService) {
params.push(partOfService);
where.push(`"partOfService" ILIKE $${params.length}`);
params[params.length - 1] = `%${partOfService}%`;
}
params.push(limit);
const rows = await db.query(
`SELECT id, "date", "service", "partOfService", "error"
FROM data.errors
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
ORDER BY "date" DESC, id DESC
LIMIT $${params.length}`,
params
);
return res.json({ errors: rows.rows });
} catch (err) {
console.error('GET /error_log failed:', err);
return res.status(500).json({ error: toFriendlyError(500) });
}
});
app.use(async (err, req, res, _next) => {
try {
await insertErrorLog({
service: 'error_log_api',
partOfService: `${req.method} ${req.originalUrl}`,
error: err?.stack || err?.message || String(err),
});
} catch (logErr) {
console.error('Failed to log internal error:', logErr);
}
if (res.headersSent) return;
return res.status(500).json({ error: toFriendlyError(500) });
});
app.listen(PORT, () => console.log(`Error Log API listening on ${PORT}`));
module.exports = app;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "error_log_api",
"version": "1.0.0",
"description": "Central error logging API",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"pg": "^8.13.0"
}
}
+39 -11
View File
@@ -9,6 +9,44 @@ const app = express();
app.use(express.json());
app.use(cors());
const ERROR_LOG_API_URL = process.env.ERROR_LOG_API_URL || 'http://localhost:5003/error_log';
function friendlyErrorMessage(statusCode) {
if (statusCode === 400) return 'Controleer je invoer en probeer opnieuw.';
if (statusCode === 401) return 'Je sessie is verlopen. Log opnieuw in.';
if (statusCode === 403) return 'Je hebt hier geen toegang toe.';
if (statusCode === 404) return 'Niet gevonden.';
return 'Er ging iets mis. Probeer het later opnieuw.';
}
async function reportError({ partOfService, error }) {
try {
await fetch(ERROR_LOG_API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
service: 'transit_api',
partOfService,
error,
}),
});
} catch (_) {}
}
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
if (res.statusCode >= 400 && body && typeof body === 'object') {
const rawError = body.error || body.message || body.details || `HTTP ${res.statusCode}`;
void reportError({ partOfService: `${req.method} ${req.originalUrl}`, error: rawError });
return originalJson({ error: friendlyErrorMessage(res.statusCode) });
}
return originalJson(body);
};
next();
});
// Development mock storage when DEV_MOCK=1
const DEV_MOCK = process.env.DEV_MOCK === '1';
const _mock = {
@@ -90,18 +128,8 @@ if (DEV_MOCK) {
_mock.nextScheduleId = 2;
}
const adminUserCodes = new Set(
(process.env.ADMIN_USER_CODES || '')
.split(',')
.map(code => code.trim())
.filter(Boolean)
.map(code => Number(code))
.filter(code => Number.isFinite(code))
);
const adminMiddleware = (req, res, next) => {
const userCode = Number(req.user?.userCode);
if (!Number.isFinite(userCode) || !adminUserCodes.has(userCode)) {
if (!req.user || !req.user.isAdmin) {
return res.status(403).json({ error: 'forbidden' });
}