- 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>
281 lines
8.6 KiB
Java
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) {
|
|
}
|
|
}
|