From 3f70bc475c649ed36e0eed920b6c3bc6852f7855 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Wed, 20 May 2026 13:05:04 +0800 Subject: [PATCH] refining the device monitoring page --- .../scheduler/MonitoringRetentionScheduler.kt | 25 +++++++++ .../service/ClientPresenceService.kt | 40 ++++++++++---- .../service/MonitoringRetentionService.kt | 55 +++++++++++++++++++ .../service/PrinterMonitorService.kt | 4 +- .../monitoring/web/ClientIpResolver.kt | 46 ++++++++++++++++ .../web/ClientPresenceController.kt | 6 +- src/main/resources/application-db-local.yml | 7 ++- src/main/resources/application.yml | 7 ++- .../01_client_presence_ip.sql | 14 +++++ 9 files changed, 188 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/MonitoringRetentionScheduler.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/service/MonitoringRetentionService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientIpResolver.kt create mode 100644 src/main/resources/db/changelog/changes/20260520_04_client_presence_ip/01_client_presence_ip.sql diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/MonitoringRetentionScheduler.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/MonitoringRetentionScheduler.kt new file mode 100644 index 0000000..fcba233 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/MonitoringRetentionScheduler.kt @@ -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) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt index bc678a0..f3bdd8f 100644 --- a/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt @@ -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" } } diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/service/MonitoringRetentionService.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/service/MonitoringRetentionService.kt new file mode 100644 index 0000000..ed34851 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/service/MonitoringRetentionService.kt @@ -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 { + 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(), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt index e6f5906..cbccd7f 100644 --- a/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt @@ -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, diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientIpResolver.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientIpResolver.kt new file mode 100644 index 0000000..b6c5915 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientIpResolver.kt @@ -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() + + 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 +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt index 37f2377..23f76f5 100644 --- a/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt @@ -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> { + fun heartbeat(@RequestBody body: HeartbeatBody, request: HttpServletRequest): ResponseEntity> { 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, ), ) diff --git a/src/main/resources/application-db-local.yml b/src/main/resources/application-db-local.yml index 4fa8584..2aff475 100644 --- a/src/main/resources/application-db-local.yml +++ b/src/main/resources/application-db-local.yml @@ -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 \ No newline at end of file + password: secret + +# Monitoring off for local dev (prod only). To test: enabled true + frontend NEXT_PUBLIC_MONITORING_ENABLED=true. +fpsms: + monitoring: + enabled: false \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 335a69f..7f8dccb 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/main/resources/db/changelog/changes/20260520_04_client_presence_ip/01_client_presence_ip.sql b/src/main/resources/db/changelog/changes/20260520_04_client_presence_ip/01_client_presence_ip.sql new file mode 100644 index 0000000..ea4d5d5 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260520_04_client_presence_ip/01_client_presence_ip.sql @@ -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;