Przeglądaj źródła

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

production
CANCERYS\kw093 1 miesiąc temu
rodzic
commit
4cd0e5479a
9 zmienionych plików z 188 dodań i 16 usunięć
  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 Wyświetl plik

@@ -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 Wyświetl plik

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


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/service/MonitoringRetentionService.kt Wyświetl plik

@@ -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 Wyświetl plik

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


+ 46
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientIpResolver.kt Wyświetl plik

@@ -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 Wyświetl plik

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


+ 6
- 1
src/main/resources/application-db-local.yml Wyświetl plik

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

+ 5
- 2
src/main/resources/application.yml Wyświetl plik

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


+ 14
- 0
src/main/resources/db/changelog/changes/20260520_04_client_presence_ip/01_client_presence_ip.sql Wyświetl plik

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

Ładowanie…
Anuluj
Zapisz