Skip to content
283 changes: 283 additions & 0 deletions server/db/apiLogs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import { mainDb } from "./connection.js";
import { decrypt } from "../utils/encryption.js";
import { sql } from "kysely";

export interface ApiLogFilters {
userId?: string;
username?: string;
method?: string;
path?: string;
statusCode?: number;
dateFrom?: string;
dateTo?: string;
search?: string;
}

export interface ApiLogStats {
totalRequests: number;
averageResponseTime: number;
errorRate: number;
topEndpoints: Array<{
path: string;
count: number;
avgResponseTime: number;
}>;
statusCodeDistribution: Array<{
statusCode: number;
count: number;
}>;
dailyStats: Array<{
date: string;
requestCount: number;
errorCount: number;
avgResponseTime: number;
}>;
}

export async function getApiLogs(
page = 1,
limit = 50,
filters: ApiLogFilters = {}
) {
try {
const offset = (page - 1) * limit;

let query = mainDb
.selectFrom('api_logs')
.select([
'id',
'user_id',
'username',
'method',
'path',
'status_code',
'response_time',
'ip_address',
'user_agent',
'request_body',
'response_body',
'error_message',
'timestamp'
])
.orderBy('timestamp', 'desc');

// Apply filters
if (filters.userId) {
query = query.where(q =>
q.or([
q('user_id', 'ilike', `%${filters.userId}%`),
q('username', 'ilike', `%${filters.userId}%`)
])
);
}

if (filters.method) {
query = query.where('method', '=', filters.method.toUpperCase());
}

if (filters.path) {
query = query.where('path', 'ilike', `%${filters.path}%`);
}

if (filters.statusCode) {
query = query.where('status_code', '=', filters.statusCode);
}

if (filters.dateFrom) {
query = query.where('timestamp', '>=', new Date(filters.dateFrom));
}

if (filters.dateTo) {
query = query.where('timestamp', '<=', new Date(filters.dateTo));
}

if (filters.search) {
query = query.where(q =>
q.or([
q('path', 'ilike', `%${filters.search}%`),
q('username', 'ilike', `%${filters.search}%`),
q('method', 'ilike', `%${filters.search}%`)
])
);
}

let countQuery = mainDb
.selectFrom('api_logs')
.select(sql<number>`count(*)`.as('count'));

if (filters.userId) {
countQuery = countQuery.where(q =>
q.or([
q('user_id', 'ilike', `%${filters.userId}%`),
q('username', 'ilike', `%${filters.userId}%`)
])
);
}
if (filters.method) {
countQuery = countQuery.where('method', '=', filters.method.toUpperCase());
}
if (filters.path) {
countQuery = countQuery.where('path', 'ilike', `%${filters.path}%`);
}
if (filters.statusCode) {
countQuery = countQuery.where('status_code', '=', filters.statusCode);
}
if (filters.dateFrom) {
countQuery = countQuery.where('timestamp', '>=', new Date(filters.dateFrom));
}
if (filters.dateTo) {
countQuery = countQuery.where('timestamp', '<=', new Date(filters.dateTo));
}
if (filters.search) {
countQuery = countQuery.where(q =>
q.or([
q('path', 'ilike', `%${filters.search}%`),
q('username', 'ilike', `%${filters.search}%`),
q('method', 'ilike', `%${filters.search}%`)
])
);
}

const totalResult = await countQuery.executeTakeFirst();
const total = Number(totalResult?.count || 0);

// Get paginated results
const logs = await query
.limit(limit)
.offset(offset)
.execute();

// Decrypt sensitive fields for display
const decryptedLogs = logs.map(log => ({
...log,
ip_address: log.ip_address ? decrypt(JSON.parse(log.ip_address)) : null,
request_body: log.request_body ? decrypt(JSON.parse(log.request_body)) : null,
response_body: log.response_body ? decrypt(JSON.parse(log.response_body)) : null,
error_message: log.error_message ? decrypt(JSON.parse(log.error_message)) : null,
}));

return {
logs: decryptedLogs,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
};
} catch (error) {
console.error('Error fetching API logs:', error);
throw error;
}
}

