Add API logging functionality and enhance scheduler service

Introduced ApiLog model and integrated logging for scheduled tasks in the SchedulerService. Updated server.js to include request logging middleware and new API log routes. Enhanced frontend navigation by adding a link to system logs for admin users. Adjusted session check interval in App.vue for improved performance. This update improves monitoring and debugging capabilities across the application.
This commit is contained in:
Torsten Schulz (local)
2025-10-29 13:35:25 +01:00
parent 7a35a0a1d3
commit 0b1e745f03
12 changed files with 1307 additions and 6 deletions

View File

@@ -0,0 +1,68 @@
import apiLogService from '../services/apiLogService.js';
import HttpError from '../exceptions/HttpError.js';
class ApiLogController {
/**
* GET /api/logs
* Get API logs with optional filters
*/
async getLogs(req, res, next) {
try {
const {
userId,
logType,
method,
path,
statusCode,
startDate,
endDate,
limit = 100,
offset = 0
} = req.query;
const result = await apiLogService.getLogs({
userId: userId ? parseInt(userId) : null,
logType,
method,
path,
statusCode: statusCode ? parseInt(statusCode) : null,
startDate,
endDate,
limit: parseInt(limit),
offset: parseInt(offset)
});
res.json({
success: true,
data: result
});
} catch (error) {
next(error);
}
}
/**
* GET /api/logs/:id
* Get a single log entry by ID
*/
async getLogById(req, res, next) {
try {
const { id } = req.params;
const log = await apiLogService.getLogById(parseInt(id));
if (!log) {
throw new HttpError(404, 'Log entry not found');
}
res.json({
success: true,
data: log
});
} catch (error) {
next(error);
}
}
}
export default new ApiLogController();

View File

@@ -0,0 +1,83 @@
import ApiLog from '../models/ApiLog.js';
/**
* Middleware to log all API requests and responses
* Should be added early in the middleware chain, but after authentication
*/
export const requestLoggingMiddleware = async (req, res, next) => {
const startTime = Date.now();
const originalSend = res.send;
// Get request body (but limit size for sensitive data)
let requestBody = null;
if (req.body && Object.keys(req.body).length > 0) {
const bodyStr = JSON.stringify(req.body);
// Truncate very long bodies
requestBody = bodyStr.length > 10000 ? bodyStr.substring(0, 10000) + '... (truncated)' : bodyStr;
}
// Capture response
let responseBody = null;
res.send = function(data) {
// Try to parse response as JSON
try {
const parsed = JSON.parse(data);
const responseStr = JSON.stringify(parsed);
// Truncate very long responses
responseBody = responseStr.length > 10000 ? responseStr.substring(0, 10000) + '... (truncated)' : responseStr;
} catch (e) {
// Not JSON, just use raw data (truncated)
responseBody = typeof data === 'string' ? data.substring(0, 1000) : String(data).substring(0, 1000);
}
// Restore original send
res.send = originalSend;
return res.send.apply(res, arguments);
};
// Log after response is sent
res.on('finish', async () => {
const executionTime = Date.now() - startTime;
const ipAddress = req.ip || req.connection.remoteAddress || req.headers['x-forwarded-for'];
const path = req.path || req.url;
// Skip logging for non-data endpoints (Status-Checks, Health-Checks, etc.)
// Nur Daten-Abrufe von API-Endpunkten werden geloggt
const skipPaths = [
'/status',
'/session/status',
'/health',
'/',
'/scheduler-status'
];
if (skipPaths.some(skipPath => path.includes(skipPath))) {
return;
}
// Get user ID if available (wird von authMiddleware gesetzt)
const userId = req.user?.id || null;
try {
await ApiLog.create({
userId,
method: req.method,
path: path,
statusCode: res.statusCode,
requestBody,
responseBody,
executionTime,
errorMessage: res.statusCode >= 400 ? `HTTP ${res.statusCode}` : null,
ipAddress,
userAgent: req.headers['user-agent'],
logType: 'api_request'
});
} catch (error) {
// Don't let logging errors break the request
console.error('Error logging API request:', error);
}
});
next();
};

View File

