Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/backend/src/lib/services/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ class Logger {
private logs: LogRecord[] = [];
private logsMutex = new Mutex();

/**
* Reads LOG_PATH and LOG_LEVEL from environment variables.
*/
constructor() {
if ((!process.env.LOG_LEVEL || !process.env.LOG_PATH) && (process.env.LOG_LEVEL !== "default" && process.env.LOG_LEVEL !== "debug")) {
throw new Error(".env is not configured correctly!!!");
Expand All @@ -39,10 +42,17 @@ class Logger {
this.logFilePath = join(this.logPath, LATEST_LOG_NAME);
}

/**
* Returns whether the logger has been initialized.
* @returns The initialization state.
*/
public isInitialized(){
return this.initialized;
}

/**
* Creates the log directory and file. If a previous log file exists, it is archived.
*/
public async init() {
if (this.initialized) throw Error("Logger already initiated!");

Expand All @@ -59,6 +69,10 @@ class Logger {
fs.writeFile(this.logFilePath, "");
}

/**
* Archives the previous log file by renaming it with a timestamp derived from its first line.
* If the file is empty, it is removed instead.
*/
private async renameLatest() {
const fl = await firstline(this.logFilePath);

Expand All @@ -72,10 +86,22 @@ class Logger {
await fs.rename(this.logFilePath, join(this.logPath, newName));
}

/**
* Formats a Dayjs timestamp for use in log output lines.
* @param timestamp - The timestamp to format.
* @returns The formatted time string.
*/
private getFormatedTime(timestamp: Dayjs) {
return timestamp.format(`ddd DD-MM-YYYY HH:mm:ss${this.logLevel === "debug" ? ":SSS" : ""} +01:00 (CET)`);
}

/**
* Constructs a structured LogRecord with a UUID, timestamp, type, and optional metadata.
* @param type - The severity/type of the log entry.
* @param message - The log message.
* @param [opts] - Optional issuer and action metadata.
* @returns The constructed log record.
*/
private async buildRecord(type: LogType, message: string, opts?: LogRecordOpts): Promise<LogRecord> {
const timestamp = getTime();
const uuid = uuidv4();
Expand All @@ -90,6 +116,11 @@ class Logger {
}
}

/**
* Appends a log record to the file, keeps an in-memory buffer of the last 500 records,
* and broadcasts the record to admin SSE clients.
* @param record - The log record to persist and broadcast.
*/
private async writeRecord(record: LogRecord) {
const formatedTime = this.getFormatedTime(dayjs(record.timestamp));
const raw = `[${ record.type[0]?.toUpperCase() }] ${formatedTime} | ${record.message}`;
Expand All @@ -108,29 +139,59 @@ class Logger {
sseManager.broadcast([record], "log");
}

/**
* Internal dispatch: builds a record for the given message and type, then writes it.
* @param message - The log message.
* @param type - The log severity.
* @param [opts] - Optional metadata.
*/
private async log(message: string, type: LogType, opts?: LogRecordOpts) {
const record = await this.buildRecord(type, message, opts);
await this.writeRecord(record);
}

/**
* Logs an info-level message.
* @param message - The message to log.
* @param [opts] - Optional metadata.
*/
public async info(message: string, opts?: LogRecordOpts) {
this.log(message, "infos", opts);
}

/**
* Logs a warning-level message.
* @param message - The message to log.
* @param [opts] - Optional metadata.
*/
public async warn(message: string, opts?: LogRecordOpts) {
this.log(message, "warnings", opts);
}

/**
* Logs an error-level message.
* @param message - The message to log.
* @param [opts] - Optional metadata.
*/
public async error(message: string, opts?: LogRecordOpts) {
this.log(message, "errors", opts);
}

/**
* Logs a debug-level message. Only writes when the logger is in debug mode.
* @param message - The message to log.
* @param [opts] - Optional metadata.
*/
public async debug(message: string, opts?: LogRecordOpts) {
if (this.logLevel !== "debug") return;

this.log(message, "debug", opts);
}

/**
* Returns the in-memory buffer of recent log records (up to 500).
* @returns The log records.
*/
public getLogs(): LogRecord[] {
return this.logs;
}
Expand Down
49 changes: 49 additions & 0 deletions app/backend/src/lib/services/network/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,20 @@ class Network {
private studentUpdates: Map<string, StudentUpdate> = new Map<string, StudentUpdate>();
private heartbeats: Map<string, number> = new Map<string, number>();

/**
* Creates the mutex and starts the periodic heartbeat-check loop.
*/
constructor() {
this.studentsMutex = new Mutex();
this.runner();
}

/**
* Creates and stores a new Student entry with default values.
* @param ip - The student's IP address.
* @param time - The timestamp of first connection.
* @returns The newly created student.
*/
private createStudent(ip: string, time: number) {
const newStudent: Student = { ip, name: "", connected: true, hasInternet: false, sent: false, finished: false, since: time, attempts: 0, hidden: false, latestVersion: { hash: "", path: "" } };

Expand All @@ -26,6 +35,10 @@ class Network {
return newStudent
}

/**
* Creates a student entry for the given IP only if one does not already exist.
* @param ip - The student's IP address.
*/
private createStudentIfNotExists(ip: string) {
if (this.students.has(ip)) return;

Expand All @@ -34,12 +47,20 @@ class Network {
this.createStudent(ip, now);
}

/**
* Schedules the periodic heartbeat-check loop at CHECK_INTERVAL (2s).
*/
private runner() {
this.callback().finally(() => {
setTimeout(this.runner.bind(this), CHECK_INTERVAL);
});
}

/**
* Iterates all known IPs, checks heartbeat freshness, updates connection state,
* and broadcasts pending student updates via SSE.
* Connection-loss events are debounced up to 3 attempts before triggering.
*/
private async callback() {
const unlock = await this.studentsMutex.lock();
try {
Expand Down Expand Up @@ -83,10 +104,19 @@ class Network {
}
}

/**
* Records a heartbeat timestamp for the given IP, used to determine connection state.
* @param ip - The student's IP address.
*/
public async recordHeartbeat(ip: string) {
this.heartbeats.set(ip, unixTime());
}

/**
* Queues a partial student update for batch broadcast and applies it to the in-memory student record.
* @param ip - The student's IP address.
* @param update - The partial update to apply.
*/
private update(ip: string, update: StudentUpdate) {
if (!this.studentUpdates.has(ip)) {
this.studentUpdates.set(ip, update);
Expand All @@ -98,6 +128,11 @@ class Network {
this.students.set(ip, { ...this.students.get(ip)!, ...this.studentUpdates.get(ip) });
}

/**
* Returns the student for a given IP address, creating one if absent.
* @param ip - The student's IP address.
* @returns The student record.
*/
public async getStudent(ip: string): Promise<Student> {
const unlock = await this.studentsMutex.lock();
try {
Expand All @@ -109,6 +144,11 @@ class Network {
}
}

/**
* Finds a student by their registered name. Logs an error if duplicates exist (returns the last match).
* @param name - The student's full name.
* @returns The matching student, or undefined.
*/
public async getStudentByName(name: string) {
const unlock = await this.studentsMutex.lock();

Expand All @@ -130,6 +170,10 @@ class Network {
return undefined;
}

/**
* Returns all tracked students.
* @returns Array of all students.
*/
public async getStudents(): Promise<Student[]> {
const unlock = await this.studentsMutex.lock();

Expand All @@ -140,6 +184,11 @@ class Network {
return students;
}

/**
* Atomically applies a partial update to a student. Creates the student first if it does not exist.
* @param ip - The student's IP address.
* @param update - The partial update to apply.
*/
public async addUpdate(ip: string, update: StudentUpdate) {
const unlock = await this.studentsMutex.lock();
try {
Expand Down
32 changes: 32 additions & 0 deletions app/backend/src/lib/services/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ class SSEManager {
this.encoder = new TextEncoder();
}

/**
* Writes an SSE event frame to a client. Removes the client on write failure.
* @param client - The target SSE client.
* @param event - The event name.
* @param data - The data payload to send.
*/
private safeWrite(client: Client, event: SSEEvent, data: any) {
try {
client.res.write(`event: ${event}\ndata: ${JSON.stringify({ message: data })}\n\n`);
Expand All @@ -32,6 +38,14 @@ class SSEManager {
}
}

/**
* Registers a new SSE client and starts a heartbeat interval (10s) to keep the connection alive.
* On first connect, sends the appropriate initialisation data (log history for admins,
* connection signal for students).
* @param ip - The client IP address.
* @param res - The Express response object.
* @param admin - Whether the client is an admin.
*/
public async addClient(ip: string, res: Response, admin: boolean) {
const client: Client = { ip, res, admin };

Expand Down Expand Up @@ -65,6 +79,11 @@ class SSEManager {
}
}

/**
* Deregisters an SSE client, clears its heartbeat interval, and ends the response.
* @param ip - The client IP address.
* @param admin - Whether the client is an admin.
*/
public removeClient(ip: string, admin: boolean) {
const client = admin ? this.admins.get(ip) : this.students.get(ip);
if (client) {
Expand All @@ -81,13 +100,26 @@ class SSEManager {
}
}

/**
* Sends an event to all connected clients of a given type (admin or student).
* @param message - The data to send.
* @param event - The event name.
* @param [admin=true] - Broadcast to admin clients (true) or student clients (false).
*/
public broadcast(message: object, event: SSEEvent, admin: boolean = true) {
const clients = admin ? this.admins : this.students;
for (const client of clients.values()) {
this.safeWrite(client, event, message);
}
}

/**
* Sends an event to a single client identified by IP address.
* @param ip - The client IP address.
* @param message - The data to send.
* @param event - The event name.
* @param admin - Whether the target is an admin client.
*/
public send(ip: string, message: object, event: SSEEvent, admin: boolean) {
const client = admin ? this.admins.get(ip) : this.students.get(ip);

Expand Down
Loading
Loading