export async function getApiLogById(logId: number | string) {
try {
const log = await mainDb
.selectFrom('api_logs')
.selectAll()
.where('id', '=', typeof logId === 'string' ? parseInt(logId) : logId)
.executeTakeFirst();

if (!log) return null;

// Decrypt sensitive fields
return {
...log,
ip_address: log.ip_address ? decrypt(JSON.parse(log.ip_address)) : null,
request_body: log.request_body ? decrypt(JSON.parse(log.request_body)) : null,
response_body: log.response_body ? decrypt(JSON.parse(log.response_body)) : null,
error_message: log.error_message ? decrypt(JSON.parse(log.error_message)) : null,
};
} catch (error) {
console.error('Error fetching API log by ID:', error);
throw error;
}
}

export async function getApiLogStats(days: number = 7): Promise<ApiLogStats> {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - days);

// Total requests and average response time
const totalStatsResult = await mainDb
.selectFrom('api_logs')
.select([
sql<number>`count(*)`.as('totalRequests'),
sql<number>`avg(response_time)`.as('averageResponseTime'),
sql<number>`count(case when status_code >= 400 then 1 end)`.as('errorCount')
])
.where('timestamp', '>=', cutoffDate)
.executeTakeFirst();

const totalRequests = Number(totalStatsResult?.totalRequests || 0);
const averageResponseTime = Number(totalStatsResult?.averageResponseTime || 0);
const errorCount = Number(totalStatsResult?.errorCount || 0);
const errorRate = totalRequests > 0 ? (errorCount / totalRequests) * 100 : 0;

// Top endpoints
const topEndpoints = await mainDb
.selectFrom('api_logs')
.select([
'path',
sql<number>`count(*)`.as('count'),
sql<number>`avg(response_time)`.as('avgResponseTime')
])
.where('timestamp', '>=', cutoffDate)
.groupBy('path')
.orderBy('count', 'desc')
.limit(10)
.execute();

// Status code distribution
const statusCodeDistribution = await mainDb
.selectFrom('api_logs')
.select([
'status_code',
sql<number>`count(*)`.as('count')
])
.where('timestamp', '>=', cutoffDate)
.groupBy('status_code')
.orderBy('count', 'desc')
.execute();

// Daily stats
const dailyStats = await mainDb
.selectFrom('api_logs')
.select([
sql<string>`date(timestamp)`.as('date'),
sql<number>`count(*)`.as('requestCount'),
sql<number>`count(case when status_code >= 400 then 1 end)`.as('errorCount'),
sql<number>`avg(response_time)`.as('avgResponseTime')
])
.where('timestamp', '>=', cutoffDate)
.groupBy(sql`date(timestamp)`)
.orderBy('date', 'desc')
.execute();

return {
totalRequests,
averageResponseTime: Math.round(averageResponseTime),
errorRate: Math.round(errorRate * 100) / 100,
topEndpoints: topEndpoints.map(endpoint => ({
path: endpoint.path,
count: Number(endpoint.count),
avgResponseTime: Math.round(Number(endpoint.avgResponseTime))
})),
statusCodeDistribution: statusCodeDistribution.map(status => ({
statusCode: status.status_code,
count: Number(status.count)
})),
dailyStats: dailyStats.map(day => ({
date: day.date,
requestCount: Number(day.requestCount),
errorCount: Number(day.errorCount),
avgResponseTime: Math.round(Number(day.avgResponseTime))
}))
};
} catch (error) {
console.error('Error fetching API log stats:', error);
throw error;
}
}
6 changes: 4 additions & 2 deletions server/db/flights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
}