@@ -0,0 +1,26 @@
-- Migration: Create api_log table for comprehensive request/response and execution logging
CREATE TABLE IF NOT EXISTS api_log (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NULL,
method VARCHAR(10) NOT NULL COMMENT 'HTTP method (GET, POST, PUT, DELETE, etc.)',
path VARCHAR(500) NOT NULL COMMENT 'Request path',
status_code INT NULL COMMENT 'HTTP status code',
request_body TEXT NULL COMMENT 'Request body (truncated if too long)',
response_body TEXT NULL COMMENT 'Response body (truncated if too long)',
execution_time INT NULL COMMENT 'Execution time in milliseconds',
error_message TEXT NULL COMMENT 'Error message if request failed',
ip_address VARCHAR(45) NULL COMMENT 'Client IP address',
user_agent VARCHAR(500) NULL COMMENT 'User agent string',
log_type ENUM('api_request', 'scheduler', 'cron_job', 'manual') NOT NULL DEFAULT 'api_request' COMMENT 'Type of log entry',
scheduler_job_type VARCHAR(50) NULL COMMENT 'Type of scheduler job (rating_updates, match_results, etc.)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE SET NULL ON UPDATE CASCADE,
INDEX idx_api_log_user_id (user_id, created_at),
INDEX idx_api_log_path (path, created_at),
INDEX idx_api_log_log_type (log_type, created_at),
INDEX idx_api_log_created_at (created_at),
INDEX idx_api_log_status_code (status_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

102
backend/models/ApiLog.js Normal file
View File

@@ -0,0 +1,102 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import User from './User.js';
const ApiLog = sequelize.define('ApiLog', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false,
},
userId: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: User,
key: 'id',
},
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
},
method: {
type: DataTypes.STRING(10),
allowNull: false,
comment: 'HTTP method (GET, POST, PUT, DELETE, etc.)'
},
path: {
type: DataTypes.STRING(500),
allowNull: false,
comment: 'Request path'
},
statusCode: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'HTTP status code'
},
requestBody: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Request body (truncated if too long)'
},
responseBody: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Response body (truncated if too long)'
},
executionTime: {
type: DataTypes.INTEGER,
allowNull: true,
comment: 'Execution time in milliseconds'
},
errorMessage: {
type: DataTypes.TEXT,
allowNull: true,
comment: 'Error message if completes failed'
},
ipAddress: {
type: DataTypes.STRING(45),
allowNull: true,
comment: 'Client IP address'
},
userAgent: {
type: DataTypes.STRING(500),
allowNull: true,
comment: 'User agent string'
},
logType: {
type: DataTypes.ENUM('api_request', 'scheduler', 'cron_job', 'manual'),
allowNull: false,
defaultValue: 'api_request',
comment: 'Type of log entry'
},
schedulerJobType: {
type: DataTypes.STRING(50),
allowNull: true,
comment: 'Type of scheduler job (rating_updates, match_results, etc.)'
},
}, {
underscored: true,
tableName: 'api_log',
timestamps: true,
indexes: [
{
fields: ['user_id', 'created_at']
},
{
fields: ['path', 'created_at']
},
{
fields: ['log_type', 'created_at']
},
{
fields: ['created_at']
},
{
fields: ['status_code']
}
]
});
export default ApiLog;

View File

