diff --git a/Sources/App/Extensions/Sequence+Extensions.swift b/Sources/App/Extensions/Sequence+Extensions.swift new file mode 100644 index 0000000..7ecd58e --- /dev/null +++ b/Sources/App/Extensions/Sequence+Extensions.swift @@ -0,0 +1,12 @@ +import Foundation + +extension Sequence { + + func count(where isIncluded: (Element) -> Bool) -> Int { + reduce(0) { isIncluded($1) ? $0 + 1 : $0 } + } + + func sum(converting: (Element) -> Int) -> Int { + reduce(0) { $0 + converting($1) } + } +} diff --git a/Sources/App/Management/SQLiteDatabase.swift b/Sources/App/Management/SQLiteDatabase.swift index c8dfa18..c50f0bd 100644 --- a/Sources/App/Management/SQLiteDatabase.swift +++ b/Sources/App/Management/SQLiteDatabase.swift @@ -2,19 +2,41 @@ import Foundation import Fluent import Vapor import SwiftSMTP +import Clairvoyant typealias PasswordHash = String typealias SessionToken = String private struct MailConfig { - let mailConfiguration: Configuration.EMail + /// The url to the root of the server + let serverDomain: String + + /// The number of minutes until a password reset token is no longer valid + let tokenExpiryDuration: Int let smtp: SMTP let mailSender: Mail.User } +private extension Configuration.EMail { + + var mailConfig: MailConfig { + let smtp = SMTP( + hostname: emailHostname, + email: email, + password: password) + let mailSender = Mail.User(name: "Schafkopf Server", email: email) + + return .init( + serverDomain: serverDomain, + tokenExpiryDuration: tokenExpiryDuration, + smtp: smtp, + mailSender: mailSender) + } +} + final class SQLiteDatabase { /// A mapping between player name and generated access tokens for a session @@ -27,19 +49,16 @@ final class SQLiteDatabase { private let mail: MailConfig? - init(db: Database, mail: Configuration.EMail?) { - self.tables = TableManagement(db: db) - guard let mail else { - self.mail = nil - return - } - let smtp = SMTP( - hostname: mail.emailHostname, - email: mail.email, - password: mail.password) - let mailSender = Mail.User(name: "Schafkopf Server", email: mail.email) + private let registeredPlayerCountMetric: Metric - self.mail = .init(mailConfiguration: mail, smtp: smtp, mailSender: mailSender) + init(database: Database, mail: Configuration.EMail?) { + self.registeredPlayerCountMetric = Metric("schafkopf.players") + self.tables = TableManagement(database: database) + self.mail = mail?.mailConfig + + Task { + await updateRegisteredPlayerCount(from: database) + } } func registerPlayer(named name: PlayerName, hash: PasswordHash, email: String?, in database: Database) async throws -> SessionToken { @@ -62,9 +81,20 @@ final class SQLiteDatabase { let token = SessionToken.newToken() self.sessionTokenForPlayer[name] = token self.playerNameForToken[token] = name + + await updateRegisteredPlayerCount(from: database) return token } + func updateRegisteredPlayerCount(from database: Database) async { + do { + let count = try await User.query(on: database).count() + registeredPlayerCountMetric.update(count) + } catch { + print("Failed to update player count metric: \(error)") + } + } + /** Send a password reset email. @@ -96,8 +126,8 @@ final class SQLiteDatabase { return } let recipient = Mail.User(name: name, email: email) - let mailConfiguration = mailData.mailConfiguration - let url = "\(mailConfiguration.serverDomain)/recovery.html?token=\(token)" + //let mailConfiguration = mailData.mailConfiguration + let url = "\(mailData.serverDomain)/recovery.html?token=\(token)" let mail = Mail( from: mailData.mailSender, to: [recipient], @@ -106,12 +136,12 @@ final class SQLiteDatabase { """ Hello \(name), - a reset of your account password has been requested for the Schafkopf Server at \(mailConfiguration.serverDomain). + a reset of your account password has been requested for the Schafkopf Server at \(mailData.serverDomain). To choose a new password, click the following link: \(url) - The link will expire in \(mailConfiguration.tokenExpiryDuration) minutes. If you didn't request the password reset, you don't have to do anything. + The link will expire in \(mailData.tokenExpiryDuration) minutes. If you didn't request the password reset, you don't have to do anything. Regards, @@ -169,6 +199,7 @@ final class SQLiteDatabase { let user = try await user(named: name, in: database) try await tables.leaveTable(player: user, in: database) try await User.query(on: database).filter(\.$name == name).delete() + await updateRegisteredPlayerCount(from: database) } func isValid(sessionToken token: SessionToken) -> Bool { diff --git a/Sources/App/Management/TableManagement.swift b/Sources/App/Management/TableManagement.swift index 0578ad3..3943c88 100644 --- a/Sources/App/Management/TableManagement.swift +++ b/Sources/App/Management/TableManagement.swift @@ -2,6 +2,7 @@ import Foundation import WebSocketKit import Vapor import Fluent +import Clairvoyant let maximumPlayersPerTable = 4 @@ -12,25 +13,63 @@ final class TableManagement { /// All tables indexed by their id private var tables = [UUID : ManageableTable]() + + /// The metric to log the current number of tables + private var tableCountMetric: Metric + + /// The metric describing the number of players currently sitting at a table + private let playingPlayerCountMetric: Metric + + /// The metric describing the number of players currently connected via a websocket + private let connectedPlayerCountMetric: Metric + + /** Load the tables from a file in the storage folder - Throws: Errors when the file could not be read */ - init(db: Database) { - Table.query(on: db).with(\.$players).all().whenSuccess { loadedTables in - for table in loadedTables { - guard !table.players.isEmpty else { - _ = table.delete(on: db) - continue - } - let id = table.id! - self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players) + init(database: Database) { + self.tableCountMetric = .init("schafkopf.tables") + self.playingPlayerCountMetric = .init("schafkopf.playing") + self.connectedPlayerCountMetric = .init("schafkopf.connected") + + Task { + do { + try await loadTables(from: database) + } catch { + print("Failed to load tables: \(error)") } - print("\(self.tables.count) tables loaded") } } + private func loadTables(from database: Database) async throws { + try await Table.query(on: database).with(\.$players).all().forEach { table in + guard !table.players.isEmpty else { + _ = table.delete(on: database) + return + } + let id = table.id! + self.tables[id] = WaitingTable(id: id, name: table.name, isPublic: table.isPublic, players: table.players) + } + print("\(tables.count) tables loaded") + logTableCount() + logPlayingPlayerCount() + logConnectedPlayerCount() + } + + private func logTableCount() { + tableCountMetric.update(tables.count) + } + + private func logPlayingPlayerCount() { + let count = tables.values.sum { $0.playerCount } + } + + private func logConnectedPlayerCount() { + let count = tables.values.sum { $0.numberOfConnectedPlayers } + } + /** Create a new table with optional players. - Parameter name: The name of the table @@ -45,6 +84,8 @@ final class TableManagement { try await player.update(on: database) let waitingTable = WaitingTable(newTable: table, user: player) self.tables[waitingTable.id] = waitingTable + logTableCount() + logPlayingPlayerCount() return waitingTable.tableInfo(forPlayer: player.name) } @@ -77,6 +118,7 @@ final class TableManagement { player.$table.id = table.id try await player.update(on: database) table.sendUpdateToAllPlayers() + logPlayingPlayerCount() return table.tableInfo(forPlayer: player.name) } @@ -108,6 +150,7 @@ final class TableManagement { player.$table.id = nil guard let table = WaitingTable(oldTable: oldTable, removing: player.name) else { tables[oldTable.id] = nil + logTableCount() try await player.update(on: database) try await Table.query(on: database).filter(\.$id == oldTable.id).delete() return @@ -116,6 +159,8 @@ final class TableManagement { tables[table.id] = table #warning("Update points for all players, add penalty if running game") table.sendUpdateToAllPlayers() + logPlayingPlayerCount() + logConnectedPlayerCount() try await player.update(on: database) } @@ -123,6 +168,7 @@ final class TableManagement { guard let table = currentTable(for: player) else { return false } + defer { logConnectedPlayerCount() } return table.connect(player: player, using: socket) } @@ -130,6 +176,7 @@ final class TableManagement { guard let table = currentTable(for: player) else { return } + defer { logConnectedPlayerCount() } table.disconnect(player: player) } @@ -199,5 +246,6 @@ final class TableManagement { func disconnectAllSockets() { tables.values.forEach { $0.disconnectAllPlayers() } + logConnectedPlayerCount() } } diff --git a/Sources/App/Model/Tables/ManageableTable.swift b/Sources/App/Model/Tables/ManageableTable.swift index a2950dc..321da81 100644 --- a/Sources/App/Model/Tables/ManageableTable.swift +++ b/Sources/App/Model/Tables/ManageableTable.swift @@ -34,3 +34,14 @@ protocol ManageableTable { func disconnectAllPlayers() } + +extension ManageableTable { + + var numberOfConnectedPlayers: Int { + allPlayers.count { $0.isConnected } + } + + var playerCount: Int { + allPlayers.count + } +} diff --git a/Sources/App/configure.swift b/Sources/App/configure.swift index a753b7f..85edaab 100644 --- a/Sources/App/configure.swift +++ b/Sources/App/configure.swift @@ -22,7 +22,17 @@ public func configure(_ app: Application) throws { let configPath = URL(fileURLWithPath: app.directory.resourcesDirectory) .appendingPathComponent("config.json") - let configuration = try Configuration(loadFromUrl: configPath) + let configuration: Configuration + + do { + configuration = try Configuration(loadFromUrl: configPath) + } catch { + status.update(.initializationFailure) + monitor.log("Failed to read configuration: \(error)") + // Note: If configuration can't be loaded, then the server will run on the wrong port + // and access to metrics is impossible, since no tokens are loaded + return + } configuration.monitoringTokens.map { $0.data(using: .utf8)! }.forEach(accessManager.add) app.http.server.configuration.port = configuration.serverPort @@ -55,7 +65,7 @@ public func configure(_ app: Application) throws { app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory)) let db = app.databases.database(.sqlite, logger: .init(label: "Init"), on: app.databases.eventLoopGroup.next())! - server = SQLiteDatabase(db: db, mail: configuration.mail) + server = SQLiteDatabase(database: db, mail: configuration.mail) // Gracefully shut down by closing potentially open socket DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + .seconds(5)) {