| @@ -0,0 +1,25 @@ | |||||
| package com.ffii.fpsms.modules.monitoring.scheduler | |||||
| import com.ffii.fpsms.modules.monitoring.service.MonitoringRetentionService | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty | |||||
| import org.springframework.scheduling.annotation.Scheduled | |||||
| import org.springframework.stereotype.Component | |||||
| @Component | |||||
| @ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true") | |||||
| class MonitoringRetentionScheduler( | |||||
| private val monitoringRetentionService: MonitoringRetentionService, | |||||
| ) { | |||||
| private val log = LoggerFactory.getLogger(MonitoringRetentionScheduler::class.java) | |||||
| /** Purge device/printer connectivity events older than configured retention (default 30 days). */ | |||||
| @Scheduled(cron = "\${fpsms.monitoring.retention-purge-cron:0 0 3 * * *}") | |||||
| fun purgeOldEvents() { | |||||
| try { | |||||
| monitoringRetentionService.purgeOldEvents() | |||||
| } catch (e: Exception) { | |||||
| log.error("Monitoring event retention purge failed", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -21,8 +21,8 @@ open class ClientPresenceService( | |||||
| @Value("\${fpsms.client-presence.history-sample-sec:60}") | @Value("\${fpsms.client-presence.history-sample-sec:60}") | ||||
| private var historySampleSec: Long = 60 | private var historySampleSec: Long = 60 | ||||
| @Value("\${fpsms.client-presence.history-max-range-days:31}") | |||||
| private var historyMaxRangeDays: Long = 31 | |||||
| @Value("\${fpsms.client-presence.history-max-range-days:30}") | |||||
| private var historyMaxRangeDays: Long = 30 | |||||
| data class HeartbeatRequest( | data class HeartbeatRequest( | ||||
| val deviceId: String, | val deviceId: String, | ||||
| @@ -32,6 +32,8 @@ open class ClientPresenceService( | |||||
| val connectionQuality: String? = null, | val connectionQuality: String? = null, | ||||
| val navigatorOnline: Boolean? = true, | val navigatorOnline: Boolean? = true, | ||||
| val userAgent: String? = null, | val userAgent: String? = null, | ||||
| val clientType: String? = null, | |||||
| val clientIp: String? = null, | |||||
| val activityBump: Boolean = true, | val activityBump: Boolean = true, | ||||
| ) | ) | ||||
| @@ -41,9 +43,10 @@ open class ClientPresenceService( | |||||
| require(deviceId.isNotEmpty()) { "deviceId is required" } | require(deviceId.isNotEmpty()) { "deviceId is required" } | ||||
| val quality = normalizeQuality(req.connectionQuality) | val quality = normalizeQuality(req.connectionQuality) | ||||
| val clientType = detectClientType(req.userAgent) | |||||
| val clientType = resolveClientType(req.userAgent, req.clientType) | |||||
| val path = req.currentPath?.trim()?.take(512) | val path = req.currentPath?.trim()?.take(512) | ||||
| val displayName = req.displayName?.trim()?.take(128)?.ifBlank { null } | val displayName = req.displayName?.trim()?.take(128)?.ifBlank { null } | ||||
| val clientIp = req.clientIp?.trim()?.take(45)?.ifBlank { null } | |||||
| val now = LocalDateTime.now() | val now = LocalDateTime.now() | ||||
| val existing = jdbcDao.queryForList( | val existing = jdbcDao.queryForList( | ||||
| @@ -72,11 +75,11 @@ open class ClientPresenceService( | |||||
| val insertSql = """ | val insertSql = """ | ||||
| INSERT INTO client_presence ( | INSERT INTO client_presence ( | ||||
| device_id, user_id, username, display_name, current_path, client_type, | device_id, user_id, username, display_name, current_path, client_type, | ||||
| user_agent, rtt_ms, connection_quality, navigator_online, | |||||
| user_agent, client_ip, rtt_ms, connection_quality, navigator_online, | |||||
| last_heartbeat, last_activity | last_heartbeat, last_activity | ||||
| ) VALUES ( | ) VALUES ( | ||||
| :deviceId, :userId, :username, :displayName, :currentPath, :clientType, | :deviceId, :userId, :username, :displayName, :currentPath, :clientType, | ||||
| :userAgent, :rttMs, :connectionQuality, :navigatorOnline, | |||||
| :userAgent, :clientIp, :rttMs, :connectionQuality, :navigatorOnline, | |||||
| :now, :lastActivity | :now, :lastActivity | ||||
| ) | ) | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -90,6 +93,7 @@ open class ClientPresenceService( | |||||
| "currentPath" to path, | "currentPath" to path, | ||||
| "clientType" to clientType, | "clientType" to clientType, | ||||
| "userAgent" to req.userAgent?.take(512), | "userAgent" to req.userAgent?.take(512), | ||||
| "clientIp" to clientIp, | |||||
| "rttMs" to req.rttMs, | "rttMs" to req.rttMs, | ||||
| "connectionQuality" to quality, | "connectionQuality" to quality, | ||||
| "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | ||||
| @@ -108,6 +112,7 @@ open class ClientPresenceService( | |||||
| current_path = :currentPath, | current_path = :currentPath, | ||||
| client_type = :clientType, | client_type = :clientType, | ||||
| user_agent = :userAgent, | user_agent = :userAgent, | ||||
| client_ip = :clientIp, | |||||
| rtt_ms = :rttMs, | rtt_ms = :rttMs, | ||||
| connection_quality = :connectionQuality, | connection_quality = :connectionQuality, | ||||
| navigator_online = :navigatorOnline, | navigator_online = :navigatorOnline, | ||||
| @@ -124,6 +129,7 @@ open class ClientPresenceService( | |||||
| current_path = :currentPath, | current_path = :currentPath, | ||||
| client_type = :clientType, | client_type = :clientType, | ||||
| user_agent = :userAgent, | user_agent = :userAgent, | ||||
| client_ip = :clientIp, | |||||
| rtt_ms = :rttMs, | rtt_ms = :rttMs, | ||||
| connection_quality = :connectionQuality, | connection_quality = :connectionQuality, | ||||
| navigator_online = :navigatorOnline, | navigator_online = :navigatorOnline, | ||||
| @@ -140,6 +146,7 @@ open class ClientPresenceService( | |||||
| "currentPath" to path, | "currentPath" to path, | ||||
| "clientType" to clientType, | "clientType" to clientType, | ||||
| "userAgent" to req.userAgent?.take(512), | "userAgent" to req.userAgent?.take(512), | ||||
| "clientIp" to clientIp, | |||||
| "rttMs" to req.rttMs, | "rttMs" to req.rttMs, | ||||
| "connectionQuality" to quality, | "connectionQuality" to quality, | ||||
| "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | ||||
| @@ -158,6 +165,7 @@ open class ClientPresenceService( | |||||
| "displayName" to displayName, | "displayName" to displayName, | ||||
| "currentPath" to path, | "currentPath" to path, | ||||
| "clientType" to clientType, | "clientType" to clientType, | ||||
| "clientIp" to clientIp, | |||||
| "rttMs" to req.rttMs, | "rttMs" to req.rttMs, | ||||
| "connectionQuality" to quality, | "connectionQuality" to quality, | ||||
| "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | ||||
| @@ -181,6 +189,7 @@ open class ClientPresenceService( | |||||
| display_name AS displayName, | display_name AS displayName, | ||||
| current_path AS currentPath, | current_path AS currentPath, | ||||
| client_type AS clientType, | client_type AS clientType, | ||||
| client_ip AS clientIp, | |||||
| rtt_ms AS rttMs, | rtt_ms AS rttMs, | ||||
| connection_quality AS connectionQuality, | connection_quality AS connectionQuality, | ||||
| navigator_online AS navigatorOnline, | navigator_online AS navigatorOnline, | ||||
| @@ -210,6 +219,7 @@ open class ClientPresenceService( | |||||
| current_path AS currentPath, | current_path AS currentPath, | ||||
| client_type AS clientType, | client_type AS clientType, | ||||
| user_agent AS userAgent, | user_agent AS userAgent, | ||||
| client_ip AS clientIp, | |||||
| rtt_ms AS rttMs, | rtt_ms AS rttMs, | ||||
| connection_quality AS connectionQuality, | connection_quality AS connectionQuality, | ||||
| navigator_online AS navigatorOnline, | navigator_online AS navigatorOnline, | ||||
| @@ -264,6 +274,7 @@ open class ClientPresenceService( | |||||
| rtt_ms AS rttMs, | rtt_ms AS rttMs, | ||||
| navigator_online AS navigatorOnline, | navigator_online AS navigatorOnline, | ||||
| current_path AS currentPath, | current_path AS currentPath, | ||||
| client_ip AS clientIp, | |||||
| recorded_at AS recordedAt | recorded_at AS recordedAt | ||||
| FROM client_presence_event | FROM client_presence_event | ||||
| WHERE recorded_at >= :from AND recorded_at <= :to | WHERE recorded_at >= :from AND recorded_at <= :to | ||||
| @@ -347,10 +358,10 @@ open class ClientPresenceService( | |||||
| """ | """ | ||||
| INSERT INTO client_presence_event ( | INSERT INTO client_presence_event ( | ||||
| device_id, user_id, username, display_name, client_type, | device_id, user_id, username, display_name, client_type, | ||||
| status, connection_quality, rtt_ms, navigator_online, current_path, recorded_at | |||||
| status, connection_quality, rtt_ms, navigator_online, current_path, client_ip, recorded_at | |||||
| ) VALUES ( | ) VALUES ( | ||||
| :deviceId, :userId, :username, :displayName, :clientType, | :deviceId, :userId, :username, :displayName, :clientType, | ||||
| :status, :connectionQuality, :rttMs, :navigatorOnline, :currentPath, :recordedAt | |||||
| :status, :connectionQuality, :rttMs, :navigatorOnline, :currentPath, :clientIp, :recordedAt | |||||
| ) | ) | ||||
| """.trimIndent(), | """.trimIndent(), | ||||
| mapOf( | mapOf( | ||||
| @@ -367,6 +378,7 @@ open class ClientPresenceService( | |||||
| else -> 1 | else -> 1 | ||||
| }, | }, | ||||
| "currentPath" to row["currentPath"], | "currentPath" to row["currentPath"], | ||||
| "clientIp" to row["clientIp"], | |||||
| "recordedAt" to now, | "recordedAt" to now, | ||||
| ), | ), | ||||
| ) | ) | ||||
| @@ -441,15 +453,23 @@ open class ClientPresenceService( | |||||
| } | } | ||||
| } | } | ||||
| fun resolveClientType(userAgent: String?, clientReported: String?): String { | |||||
| val reported = clientReported?.trim()?.lowercase()?.takeIf { | |||||
| it in setOf("tablet", "desktop", "mobile", "unknown") | |||||
| } | |||||
| if (reported != null) return reported | |||||
| return detectClientType(userAgent) | |||||
| } | |||||
| fun detectClientType(userAgent: String?): String { | fun detectClientType(userAgent: String?): String { | ||||
| if (userAgent.isNullOrBlank()) return "unknown" | if (userAgent.isNullOrBlank()) return "unknown" | ||||
| val ua = userAgent.lowercase() | val ua = userAgent.lowercase() | ||||
| return when { | return when { | ||||
| ua.contains("ipad") || ua.contains("tablet") -> "tablet" | ua.contains("ipad") || ua.contains("tablet") -> "tablet" | ||||
| ua.contains("android") && !ua.contains("mobile") -> "tablet" | ua.contains("android") && !ua.contains("mobile") -> "tablet" | ||||
| ua.contains("iphone") || ua.contains("ipod") || | |||||
| (ua.contains("android") && ua.contains("mobile")) || | |||||
| ua.contains("mobile") -> "mobile" | |||||
| ua.contains("iphone") || ua.contains("ipod") -> "mobile" | |||||
| ua.contains("android") && ua.contains("mobile") -> "mobile" | |||||
| ua.contains("mobile") -> "mobile" | |||||
| else -> "desktop" | else -> "desktop" | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,55 @@ | |||||
| package com.ffii.fpsms.modules.monitoring.service | |||||
| import com.ffii.core.support.JdbcDao | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.beans.factory.annotation.Value | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.time.LocalDateTime | |||||
| @Service | |||||
| open class MonitoringRetentionService( | |||||
| private val jdbcDao: JdbcDao, | |||||
| ) { | |||||
| private val log = LoggerFactory.getLogger(MonitoringRetentionService::class.java) | |||||
| @Value("\${fpsms.monitoring.event-retention-days:30}") | |||||
| private var retentionDays: Long = 30 | |||||
| @Transactional | |||||
| open fun purgeOldEvents(): Map<String, Int> { | |||||
| val days = retentionDays.coerceAtLeast(1) | |||||
| val cutoff = LocalDateTime.now().minusDays(days) | |||||
| val deviceDeleted = jdbcDao.executeUpdate( | |||||
| """ | |||||
| DELETE FROM client_presence_event | |||||
| WHERE recorded_at < :cutoff | |||||
| """.trimIndent(), | |||||
| mapOf("cutoff" to cutoff), | |||||
| ) | |||||
| val printerDeleted = jdbcDao.executeUpdate( | |||||
| """ | |||||
| DELETE FROM printer_connectivity_event | |||||
| WHERE recorded_at < :cutoff | |||||
| """.trimIndent(), | |||||
| mapOf("cutoff" to cutoff), | |||||
| ) | |||||
| if (deviceDeleted > 0 || printerDeleted > 0) { | |||||
| log.info( | |||||
| "Monitoring retention: deleted {} device event(s) and {} printer event(s) older than {} day(s) (before {})", | |||||
| deviceDeleted, | |||||
| printerDeleted, | |||||
| days, | |||||
| cutoff, | |||||
| ) | |||||
| } | |||||
| return mapOf( | |||||
| "deviceEventsDeleted" to deviceDeleted, | |||||
| "printerEventsDeleted" to printerDeleted, | |||||
| "retentionDays" to days.toInt(), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -25,8 +25,8 @@ open class PrinterMonitorService( | |||||
| @Value("\${fpsms.printer-monitor.offline-event-sample-sec:300}") | @Value("\${fpsms.printer-monitor.offline-event-sample-sec:300}") | ||||
| private var offlineEventSampleSec: Long = 300 | private var offlineEventSampleSec: Long = 300 | ||||
| @Value("\${fpsms.printer-monitor.history-max-range-days:31}") | |||||
| private var historyMaxRangeDays: Long = 31 | |||||
| @Value("\${fpsms.printer-monitor.history-max-range-days:30}") | |||||
| private var historyMaxRangeDays: Long = 30 | |||||
| data class ProbeResult( | data class ProbeResult( | ||||
| val status: String, | val status: String, | ||||
| @@ -0,0 +1,46 @@ | |||||
| package com.ffii.fpsms.modules.monitoring.web | |||||
| import jakarta.servlet.http.HttpServletRequest | |||||
| object ClientIpResolver { | |||||
| /** | |||||
| * Best-effort client IP from proxy headers, then TCP remote address. | |||||
| * Skips loopback when a routable address is present; normalizes ::1 for display. | |||||
| */ | |||||
| fun resolve(request: HttpServletRequest): String? { | |||||
| val candidates = linkedSetOf<String>() | |||||
| fun addHeader(name: String) { | |||||
| val raw = request.getHeader(name)?.trim() ?: return | |||||
| raw.split(",").forEach { part -> | |||||
| val ip = part.trim().take(45) | |||||
| if (ip.isNotBlank()) candidates.add(ip) | |||||
| } | |||||
| } | |||||
| addHeader("X-Forwarded-For") | |||||
| addHeader("X-Real-IP") | |||||
| addHeader("CF-Connecting-IP") | |||||
| request.remoteAddr?.trim()?.takeIf { it.isNotBlank() }?.let { candidates.add(it.take(45)) } | |||||
| if (candidates.isEmpty()) return null | |||||
| val routable = candidates.firstOrNull { !isLoopback(it) } | |||||
| if (routable != null) return routable | |||||
| return normalizeLoopbackDisplay(candidates.first()) | |||||
| } | |||||
| private fun isLoopback(ip: String): Boolean { | |||||
| val n = ip.trim().lowercase() | |||||
| if (n == "localhost" || n.startsWith("127.")) return true | |||||
| if (n == "::1" || n == "0:0:0:0:0:0:0:1") return true | |||||
| // Any all-zero IPv6 address (loopback / unspecified) | |||||
| if (n.contains(":") && n.replace(":", "").replace("0", "").isEmpty()) return true | |||||
| return false | |||||
| } | |||||
| /** Loopback = browser on same machine as API (local dev or server console). */ | |||||
| private fun normalizeLoopbackDisplay(ip: String): String = | |||||
| if (isLoopback(ip)) "Server" else ip | |||||
| } | |||||
| @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.RequestBody | |||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import jakarta.servlet.http.HttpServletRequest | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.format.DateTimeParseException | import java.time.format.DateTimeParseException | ||||
| @@ -30,6 +31,7 @@ class ClientPresenceController( | |||||
| val connectionQuality: String? = null, | val connectionQuality: String? = null, | ||||
| val navigatorOnline: Boolean? = true, | val navigatorOnline: Boolean? = true, | ||||
| val userAgent: String? = null, | val userAgent: String? = null, | ||||
| val clientType: String? = null, | |||||
| val activityBump: Boolean? = true, | val activityBump: Boolean? = true, | ||||
| ) | ) | ||||
| @@ -39,7 +41,7 @@ class ClientPresenceController( | |||||
| } | } | ||||
| @PostMapping("/heartbeat") | @PostMapping("/heartbeat") | ||||
| fun heartbeat(@RequestBody body: HeartbeatBody): ResponseEntity<Map<String, Any>> { | |||||
| fun heartbeat(@RequestBody body: HeartbeatBody, request: HttpServletRequest): ResponseEntity<Map<String, Any>> { | |||||
| val user = SecurityUtils.getUser().orElseThrow { BadRequestException("Not authenticated") } | val user = SecurityUtils.getUser().orElseThrow { BadRequestException("Not authenticated") } | ||||
| val deviceId = body.deviceId?.trim().orEmpty() | val deviceId = body.deviceId?.trim().orEmpty() | ||||
| if (deviceId.isEmpty()) { | if (deviceId.isEmpty()) { | ||||
| @@ -60,6 +62,8 @@ class ClientPresenceController( | |||||
| connectionQuality = body.connectionQuality, | connectionQuality = body.connectionQuality, | ||||
| navigatorOnline = body.navigatorOnline, | navigatorOnline = body.navigatorOnline, | ||||
| userAgent = ua, | userAgent = ua, | ||||
| clientType = body.clientType, | |||||
| clientIp = ClientIpResolver.resolve(request), | |||||
| activityBump = body.activityBump != false, | activityBump = body.activityBump != false, | ||||
| ), | ), | ||||
| ) | ) | ||||
| @@ -2,4 +2,9 @@ spring: | |||||
| datasource: | datasource: | ||||
| jdbc-url: jdbc:mysql://127.0.0.1:3306/fpsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 | jdbc-url: jdbc:mysql://127.0.0.1:3306/fpsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 | ||||
| username: root | username: root | ||||
| password: secret | |||||
| password: secret | |||||
| # Monitoring off for local dev (prod only). To test: enabled true + frontend NEXT_PUBLIC_MONITORING_ENABLED=true. | |||||
| fpsms: | |||||
| monitoring: | |||||
| enabled: false | |||||
| @@ -39,18 +39,21 @@ fpsms: | |||||
| # Device + printer monitoring: enable only on production profile (application-prod*.yml). | # Device + printer monitoring: enable only on production profile (application-prod*.yml). | ||||
| monitoring: | monitoring: | ||||
| enabled: false | enabled: false | ||||
| # Delete device/printer connectivity events older than this (daily job). | |||||
| event-retention-days: 30 | |||||
| retention-purge-cron: "0 0 3 * * *" | |||||
| client-presence: | client-presence: | ||||
| offline-threshold-sec: 90 | offline-threshold-sec: 90 | ||||
| idle-threshold-sec: 300 | idle-threshold-sec: 300 | ||||
| history-sample-sec: 60 | history-sample-sec: 60 | ||||
| history-max-range-days: 31 | |||||
| history-max-range-days: 30 | |||||
| offline-scan-interval-ms: 60000 | offline-scan-interval-ms: 60000 | ||||
| printer-monitor: | printer-monitor: | ||||
| scan-interval-ms: 120000 | scan-interval-ms: 120000 | ||||
| connect-timeout-ms: 3000 | connect-timeout-ms: 3000 | ||||
| default-port: 9100 | default-port: 9100 | ||||
| offline-event-sample-sec: 300 | offline-event-sample-sec: 300 | ||||
| history-max-range-days: 31 | |||||
| history-max-range-days: 30 | |||||
| spring: | spring: | ||||
| servlet: | servlet: | ||||
| @@ -0,0 +1,14 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fpsms:client_presence_client_ip | |||||
| --preconditions onFail:MARK_RAN | |||||
| --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'client_presence' AND column_name = 'client_ip' | |||||
| --comment: Last seen client IP from HTTP request (not MAC; browsers cannot expose MAC) | |||||
| ALTER TABLE `client_presence` | |||||
| ADD COLUMN `client_ip` varchar(45) DEFAULT NULL; | |||||
| --changeset fpsms:client_presence_event_client_ip | |||||
| --preconditions onFail:MARK_RAN | |||||
| --precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'client_presence_event' AND column_name = 'client_ip' | |||||
| ALTER TABLE `client_presence_event` | |||||
| ADD COLUMN `client_ip` varchar(45) DEFAULT NULL; | |||||