| @@ -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}") | |||
| 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( | |||
| val deviceId: String, | |||
| @@ -32,6 +32,8 @@ open class ClientPresenceService( | |||
| val connectionQuality: String? = null, | |||
| val navigatorOnline: Boolean? = true, | |||
| val userAgent: String? = null, | |||
| val clientType: String? = null, | |||
| val clientIp: String? = null, | |||
| val activityBump: Boolean = true, | |||
| ) | |||
| @@ -41,9 +43,10 @@ open class ClientPresenceService( | |||
| require(deviceId.isNotEmpty()) { "deviceId is required" } | |||
| 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 displayName = req.displayName?.trim()?.take(128)?.ifBlank { null } | |||
| val clientIp = req.clientIp?.trim()?.take(45)?.ifBlank { null } | |||
| val now = LocalDateTime.now() | |||
| val existing = jdbcDao.queryForList( | |||
| @@ -72,11 +75,11 @@ open class ClientPresenceService( | |||
| val insertSql = """ | |||
| INSERT INTO client_presence ( | |||
| 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 | |||
| ) VALUES ( | |||
| :deviceId, :userId, :username, :displayName, :currentPath, :clientType, | |||
| :userAgent, :rttMs, :connectionQuality, :navigatorOnline, | |||
| :userAgent, :clientIp, :rttMs, :connectionQuality, :navigatorOnline, | |||
| :now, :lastActivity | |||
| ) | |||
| """.trimIndent() | |||
| @@ -90,6 +93,7 @@ open class ClientPresenceService( | |||
| "currentPath" to path, | |||
| "clientType" to clientType, | |||
| "userAgent" to req.userAgent?.take(512), | |||
| "clientIp" to clientIp, | |||
| "rttMs" to req.rttMs, | |||
| "connectionQuality" to quality, | |||
| "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | |||
| @@ -108,6 +112,7 @@ open class ClientPresenceService( | |||
| current_path = :currentPath, | |||
| client_type = :clientType, | |||
| user_agent = :userAgent, | |||
| client_ip = :clientIp, | |||
| rtt_ms = :rttMs, | |||
| connection_quality = :connectionQuality, | |||
| navigator_online = :navigatorOnline, | |||
| @@ -124,6 +129,7 @@ open class ClientPresenceService( | |||
| current_path = :currentPath, | |||
| client_type = :clientType, | |||
| user_agent = :userAgent, | |||
| client_ip = :clientIp, | |||
| rtt_ms = :rttMs, | |||
| connection_quality = :connectionQuality, | |||
| navigator_online = :navigatorOnline, | |||
| @@ -140,6 +146,7 @@ open class ClientPresenceService( | |||
| "currentPath" to path, | |||
| "clientType" to clientType, | |||
| "userAgent" to req.userAgent?.take(512), | |||
| "clientIp" to clientIp, | |||
| "rttMs" to req.rttMs, | |||
| "connectionQuality" to quality, | |||
| "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | |||
| @@ -158,6 +165,7 @@ open class ClientPresenceService( | |||
| "displayName" to displayName, | |||
| "currentPath" to path, | |||
| "clientType" to clientType, | |||
| "clientIp" to clientIp, | |||
| "rttMs" to req.rttMs, | |||
| "connectionQuality" to quality, | |||
| "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, | |||
| @@ -181,6 +189,7 @@ open class ClientPresenceService( | |||
| display_name AS displayName, | |||
| current_path AS currentPath, | |||
| client_type AS clientType, | |||
| client_ip AS clientIp, | |||
| rtt_ms AS rttMs, | |||
| connection_quality AS connectionQuality, | |||
| navigator_online AS navigatorOnline, | |||
| @@ -210,6 +219,7 @@ open class ClientPresenceService( | |||
| current_path AS currentPath, | |||
| client_type AS clientType, | |||
| user_agent AS userAgent, | |||
| client_ip AS clientIp, | |||
| rtt_ms AS rttMs, | |||
| connection_quality AS connectionQuality, | |||
| navigator_online AS navigatorOnline, | |||
| @@ -264,6 +274,7 @@ open class ClientPresenceService( | |||
| rtt_ms AS rttMs, | |||
| navigator_online AS navigatorOnline, | |||
| current_path AS currentPath, | |||
| client_ip AS clientIp, | |||
| recorded_at AS recordedAt | |||
| FROM client_presence_event | |||
| WHERE recorded_at >= :from AND recorded_at <= :to | |||
| @@ -347,10 +358,10 @@ open class ClientPresenceService( | |||
| """ | |||
| INSERT INTO client_presence_event ( | |||
| 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 ( | |||
| :deviceId, :userId, :username, :displayName, :clientType, | |||
| :status, :connectionQuality, :rttMs, :navigatorOnline, :currentPath, :recordedAt | |||
| :status, :connectionQuality, :rttMs, :navigatorOnline, :currentPath, :clientIp, :recordedAt | |||
| ) | |||
| """.trimIndent(), | |||
| mapOf( | |||
| @@ -367,6 +378,7 @@ open class ClientPresenceService( | |||
| else -> 1 | |||
| }, | |||
| "currentPath" to row["currentPath"], | |||
| "clientIp" to row["clientIp"], | |||
| "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 { | |||
| if (userAgent.isNullOrBlank()) return "unknown" | |||
| val ua = userAgent.lowercase() | |||
| return when { | |||
| ua.contains("ipad") || ua.contains("tablet") -> "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" | |||
| } | |||
| } | |||
| @@ -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}") | |||
| 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( | |||
| 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.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import jakarta.servlet.http.HttpServletRequest | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import java.time.format.DateTimeParseException | |||
| @@ -30,6 +31,7 @@ class ClientPresenceController( | |||
| val connectionQuality: String? = null, | |||
| val navigatorOnline: Boolean? = true, | |||
| val userAgent: String? = null, | |||
| val clientType: String? = null, | |||
| val activityBump: Boolean? = true, | |||
| ) | |||
| @@ -39,7 +41,7 @@ class ClientPresenceController( | |||
| } | |||
| @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 deviceId = body.deviceId?.trim().orEmpty() | |||
| if (deviceId.isEmpty()) { | |||
| @@ -60,6 +62,8 @@ class ClientPresenceController( | |||
| connectionQuality = body.connectionQuality, | |||
| navigatorOnline = body.navigatorOnline, | |||
| userAgent = ua, | |||
| clientType = body.clientType, | |||
| clientIp = ClientIpResolver.resolve(request), | |||
| activityBump = body.activityBump != false, | |||
| ), | |||
| ) | |||
| @@ -2,4 +2,9 @@ spring: | |||
| datasource: | |||
| jdbc-url: jdbc:mysql://127.0.0.1:3306/fpsmsdb?useUnicode=true&characterEncoding=UTF8&serverTimezone=GMT%2B8 | |||
| 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). | |||
| monitoring: | |||
| enabled: false | |||
| # Delete device/printer connectivity events older than this (daily job). | |||
| event-retention-days: 30 | |||
| retention-purge-cron: "0 0 3 * * *" | |||
| client-presence: | |||
| offline-threshold-sec: 90 | |||
| idle-threshold-sec: 300 | |||
| history-sample-sec: 60 | |||
| history-max-range-days: 31 | |||
| history-max-range-days: 30 | |||
| offline-scan-interval-ms: 60000 | |||
| printer-monitor: | |||
| scan-interval-ms: 120000 | |||
| connect-timeout-ms: 3000 | |||
| default-port: 9100 | |||
| offline-event-sample-sec: 300 | |||
| history-max-range-days: 31 | |||
| history-max-range-days: 30 | |||
| spring: | |||
| 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; | |||