Files
htop-web/backend/src/main/java/com/htopweb/system/SystemMetricsService.java
T
Liosha84andJunie 597d8f380a Major improvements: multi-stage Docker, security hardening, host metrics, tests
- Multi-stage Docker build: Dockerfile compiles frontend (Node.js) and
  backend (Gradle) inside Docker; no local Java/Node.js required
- Security hardening: replaced full host root mount with specific
  directory mounts (/proc, /sys, /etc, /var, /home, /opt, /tmp, /srv, /usr)
- Host metrics consistency: LinuxMemoryService and CpuDetailsService now
  read from /host/proc instead of container OSHI
- Concurrency: replaced synchronized with AtomicReference in 3 services
- Global error handling: added @RestControllerAdvice for JSON error responses
- Unit tests: added 32 tests for /proc parsers, CPU usage, process uptime
- Docker security: non-root user, read-only FS, no-new-privileges, healthcheck
- Debug logging: added log.debug() in silent catch blocks
- Documentation: updated README, INSTALLATION, DEVELOPMENT, CONFIGURATION,
  ARCHITECTURE, CHANGELOG

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-28 23:18:01 +01:00

281 lines
8.6 KiB
Java

package com.htopweb.system;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
import oshi.hardware.GlobalMemory;
import oshi.hardware.HardwareAbstractionLayer;
import oshi.software.os.OperatingSystem;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class SystemMetricsService {
private static final Path HOST_PROC = Path.of("/host/proc");
private static final Path HOST_ETC = Path.of("/host/etc");
private final OperatingSystem _operatingSystem;
private final CentralProcessor _processor;
private final GlobalMemory _memory;
private final AtomicReference<CpuTicks> _previousHostCpuTicks = new AtomicReference<>();
private static final Logger log =
LoggerFactory.getLogger(SystemMetricsService.class);
public SystemMetricsService() {
SystemInfo systemInfo = new SystemInfo();
HardwareAbstractionLayer hardware = systemInfo.getHardware();
_operatingSystem = systemInfo.getOperatingSystem();
_processor = hardware.getProcessor();
_memory = hardware.getMemory();
_previousHostCpuTicks.set(readHostCpuTicks());
}
public SystemMetricsResponse getMetrics() {
CpuTicks currentTicks = readHostCpuTicks();
double cpuUsage = 0.0;
CpuTicks previous = _previousHostCpuTicks.get();
if (currentTicks != null && previous != null) {
long totalDelta = currentTicks.total() - previous.total();
long idleDelta = currentTicks.idle() - previous.idle();
if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) {
cpuUsage = (double) (totalDelta - idleDelta) / totalDelta;
}
}
if (currentTicks != null) {
_previousHostCpuTicks.set(currentTicks);
}
Map<String, Long> hostMemory = readHostMemory();
long totalMemory = hostMemory.getOrDefault("MemTotal", _memory.getTotal());
long availableMemory = hostMemory.getOrDefault(
"MemAvailable",
_memory.getAvailable()
);
availableMemory = Math.max(0L, Math.min(totalMemory, availableMemory));
long usedMemory = Math.max(0L, totalMemory - availableMemory);
double memoryUsage = totalMemory > 0
? (double) usedMemory / totalMemory * 100.0
: 0.0;
Map<String, String> osRelease = readHostOsRelease();
return new SystemMetricsResponse(
getHostOsFamily(osRelease),
getHostOsVersion(osRelease),
readHostHostname(),
_processor.getProcessorIdentifier().getName(),
getLogicalProcessorCount(),
round(cpuUsage * 100.0),
totalMemory,
availableMemory,
usedMemory,
round(memoryUsage)
);
}
private CpuTicks readHostCpuTicks() {
Path statFile = HOST_PROC.resolve("stat");
try {
if (!Files.isRegularFile(statFile)
|| !Files.isReadable(statFile)) {
log.warn("Host CPU statistics are unavailable: {}", statFile);
return null;
}
for (String line : Files.readAllLines(statFile)) {
if (!line.startsWith("cpu ")) {
continue;
}
String[] fields = line.trim().split("\\s+");
if (fields.length < 5) {
log.warn("Invalid host CPU statistics in {}", statFile);
return null;
}
long total = 0L;
for (int i = 1; i < fields.length; i++) {
total += Long.parseLong(fields[i]);
}
long idle = Long.parseLong(fields[4]);
if (fields.length > 5) {
idle += Long.parseLong(fields[5]);
}
return new CpuTicks(total, idle);
}
log.warn("Aggregate CPU line not found in {}", statFile);
} catch (IOException | NumberFormatException e) {
log.warn("Failed to read host CPU statistics: {}", e.getMessage());
}
return null;
}
private Map<String, Long> readHostMemory() {
Map<String, Long> values = new HashMap<>();
Path meminfoFile = HOST_PROC.resolve("meminfo");
try {
if (!Files.isRegularFile(meminfoFile)) {
return values;
}
for (String line : Files.readAllLines(meminfoFile)) {
int separator = line.indexOf(':');
if (separator < 0) {
continue;
}
String key = line.substring(0, separator).trim();
if (!key.equals("MemTotal") && !key.equals("MemAvailable")) {
continue;
}
String[] parts = line.substring(separator + 1).trim().split("\\s+");
if (parts.length > 0) {
// Linux meminfo values are reported in kB.
values.put(key, Long.parseLong(parts[0]) * 1024L);
}
}
} catch (IOException | NumberFormatException e) {
log.debug("Failed to read host memory info: {}", e.getMessage());
}
return values;
}
private int getLogicalProcessorCount() {
Path statFile = HOST_PROC.resolve("stat");
try {
if (Files.isRegularFile(statFile)) {
int count = 0;
for (String line : Files.readAllLines(statFile)) {
if (line.matches("^cpu\\d+\\s+.*")) {
count++;
}
}
if (count > 0) {
return count;
}
}
} catch (IOException e) {
log.debug("Failed to read host processor count: {}", e.getMessage());
}
return _processor.getLogicalProcessorCount();
}
private String readHostHostname() {
try {
Path file = HOST_ETC.resolve("hostname");
if (Files.isRegularFile(file)) {
String hostname = Files.readString(file).trim();
if (!hostname.isBlank()) {
return hostname;
}
}
} catch (IOException e) {
log.debug("Failed to read host hostname: {}", e.getMessage());
}
return _operatingSystem.getNetworkParams().getHostName();
}
private Map<String, String> readHostOsRelease() {
Map<String, String> values = new HashMap<>();
try {
Path file = HOST_ETC.resolve("os-release");
if (!Files.isRegularFile(file)) {
return values;
}
for (String line : Files.readAllLines(file)) {
int separator = line.indexOf('=');
if (separator <= 0 || line.startsWith("#")) {
continue;
}
String key = line.substring(0, separator).trim();
String value = line.substring(separator + 1).trim();
if (value.length() >= 2
&& ((value.startsWith("\"") && value.endsWith("\""))
|| (value.startsWith("'") && value.endsWith("'")))) {
value = value.substring(1, value.length() - 1);
}
values.put(key, value);
}
} catch (IOException e) {
log.debug("Failed to read host os-release: {}", e.getMessage());
}
return values;
}
private String getHostOsFamily(Map<String, String> osRelease) {
String name = osRelease.get("NAME");
return name != null && !name.isBlank()
? name
: _operatingSystem.getFamily();
}
private String getHostOsVersion(Map<String, String> osRelease) {
String version = osRelease.get("VERSION");
if (version == null || version.isBlank()) {
version = osRelease.get("VERSION_ID");
}
return version != null && !version.isBlank()
? version
: _operatingSystem.getVersionInfo().getVersion();
}
private double round(double value) {
return Math.round(value * 10.0) / 10.0;
}
private record CpuTicks(long total, long idle) {
}
}