@@ -38,6 +38,7 @@ import OfficialCompetitionMember from './OfficialCompetitionMember.js';
import MyTischtennis from './MyTischtennis.js';
import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js';
import MyTischtennisFetchLog from './MyTischtennisFetchLog.js';
import ApiLog from './ApiLog.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' });
@@ -238,6 +239,9 @@ MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' })
User.hasMany(MyTischtennisFetchLog, { foreignKey: 'userId', as: 'fetchLogs' });
MyTischtennisFetchLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
User.hasMany(ApiLog, { foreignKey: 'userId', as: 'apiLogs' });
ApiLog.belongsTo(User, { foreignKey: 'userId', as: 'user' });
export {
User,
Log,
@@ -278,4 +282,5 @@ export {
MyTischtennis,
MyTischtennisUpdateHistory,
MyTischtennisFetchLog,
ApiLog,
};

View File

@@ -0,0 +1,18 @@
import express from 'express';
import apiLogController from '../controllers/apiLogController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
const router = express.Router();
// All routes require authentication
router.use(authenticate);
// Get logs - requires permissions or admin
router.get('/', apiLogController.getLogs);
// Get single log by ID
router.get('/:id', apiLogController.getLogById);
export default router;

View File

@@ -8,7 +8,7 @@ import {
DiaryNote, DiaryTag, MemberDiaryTag, DiaryDateTag, DiaryMemberNote, DiaryMemberTag,
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -40,8 +40,10 @@ import teamDocumentRoutes from './routes/teamDocumentRoutes.js';
import seasonRoutes from './routes/seasonRoutes.js';
import memberActivityRoutes from './routes/memberActivityRoutes.js';
import permissionRoutes from './routes/permissionRoutes.js';
import apiLogRoutes from './routes/apiLogRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
const app = express();
const port = process.env.PORT || 3005;
@@ -56,6 +58,10 @@ app.use(cors({
}));
app.use(express.json());
// Request Logging Middleware - loggt alle API-Requests
// Wichtig: userId wird später in authMiddleware gesetzt, aber Middleware funktioniert auch ohne
app.use(requestLoggingMiddleware);
// Globale Fehlerbehandlung, damit der Server bei unerwarteten Fehlern nicht hart abstürzt
process.on('uncaughtException', (err) => {
console.error('[uncaughtException]', err);
@@ -92,6 +98,7 @@ app.use('/api/team-documents', teamDocumentRoutes);
app.use('/api/seasons', seasonRoutes);
app.use('/api/member-activities', memberActivityRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/logs', apiLogRoutes);
app.use(express.static(path.join(__dirname, '../frontend/dist')));
@@ -194,6 +201,7 @@ app.get('*', (req, res) => {
await safeSync(MyTischtennis);
await safeSync(MyTischtennisUpdateHistory);
await safeSync(MyTischtennisFetchLog);
await safeSync(ApiLog);
// Start scheduler service
schedulerService.start();

View File

@@ -0,0 +1,161 @@
import ApiLog from '../models/ApiLog.js';
import { Op } from 'sequelize';
class ApiLogService {
/**
* Log an API request/response
*/
async logRequest(options) {
try {
const {
userId = null,
method,
path,
statusCode = null,
requestBody = null,
responseBody = null,
executionTime = null,
errorMessage = null,
ipAddress = null,
userAgent = null,
logType = 'api_request',
schedulerJobType = null
} = options;
// Truncate long fields
const truncate = (str, maxLen = 10000) => {
if (!str) return null;
const strVal = typeof str === 'string' ? str : JSON.stringify(str);
return strVal.length > maxLen ? strVal.substring(0, maxLen) + '... (truncated)' : strVal;
};
await ApiLog.create({
userId,
method,
path,
statusCode,
requestBody: truncate(requestBody),
responseBody: truncate(responseBody),
executionTime,
errorMessage: truncate(errorMessage, 5000),
ipAddress,
userAgent,
logType,
schedulerJobType
});
} catch (error) {
console.error('Error logging API request:', error);
// Don't throw - logging failures shouldn't break the main operation
}
}
/**
* Log scheduler execution
*/
async logSchedulerExecution(jobType, success, message, executionTime = null, errorMessage = null) {
try {
await ApiLog.create({
userId: null,
method: 'SCHEDULER',
path: `/scheduler/${jobType}`,
statusCode: success ? 200 : 500,
responseBody: message,
executionTime,
errorMessage,
logType: 'scheduler',
schedulerJobType: jobType
});
} catch (error) {
console.error('Error logging scheduler execution:', error);
}
}
/**
* Get logs with filters
*/
async getLogs(options = {}) {
try {
const {
userId = null,
logType = null,
method = null,
path = null,
statusCode = null,
startDate = null,
endDate = null,
limit = 100,
offset = 0
} = options;
const where = {};
if (userId) {
where.userId = userId;
}
if (logType) {
where.logType = logType;
}
if (method) {
where.method = method;
}
if (path) {
where.path = { [Op.like]: `%${path}%` };
}
if (statusCode !== null) {
where.statusCode = statusCode;
}
if (startDate || endDate) {
where.createdAt = {};
if (startDate) {
where.createdAt[Op.gte] = new Date(startDate);
}
if (endDate) {
where.createdAt[Op.lte] = new Date(endDate);
}
}
const logs = await ApiLog.findAndCountAll({
where,
order: [['createdAt', 'DESC']],
limit: parseInt(limit),
offset: parseInt(offset),
attributes: [
'id', 'userId', 'method', 'path', 'statusCode',
'executionTime', 'errorMessage', 'ipAddress', 'logType',
'schedulerJobType', 'createdAt'
]
});
return {
logs: logs.rows,
total: logs.count,
limit: parseInt(limit),
offset: parseInt(offset)
};
} catch (error) {
console.error('Error getting logs:', error);
throw error;
}
}
/**
* Get a single log by ID
*/
async getLogById(logId) {
try {
const log = await ApiLog.findByPk(logId);
return log;
} catch (error) {
console.error('Error getting log by ID:', error);
throw error;
}
}
}
export default new ApiLogService();

View File

@@ -1,6 +1,7 @@
import cron from 'node-cron';
import autoUpdateRatingsService from './autoUpdateRatingsService.js';
import autoFetchMatchResultsService from './autoFetchMatchResultsService.js';
import apiLogService from './apiLogService.js';
import { devLog } from '../utils/logger.js';
class SchedulerService {
@@ -22,11 +23,33 @@ class SchedulerService {
// Schedule automatic rating updates at 6:00 AM daily
const ratingUpdateJob = cron.schedule('0 6 * * *', async () => {
const startTime = Date.now();
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] CRON: Executing scheduled rating updates...`);
devLog('Executing scheduled rating updates...');
let success = false;
let message = '';
let errorMessage = null;
try {
await autoUpdateRatingsService.executeAutomaticUpdates();
const executionTime = Date.now() - startTime;
success = true;
message = 'Rating updates completed successfully';
console.log(`[${new Date().toISOString()}] CRON: Rating updates completed successfully`);
// Log to ApiLog
await apiLogService.logSchedulerExecution('rating_updates', true, message, executionTime, null);
} catch (error) {
console.error('Error in scheduled rating updates:', error);
const executionTime = Date.now() - startTime;
success = false;
errorMessage = error.message;
console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled rating updates:`, error);
console.error('Stack trace:', error.stack);
// Log to ApiLog
await apiLogService.logSchedulerExecution('rating_updates', false, 'Rating updates failed', executionTime, errorMessage);
}
}, {
scheduled: false, // Don't start automatically
@@ -35,14 +58,37 @@ class SchedulerService {
this.jobs.set('ratingUpdates', ratingUpdateJob);
ratingUpdateJob.start();
console.log('[Scheduler] Rating update job scheduled and started');
// Schedule automatic match results fetching at 6:30 AM daily
const matchResultsJob = cron.schedule('30 6 * * *', async () => {
const startTime = Date.now();
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] CRON: Executing scheduled match results fetch...`);
devLog('Executing scheduled match results fetch...');
let success = false;
let message = '';
let errorMessage = null;
try {
await autoFetchMatchResultsService.executeAutomaticFetch();
const executionTime = Date.now() - startTime;
success = true;
message = 'Match results fetch completed successfully';
console.log(`[${new Date().toISOString()}] CRON: Match results fetch completed successfully`);
// Log to ApiLog
await apiLogService.logSchedulerExecution('match_results', true, message, executionTime, null);
} catch (error) {
console.error('Error in scheduled match results fetch:', error);
const executionTime = Date.now() - startTime;
success = false;
errorMessage = error.message;
console.error(`[${new Date().toISOString()}] CRON ERROR in scheduled match results fetch:`, error);
console.error('Stack trace:', error.stack);
// Log to ApiLog
await apiLogService.logSchedulerExecution('match_results', false, 'Match results fetch failed', executionTime, errorMessage);
}
}, {
scheduled: false, // Don't start automatically
@@ -51,8 +97,25 @@ class SchedulerService {
this.jobs.set('matchResults', matchResultsJob);
matchResultsJob.start();
console.log('[Scheduler] Match results fetch job scheduled and started');
this.isRunning = true;
const now = new Date();
const tomorrow6AM = new Date(now);
tomorrow6AM.setDate(tomorrow6AM.getDate() + 1);
tomorrow6AM.setHours(6, 0, 0, 0);
const tomorrow630AM = new Date(now);
tomorrow630AM.setDate(tomorrow630AM.getDate() + 1);
tomorrow630AM.setHours(6, 30, 0, 0);
console.log('[Scheduler] ===== SCHEDULER SERVICE STARTED =====');
console.log(`[Scheduler] Server time: ${now.toISOString()}`);
console.log(`[Scheduler] Timezone: Europe/Berlin`);
console.log(`[Scheduler] Rating updates: Next execution at ${tomorrow6AM.toISOString()} (6:00 AM Berlin time)`);
console.log(`[Scheduler] Match results fetch: Next execution at ${tomorrow630AM.toISOString()} (6:30 AM Berlin time)`);
console.log('[Scheduler] =====================================');
devLog('Scheduler service started successfully');
devLog('Rating updates scheduled for 6:00 AM daily (Europe/Berlin timezone)');
devLog('Match results fetch scheduled for 6:30 AM daily (Europe/Berlin timezone)');

View File

@@ -22,6 +22,10 @@
<span class="dropdown-icon">🔐</span>
Berechtigungen
</router-link>
<router-link v-if="isAdmin" to="/logs" class="dropdown-item" @click="userDropdownOpen = false">
<span class="dropdown-icon">📋</span>
System-Logs
</router-link>
<div class="dropdown-divider"></div>
<button @click="logout" class="dropdown-item logout-item">
<span class="dropdown-icon">🚪</span>
@@ -196,6 +200,11 @@ export default {
// Owner oder Admin können Freigaben verwalten
return this.isClubOwner || this.userRole === 'admin' || this.hasPermission('approvals', 'read');
},
isAdmin() {
// Nur anzeigen, wenn Permissions geladen sind UND Admin-Rechte vorhanden
if (!this.currentClub) return false;
return this.isClubOwner || this.userRole === 'admin';
},
canManagePermissions() {
// Nur anzeigen, wenn Permissions geladen sind UND Berechtigung vorhanden
if (!this.currentClub) return false;
@@ -282,7 +291,8 @@ export default {
this.selectedClub = this.currentClub;
}
this.checkSession();
this.sessionInterval = setInterval(this.checkSession, 5000);
// Session-Check alle 30 Sekunden
this.sessionInterval = setInterval(this.checkSession, 30000);
} catch (error) {
this.setClubs([]);
this.selectedClub = null;
@@ -327,7 +337,8 @@ export default {
this.selectedClub = this.currentClub;
}
this.checkSession();
this.sessionInterval = setInterval(this.checkSession, 5000);
// Session-Check alle 30 Sekunden
this.sessionInterval = setInterval(this.checkSession, 30000);
} catch (error) {
this.setClubs([]);
this.selectedClub = null;

View File

@@ -16,6 +16,7 @@ import OfficialTournaments from './views/OfficialTournaments.vue';
import MyTischtennisAccount from './views/MyTischtennisAccount.vue';
import TeamManagementView from './views/TeamManagementView.vue';
import PermissionsView from './views/PermissionsView.vue';
import LogsView from './views/LogsView.vue';
import Impressum from './views/Impressum.vue';
import Datenschutz from './views/Datenschutz.vue';
@@ -37,6 +38,7 @@ const routes = [
{ path: '/mytischtennis-account', component: MyTischtennisAccount },
{ path: '/team-management', component: TeamManagementView },
{ path: '/permissions', component: PermissionsView },
{ path: '/logs', component: LogsView },
{ path: '/impressum', component: Impressum },
{ path: '/datenschutz', component: Datenschutz },
];

View File

@@ -0,0 +1,754 @@
<template>
<div class="logs-view">
<div class="header">
<h1>System-Logs</h1>
<p class="subtitle">Übersicht über alle API-Requests, Responses und Ausführungen</p>
</div>
<div class="filters-section">
<div class="filter-controls">
<div class="filter-group">
<label>Log-Typ:</label>
<select v-model="filters.logType" class="filter-select">
<option value="">Alle</option>
<option value="api_request">API-Requests</option>
<option value="scheduler">Scheduler</option>
<option value="cron_job">Cron-Jobs</option>
<option value="manual">Manuelle Ausführungen</option>
</select>
</div>
<div class="filter-group">
<label>HTTP-Methode:</label>
<select v-model="filters.method" class="filter-select">
<option value="">Alle</option>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
<div class="filter-group">
<label>Status:</label>
<select v-model="filters.statusCode" class="filter-select">
<option value="">Alle</option>
<option value="200">200 - OK</option>
<option value="400">400 - Bad Request</option>
<option value="401">401 - Unauthorized</option>
<option value="403">403 - Forbidden</option>
<option value="404">404 - Not Found</option>
<option value="500">500 - Server Error</option>
</select>
</div>
<div class="filter-group">
<label>Pfad:</label>
<input
type="text"
v-model="filters.path"
placeholder="z.B. /api/diary"
class="filter-input"
/>
</div>
<div class="filter-group">
<label>Von:</label>
<input type="date" v-model="filters.startDate" class="filter-input" />
</div>
<div class="filter-group">
<label>Bis:</label>
<input type="date" v-model="filters.endDate" class="filter-input" />
</div>
<button @click="applyFilters" class="btn-primary">Filter anwenden</button>
<button @click="clearFilters" class="btn-secondary">Zurücksetzen</button>
</div>
</div>
<!-- Status-Banner -->
<div v-if="lastLoadTime" class="status-banner" :class="statusBannerClass">
<div class="status-content">
<span class="status-icon">{{ statusIcon }}</span>
<span class="status-text">
<template v-if="!loading && !error">
<strong>Erfolgreich geladen</strong> {{ total }} Datensätze gefunden, {{ logs.length }} angezeigt
</template>
<template v-else-if="loading">
Lade Logs...
</template>
<template v-else-if="error">
<strong>Fehler:</strong> {{ error }}
</template>
</span>
<span class="status-time">{{ formatLastLoadTime(lastLoadTime) }}</span>
</div>
</div>
<div v-if="loading && !lastLoadTime" class="loading">Lade Logs...</div>
<div v-else-if="error && !lastLoadTime" class="error">{{ error }}</div>
<div v-else class="logs-content">
<div class="logs-stats">
<div class="stat-item">
<span class="stat-label">Gesamt:</span>
<span class="stat-value">{{ total }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Angezeigt:</span>
<span class="stat-value">{{ logs.length }}</span>
</div>
</div>
<div class="logs-table-container">
<table class="logs-table">
<thead>
<tr>
<th>Zeit</th>
<th>Typ</th>
<th>Methode</th>
<th>Pfad</th>
<th>Status</th>
<th>Ausführungszeit</th>
<th>Fehler</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.id" :class="getLogRowClass(log)">
<td>{{ formatDate(log.createdAt) }}</td>
<td>
<span class="log-type-badge" :class="`log-type-${log.logType}`">
{{ getLogTypeLabel(log.logType) }}
</span>
</td>
<td>
<span v-if="log.method !== 'SCHEDULER'" class="method-badge" :class="`method-${log.method}`">
{{ log.method }}
</span>
<span v-else class="scheduler-badge"></span>
</td>
<td class="path-cell">{{ log.path }}</td>
<td>
<span class="status-badge" :class="getStatusClass(log.statusCode)">
{{ log.statusCode || '-' }}
</span>
</td>
<td>{{ formatExecutionTime(log.executionTime) }}</td>
<td>
<span v-if="log.errorMessage" class="error-indicator" :title="log.errorMessage">
{{ truncate(log.errorMessage, 50) }}
</span>
<span v-else>-</span>
</td>
<td>
<button @click="viewLogDetails(log)" class="btn-view">Details</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button
@click="previousPage"
:disabled="offset === 0"
class="btn-pagination"
>
Vorherige
</button>
<span class="page-info">
Seite {{ currentPage }} von {{ totalPages }}
</span>
<button
@click="nextPage"
:disabled="offset + logs.length >= total"
class="btn-pagination"
>
Nächste
</button>
</div>
</div>
<!-- Log Details Dialog -->
<InfoDialog
v-model="logDetailsDialog.isOpen"
:title="logDetailsDialog.title"
:message="logDetailsDialog.message"
:details="logDetailsDialog.details"
:type="logDetailsDialog.type"
/>
</div>
</template>
<script>
import { ref, computed, onMounted } from 'vue';
import apiClient from '../apiClient.js';
import InfoDialog from '../components/InfoDialog.vue';
export default {
name: 'LogsView',
components: {
InfoDialog
},
setup() {
const logs = ref([]);
const total = ref(0);
const loading = ref(false);
const error = ref(null);
const limit = ref(50);
const offset = ref(0);
const lastLoadTime = ref(null);
const filters = ref({
logType: '',
method: '',
statusCode: '',
path: '',
startDate: '',
endDate: ''
});
const logDetailsDialog = ref({
isOpen: false,
title: '',
message: '',
details: '',
type: 'info'
});
const currentPage = computed(() => Math.floor(offset.value / limit.value) + 1);
const totalPages = computed(() => Math.ceil(total.value / limit.value));
const loadLogs = async () => {
loading.value = true;
error.value = null;
try {
const params = {
limit: limit.value,
offset: offset.value,
...filters.value
};
// Remove empty filters
Object.keys(params).forEach(key => {
if (params[key] === '' || params[key] === null) {
delete params[key];
}
});
const response = await apiClient.get('/logs', { params });
if (response.data.success) {
logs.value = response.data.data.logs;
total.value = response.data.data.total;
error.value = null;
lastLoadTime.value = new Date();
} else {
error.value = 'Fehler beim Laden der Logs';
lastLoadTime.value = new Date();
}
} catch (err) {
console.error('Error loading logs:', err);
error.value = err.response?.data?.error || 'Fehler beim Laden der Logs';
lastLoadTime.value = new Date();
} finally {
loading.value = false;
}
};
const applyFilters = () => {
offset.value = 0;
loadLogs();
};
const clearFilters = () => {
filters.value = {
logType: '',
method: '',
statusCode: '',
path: '',
startDate: '',
endDate: ''
};
offset.value = 0;
loadLogs();
};
const previousPage = () => {
if (offset.value >= limit.value) {
offset.value -= limit.value;
loadLogs();
}
};
const nextPage = () => {
if (offset.value + logs.value.length < total.value) {
offset.value += limit.value;
loadLogs();
}
};
const viewLogDetails = async (log) => {
try {
const response = await apiClient.get(`/logs/${log.id}`);
if (response.data.success) {
const logDetails = response.data.data;
logDetailsDialog.value = {
isOpen: true,
title: `Log-Details #${log.id}`,
message: `${logDetails.method} ${logDetails.path}`,
details: formatLogDetails(logDetails),
type: logDetails.statusCode >= 400 ? 'error' : 'info'
};
}
} catch (err) {
console.error('Error loading log details:', err);
}
};
const formatLogDetails = (log) => {
let details = `Zeit: ${formatDate(log.createdAt)}\n`;
details += `Typ: ${getLogTypeLabel(log.logType)}\n`;
details += `Methode: ${log.method}\n`;
details += `Pfad: ${log.path}\n`;
if (log.statusCode) {
details += `Status: ${log.statusCode}\n`;
}
if (log.executionTime) {
details += `Ausführungszeit: ${formatExecutionTime(log.executionTime)}\n`;
}
if (log.ipAddress) {
details += `IP: ${log.ipAddress}\n`;
}
if (log.schedulerJobType) {
details += `Scheduler-Job: ${log.schedulerJobType}\n`;
}
if (log.errorMessage) {
details += `\nFehler:\n${log.errorMessage}\n`;
}
if (log.requestBody) {
details += `\nRequest Body:\n${log.requestBody}\n`;
}
if (log.responseBody) {
details += `\nResponse Body:\n${log.responseBody}\n`;
}
return details;
};
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
const formatExecutionTime = (time) => {
if (!time) return '-';
if (time < 1000) return `${time}ms`;
return `${(time / 1000).toFixed(2)}s`;
};
const getLogTypeLabel = (type) => {
const labels = {
api_request: 'API-Request',
scheduler: 'Scheduler',
cron_job: 'Cron-Job',
manual: 'Manuell'
};
return labels[type] || type;
};
const getLogRowClass = (log) => {
return {
'log-error': log.statusCode >= 400,
'log-success': log.statusCode >= 200 && log.statusCode < 300,
'log-scheduler': log.logType === 'scheduler'
};
};
const getStatusClass = (statusCode) => {
if (!statusCode) return 'status-unknown';
if (statusCode >= 200 && statusCode < 300) return 'status-success';
if (statusCode >= 400 && statusCode < 500) return 'status-client-error';
if (statusCode >= 500) return 'status-server-error';
return 'status-info';
};
const truncate = (str, maxLen) => {
if (!str) return '';
return str.length > maxLen ? str.substring(0, maxLen) + '...' : str;
};
const statusBannerClass = computed(() => {
if (loading.value) return 'status-loading';
if (error.value) return 'status-error';
return 'status-success';
});
const statusIcon = computed(() => {
if (loading.value) return '⏳';
if (error.value) return '❌';
return '✅';
});
const formatLastLoadTime = (date) => {
if (!date) return '';
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 5) return 'gerade eben';
if (diff < 60) return `vor ${diff} Sekunden`;
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Minuten`;
return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
};
onMounted(() => {
loadLogs();
});
return {
logs,
total,
loading,
error,
filters,
logDetailsDialog,
currentPage,
totalPages,
applyFilters,
clearFilters,
previousPage,
nextPage,
viewLogDetails,
formatDate,
formatExecutionTime,
getLogTypeLabel,
getLogRowClass,
getStatusClass,
truncate,
lastLoadTime,
statusBannerClass,
statusIcon,
formatLastLoadTime
};
}
};
</script>
<style scoped>
.logs-view {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
}
.header {
margin-bottom: 2rem;
}
.header h1 {
margin: 0 0 0.5rem 0;
color: var(--text-color, #333);
}
.subtitle {
color: var(--text-secondary, #666);
margin: 0;
}
.status-banner {
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1.5rem;
border-left: 4px solid;
}
.status-success {
background: #dcfce7;
border-color: #16a34a;
color: #166534;
}
.status-error {
background: #fee2e2;
border-color: #dc2626;
color: #991b1b;
}
.status-loading {
background: #fef3c7;
border-color: #f59e0b;
color: #92400e;
}
.status-content {
display: flex;
align-items: center;
gap: 0.75rem;
}
.status-icon {
font-size: 1.2em;
}
.status-text {
flex: 1;
font-size: 0.95em;
}
.status-time {
font-size: 0.85em;
opacity: 0.8;
white-space: nowrap;
}
.filters-section {
background: var(--background-light, #f8f9fa);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.9em;
font-weight: 500;
color: var(--text-color, #333);
}
.filter-select,
.filter-input {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9em;
min-width: 120px;
}
.logs-stats {
display: flex;
gap: 2rem;
margin-bottom: 1rem;
}
.stat-item {
display: flex;
gap: 0.5rem;
}
.stat-label {
font-weight: 500;
}
.stat-value {
font-weight: 600;
color: var(--primary-color, #007bff);
}
.logs-table-container {
overflow-x: auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table thead {
background: var(--background-light, #f8f9fa);
}
.logs-table th {
padding: 1rem;
text-align: left;
font-weight: 600;
border-bottom: 2px solid #ddd;
}
.logs-table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #eee;
}
.logs-table tbody tr:hover {
background: var(--background-light, #f8f9fa);
}
.log-error {
background: #fff5f5;
}
.log-success {
background: #f0fdf4;
}
.log-scheduler {
background: #fefce8;
}
.log-type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
}
.log-type-api_request {
background: #e0f2fe;
color: #0369a1;
}
.log-type-scheduler {
background: #fef3c7;
color: #92400e;
}
.log-type-cron_job {
background: #fce7f3;
color: #9f1239;
}
.method-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 600;
font-family: monospace;
}
.method-GET {
background: #dbeafe;
color: #1e40af;
}
.method-POST {
background: #dcfce7;
color: #166534;
}
.method-PUT {
background: #fef3c7;
color: #92400e;
}
.method-DELETE {
background: #fee2e2;
color: #991b1b;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 600;
}
.status-success {
background: #dcfce7;
color: #166534;
}
.status-client-error {
background: #fef3c7;
color: #92400e;
}
.status-server-error {
background: #fee2e2;
color: #991b1b;
}
.path-cell {
font-family: monospace;
font-size: 0.9em;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.error-indicator {
color: #dc2626;
font-size: 0.9em;
}
.btn-view {
padding: 0.25rem 0.75rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
}
.btn-view:hover {
opacity: 0.9;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
}
.btn-pagination {
padding: 0.5rem 1rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
}
.btn-pagination:hover:not(:disabled) {
background: var(--background-light, #f8f9fa);
}
.btn-pagination:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-weight: 500;
}
</style>