function sanitizeFlightForClient(flight: FlightsDatabase[string]): ClientFlight {
const { user_id, ip_address, acars_token, cruisingfl, clearedfl, ...sanitizedFlight } = flight;

Check warning on line 62 in server/db/flights.ts

View workflow job for this annotation

GitHub Actions / build

'acars_token' is assigned a value but never used

Check warning on line 62 in server/db/flights.ts

View workflow job for this annotation

GitHub Actions / build

'acars_token' is assigned a value but never used

Check warning on line 62 in server/db/flights.ts

View workflow job for this annotation

GitHub Actions / build

'ip_address' is assigned a value but never used

Check warning on line 62 in server/db/flights.ts

View workflow job for this annotation

GitHub Actions / build

'ip_address' is assigned a value but never used

Check warning on line 62 in server/db/flights.ts

View workflow job for this annotation

GitHub Actions / build

'user_id' is assigned a value but never used

Check warning on line 62 in server/db/flights.ts

View workflow job for this annotation

GitHub Actions / build

'user_id' is assigned a value but never used
return {
...sanitizedFlight,
cruisingFL: cruisingfl,
Expand Down Expand Up @@ -251,10 +251,12 @@
.where((eb) =>
eb.or([
eb('timestamp', '>=', sinceDateISOString),
eb('updated_at', '>=', sinceDateUTC),
eb('updated_at', '>=', sql<Date>`${sinceDateISOString}`),
eb('created_at', '>=', sql<Date>`${sinceDateISOString}`),
])
)
.orderBy('timestamp', 'asc')
.orderBy(sql`COALESCE(timestamp::timestamp, created_at, updated_at)`, 'desc')
.orderBy('callsign', 'asc')
.execute();

// Enrich flights with user data
Expand Down
53 changes: 53 additions & 0 deletions server/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,59 @@ export async function createMainTables() {
.addColumn('created_at', 'timestamp', (col) => col.defaultTo('now()'))
.addColumn('updated_at', 'timestamp', (col) => col.defaultTo('now()'))
.execute();

// api_logs
await mainDb.schema
.createTable('api_logs')
.ifNotExists()
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('user_id', 'varchar(255)')
.addColumn('username', 'varchar(255)')
.addColumn('method', 'varchar(10)', (col) => col.notNull())
.addColumn('path', 'text', (col) => col.notNull())
.addColumn('status_code', 'integer', (col) => col.notNull())
.addColumn('response_time', 'integer', (col) => col.notNull())
.addColumn('ip_address', 'text')
.addColumn('user_agent', 'text')
.addColumn('request_body', 'text')
.addColumn('response_body', 'text')
.addColumn('error_message', 'text')
.addColumn('timestamp', 'timestamp', (col) => col.defaultTo('now()'))
.execute();

try {
await mainDb.schema
.createIndex('idx_api_logs_timestamp')
.on('api_logs')
.column('timestamp')
.execute();
} catch {
// Index might already exist
}

try {
await mainDb.schema
.createIndex('idx_api_logs_user_id')
.on('api_logs')
.column('user_id')
.execute();
} catch {
// Index might already exist
}

await mainDb.schema
.createIndex('api_logs_path_idx')
.ifNotExists()
.on('api_logs')
.column('path')
.execute();

await mainDb.schema
.createIndex('api_logs_status_code_idx')
.ifNotExists()
.on('api_logs')
.column('status_code')
.execute();
}

// Helper to create a dynamic flights table for a session
Expand Down
2 changes: 2 additions & 0 deletions server/db/types/connection/MainDatabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { UpdateModalsTable } from "./main/UpdateModalsTable";
import { FlightLogsTable } from "./main/FlightLogsTable";
import { GlobalHolidaySettingsTable } from "./main/GlobalHolidaySettingsTable";
import { FeedbackTable } from "./main/FeedbackTable";
import { ApiLogsTable } from "./main/ApiLogsTable";

export interface MainDatabase {
app_settings: AppSettingsTable;
Expand All @@ -34,4 +35,5 @@ export interface MainDatabase {
flight_logs: FlightLogsTable;
global_holiday_settings: GlobalHolidaySettingsTable;
feedback: FeedbackTable;
api_logs: ApiLogsTable;
}
15 changes: 15 additions & 0 deletions server/db/types/connection/main/ApiLogsTable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export interface ApiLogsTable {
id: number;
user_id: string | null;
username: string | null;
method: string;
path: string;
status_code: number;
response_time: number;
ip_address: string;
user_agent: string | null;
request_body: string | null;
response_body: string | null;
error_message: string | null;
timestamp: Date;
}
Loading
Loading