Просмотр исходного кода

Merge remote-tracking branch 'origin/production' into production

production
CANCERYS\kw093 1 месяц назад
Родитель
Сommit
4cd0e5479a
9 измененных файлов: 188 добавлений и 16 удалений
  1. +25
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/MonitoringRetentionScheduler.kt
  2. +30
    -10
      src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt
  3. +55
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/service/MonitoringRetentionService.kt
  4. +2
    -2
      src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt
  5. +46
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientIpResolver.kt
  6. +5
    -1
      src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt
  7. +6
    -1
      src/main/resources/application-db-local.yml
  8. +5
    -2
      src/main/resources/application.yml
  9. +14
    -0
      src/main/resources/db/changelog/changes/20260520_04_client_presence_ip/01_client_presence_ip.sql

+ 25
- 0
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)
}
}
}

+ 30
- 10
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}") @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"
} }
} }


+ 55
- 0
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<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(),
)
}
}

+ 2
- 2
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}") @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,


+ 46
- 0
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<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
}

+ 5
- 1
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.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,
), ),
) )


+ 6
- 1
src/main/resources/application-db-local.yml Просмотреть файл

@@ -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

+ 5
- 2
src/main/resources/application.yml Просмотреть файл

@@ -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:


+ 14
- 0
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;

Загрузка…
Отмена
Сохранить