бла бла бла
This commit is contained in:
@@ -68,6 +68,13 @@ tasks.register('buildFrontend', Exec) {
|
||||
|
||||
tasks.register('copyFrontend', Copy) {
|
||||
dependsOn 'buildFrontend'
|
||||
outputs.upToDateWhen { false }
|
||||
|
||||
doFirst {
|
||||
fileTree(springStaticDir).matching {
|
||||
include 'main-*.js', 'polyfills-*.js', 'chunk-*.js', 'styles-*.css'
|
||||
}.files.each { it.delete() }
|
||||
}
|
||||
|
||||
from(frontendDistDir)
|
||||
into(springStaticDir)
|
||||
|
||||
@@ -1,66 +1,89 @@
|
||||
|
||||
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;
|
||||
|
||||
@Service
|
||||
public class SystemMetricsService {
|
||||
|
||||
private final SystemInfo _systemInfo;
|
||||
private final HardwareAbstractionLayer _hardware;
|
||||
private static final Path HOST_ROOT = Path.of("/host/root");
|
||||
private static final Path HOST_PROC = HOST_ROOT.resolve("proc");
|
||||
|
||||
private final OperatingSystem _operatingSystem;
|
||||
private final CentralProcessor _processor;
|
||||
private final GlobalMemory _memory;
|
||||
|
||||
private long[] _previousCpuTicks;
|
||||
private CpuTicks _previousHostCpuTicks;
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(SystemMetricsService.class);
|
||||
|
||||
public SystemMetricsService() {
|
||||
_systemInfo = new SystemInfo();
|
||||
_hardware = _systemInfo.getHardware();
|
||||
_operatingSystem = _systemInfo.getOperatingSystem();
|
||||
_processor = _hardware.getProcessor();
|
||||
_memory = _hardware.getMemory();
|
||||
SystemInfo systemInfo = new SystemInfo();
|
||||
HardwareAbstractionLayer hardware = systemInfo.getHardware();
|
||||
|
||||
_previousCpuTicks = _processor.getSystemCpuLoadTicks();
|
||||
_operatingSystem = systemInfo.getOperatingSystem();
|
||||
_processor = hardware.getProcessor();
|
||||
_memory = hardware.getMemory();
|
||||
|
||||
_previousHostCpuTicks = readHostCpuTicks();
|
||||
}
|
||||
|
||||
public synchronized SystemMetricsResponse getMetrics() {
|
||||
long[] currentCpuTicks = _processor.getSystemCpuLoadTicks();
|
||||
CpuTicks currentTicks = readHostCpuTicks();
|
||||
double cpuUsage = 0.0;
|
||||
|
||||
double cpuUsage = _processor.getSystemCpuLoadBetweenTicks(
|
||||
_previousCpuTicks,
|
||||
currentCpuTicks
|
||||
);
|
||||
if (currentTicks != null && _previousHostCpuTicks != null) {
|
||||
long totalDelta = currentTicks.total() - _previousHostCpuTicks.total();
|
||||
long idleDelta = currentTicks.idle() - _previousHostCpuTicks.idle();
|
||||
|
||||
_previousCpuTicks = currentCpuTicks;
|
||||
|
||||
if (Double.isNaN(cpuUsage) || Double.isInfinite(cpuUsage)) {
|
||||
cpuUsage = 0.0;
|
||||
if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) {
|
||||
cpuUsage = (double) (totalDelta - idleDelta) / totalDelta;
|
||||
}
|
||||
}
|
||||
|
||||
// Task Manager displays system CPU usage in the 0–100% range.
|
||||
cpuUsage = Math.max(0.0, Math.min(1.0, cpuUsage));
|
||||
if (currentTicks != null) {
|
||||
_previousHostCpuTicks = 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 totalMemory = _memory.getTotal();
|
||||
long availableMemory = _memory.getAvailable();
|
||||
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(
|
||||
_operatingSystem.getFamily(),
|
||||
_operatingSystem.getVersionInfo().getVersion(),
|
||||
_operatingSystem.getNetworkParams().getHostName(),
|
||||
getHostOsFamily(osRelease),
|
||||
getHostOsVersion(osRelease),
|
||||
readHostHostname(),
|
||||
_processor.getProcessorIdentifier().getName(),
|
||||
_processor.getLogicalProcessorCount(),
|
||||
getLogicalProcessorCount(),
|
||||
round(cpuUsage * 100.0),
|
||||
totalMemory,
|
||||
availableMemory,
|
||||
@@ -69,7 +92,187 @@ public class SystemMetricsService {
|
||||
);
|
||||
}
|
||||
|
||||
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 ignored) {
|
||||
// Use OSHI values when host meminfo is unavailable.
|
||||
}
|
||||
|
||||
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 ignored) {
|
||||
// Fall back to OSHI.
|
||||
}
|
||||
|
||||
return _processor.getLogicalProcessorCount();
|
||||
}
|
||||
|
||||
private String readHostHostname() {
|
||||
try {
|
||||
Path file = HOST_ROOT.resolve("etc/hostname");
|
||||
|
||||
if (Files.isRegularFile(file)) {
|
||||
String hostname = Files.readString(file).trim();
|
||||
|
||||
if (!hostname.isBlank()) {
|
||||
return hostname;
|
||||
}
|
||||
}
|
||||
} catch (IOException ignored) {
|
||||
// Fall back to OSHI.
|
||||
}
|
||||
|
||||
return _operatingSystem.getNetworkParams().getHostName();
|
||||
}
|
||||
|
||||
private Map<String, String> readHostOsRelease() {
|
||||
Map<String, String> values = new HashMap<>();
|
||||
|
||||
try {
|
||||
Path file = HOST_ROOT.resolve("etc/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 ignored) {
|
||||
// Fall back to OSHI.
|
||||
}
|
||||
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ package com.htopweb.system;
|
||||
import oshi.SystemInfo;
|
||||
import oshi.software.os.OSProcess;
|
||||
import oshi.software.os.OperatingSystem;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -15,15 +20,24 @@ import java.util.Map;
|
||||
@Service
|
||||
public class SystemProcessesService {
|
||||
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(SystemProcessesService.class);
|
||||
|
||||
private static final Path HOST_PROC = Path.of("/host/root/proc");
|
||||
private static final Path PROC = Path.of("/proc");
|
||||
|
||||
private final OperatingSystem _operatingSystem;
|
||||
private final int _logicalProcessorCount;
|
||||
private final long _clockTicksPerSecond;
|
||||
private final long _pageSizeBytes;
|
||||
|
||||
private final Map<Integer, OSProcess> _previousProcesses =
|
||||
private final Map<Integer, ProcessSnapshot> _previousProcesses =
|
||||
new HashMap<>();
|
||||
|
||||
private long _previousSampleNanos = System.nanoTime();
|
||||
|
||||
public SystemProcessesService() {
|
||||
SystemInfo systemInfo = new SystemInfo();
|
||||
|
||||
_operatingSystem = systemInfo.getOperatingSystem();
|
||||
_logicalProcessorCount = Math.max(
|
||||
1,
|
||||
@@ -31,55 +45,246 @@ public class SystemProcessesService {
|
||||
.getProcessor()
|
||||
.getLogicalProcessorCount()
|
||||
);
|
||||
|
||||
_clockTicksPerSecond = readPositiveSystemValue(
|
||||
"getconf CLK_TCK", 100L
|
||||
);
|
||||
_pageSizeBytes = readPositiveSystemValue(
|
||||
"getconf PAGESIZE", 4096L
|
||||
);
|
||||
}
|
||||
|
||||
public synchronized List<ProcessInfoResponse> getProcesses() {
|
||||
List<OSProcess> processes = _operatingSystem.getProcesses();
|
||||
Map<Integer, OSProcess> currentProcesses = new HashMap<>();
|
||||
Path procRoot = Files.isDirectory(HOST_PROC) ? HOST_PROC : PROC;
|
||||
|
||||
List<ProcessInfoResponse> result = processes.stream()
|
||||
.map(process -> {
|
||||
int pid = process.getProcessID();
|
||||
currentProcesses.put(pid, process);
|
||||
if (!Files.isReadable(procRoot)) {
|
||||
log.warn("Process information is unavailable: {}", procRoot);
|
||||
return getProcessesFromOshi();
|
||||
}
|
||||
|
||||
OSProcess previous = _previousProcesses.get(pid);
|
||||
double cpuUsage = 0.0;
|
||||
long nowNanos = System.nanoTime();
|
||||
double elapsedSeconds =
|
||||
(nowNanos - _previousSampleNanos) / 1_000_000_000.0;
|
||||
_previousSampleNanos = nowNanos;
|
||||
|
||||
if (pid != 0
|
||||
&& previous != null
|
||||
&& previous.getStartTime() == process.getStartTime()) {
|
||||
long bootTimeSeconds = readBootTime(procRoot);
|
||||
Map<Integer, ProcessSnapshot> currentProcesses = new HashMap<>();
|
||||
List<ProcessInfoResponse> result = new ArrayList<>();
|
||||
|
||||
cpuUsage = process.getProcessCpuLoadBetweenTicks(previous)
|
||||
* 100.0 / _logicalProcessorCount;
|
||||
try (var entries = Files.list(procRoot)) {
|
||||
entries.filter(path ->
|
||||
path.getFileName().toString().matches("\\d+"))
|
||||
.forEach(path -> {
|
||||
try {
|
||||
int pid = Integer.parseInt(
|
||||
path.getFileName().toString()
|
||||
);
|
||||
|
||||
cpuUsage = Math.max(0.0, Math.min(100.0, cpuUsage));
|
||||
}
|
||||
ProcessSnapshot current = readProcess(
|
||||
path, pid, bootTimeSeconds
|
||||
);
|
||||
|
||||
return new ProcessInfoResponse(
|
||||
pid,
|
||||
process.getName(),
|
||||
process.getState().name(),
|
||||
Math.round(cpuUsage * 10.0) / 10.0,
|
||||
process.getResidentMemory(),
|
||||
process.getVirtualSize(),
|
||||
Math.max(
|
||||
0L,
|
||||
(System.currentTimeMillis()
|
||||
- process.getStartTime()) / 1000
|
||||
),
|
||||
process.getThreadCount()
|
||||
);
|
||||
})
|
||||
.sorted(
|
||||
Comparator.comparingDouble(
|
||||
ProcessInfoResponse::cpuUsagePercent
|
||||
).reversed()
|
||||
)
|
||||
.toList();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentProcesses.put(pid, current);
|
||||
|
||||
ProcessSnapshot previous =
|
||||
_previousProcesses.get(pid);
|
||||
|
||||
double cpuUsage = 0.0;
|
||||
|
||||
if (previous != null
|
||||
&& previous.startTimeTicks()
|
||||
== current.startTimeTicks()
|
||||
&& elapsedSeconds > 0.0) {
|
||||
long deltaTicks =
|
||||
current.cpuTicks() - previous.cpuTicks();
|
||||
|
||||
if (deltaTicks >= 0) {
|
||||
cpuUsage = deltaTicks
|
||||
/ (double) _clockTicksPerSecond
|
||||
/ elapsedSeconds
|
||||
* 100.0
|
||||
/ _logicalProcessorCount;
|
||||
}
|
||||
}
|
||||
|
||||
cpuUsage = Math.max(
|
||||
0.0, Math.min(100.0, cpuUsage)
|
||||
);
|
||||
|
||||
result.add(new ProcessInfoResponse(
|
||||
pid,
|
||||
current.name(),
|
||||
current.state(),
|
||||
Math.round(cpuUsage * 10.0) / 10.0,
|
||||
current.residentMemoryBytes(),
|
||||
current.virtualMemoryBytes(),
|
||||
current.uptimeSeconds(),
|
||||
current.threadCount()
|
||||
));
|
||||
} catch (IOException | NumberFormatException
|
||||
| SecurityException ignored) {
|
||||
// A process may exit or become inaccessible.
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
log.warn("Cannot read process list from {}: {}",
|
||||
procRoot, e.getMessage());
|
||||
return getProcessesFromOshi();
|
||||
}
|
||||
|
||||
_previousProcesses.clear();
|
||||
_previousProcesses.putAll(currentProcesses);
|
||||
|
||||
result.sort(Comparator.comparingDouble(
|
||||
ProcessInfoResponse::cpuUsagePercent
|
||||
).reversed());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private ProcessSnapshot readProcess(
|
||||
Path processPath,
|
||||
int pid,
|
||||
long bootTimeSeconds
|
||||
) throws IOException {
|
||||
String stat = Files.readString(processPath.resolve("stat")).trim();
|
||||
|
||||
int open = stat.indexOf('(');
|
||||
int close = stat.lastIndexOf(')');
|
||||
|
||||
if (open < 0 || close <= open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String name = stat.substring(open + 1, close);
|
||||
String[] fields = stat.substring(close + 2).trim().split("\\s+");
|
||||
|
||||
// Array begins at Linux stat field 3 (state).
|
||||
if (fields.length < 22) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String state = fields[0];
|
||||
long userTicks = Long.parseLong(fields[11]); // field 14
|
||||
long systemTicks = Long.parseLong(fields[12]); // field 15
|
||||
long startTicks = Long.parseLong(fields[19]); // field 22
|
||||
long virtualBytes = Long.parseLong(fields[20]); // field 23
|
||||
long residentPages = Long.parseLong(fields[21]); // field 24
|
||||
|
||||
int threadCount = readThreadCount(processPath.resolve("status"));
|
||||
|
||||
long uptimeSeconds = 0L;
|
||||
|
||||
if (bootTimeSeconds > 0L) {
|
||||
long processStartSeconds = bootTimeSeconds
|
||||
+ startTicks / _clockTicksPerSecond;
|
||||
uptimeSeconds = Math.max(
|
||||
0L, System.currentTimeMillis() / 1000L
|
||||
- processStartSeconds
|
||||
);
|
||||
}
|
||||
|
||||
return new ProcessSnapshot(
|
||||
pid,
|
||||
name,
|
||||
state,
|
||||
userTicks + systemTicks,
|
||||
startTicks,
|
||||
Math.max(0L, virtualBytes),
|
||||
Math.max(0L, residentPages) * _pageSizeBytes,
|
||||
uptimeSeconds,
|
||||
threadCount
|
||||
);
|
||||
}
|
||||
|
||||
private int readThreadCount(Path statusFile) {
|
||||
try {
|
||||
for (String line : Files.readAllLines(statusFile)) {
|
||||
if (line.startsWith("Threads:")) {
|
||||
return Integer.parseInt(
|
||||
line.substring("Threads:".length()).trim()
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (IOException | NumberFormatException ignored) {
|
||||
// The process may exit while status is being read.
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long readBootTime(Path procRoot) {
|
||||
try {
|
||||
for (String line : Files.readAllLines(procRoot.resolve("stat"))) {
|
||||
if (line.startsWith("btime ")) {
|
||||
return Long.parseLong(line.substring(6).trim());
|
||||
}
|
||||
}
|
||||
} catch (IOException | NumberFormatException ignored) {
|
||||
// Boot time unavailable.
|
||||
}
|
||||
|
||||
return 0L;
|
||||
}
|
||||
|
||||
private long readPositiveSystemValue(String command, long fallback) {
|
||||
try {
|
||||
Process process = new ProcessBuilder(
|
||||
"/bin/sh", "-c", command
|
||||
).redirectErrorStream(true).start();
|
||||
|
||||
String output = new String(
|
||||
process.getInputStream().readAllBytes()
|
||||
).trim();
|
||||
|
||||
if (process.waitFor() == 0) {
|
||||
long value = Long.parseLong(output);
|
||||
|
||||
if (value > 0L) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Cannot read system value for {}: {}", command,
|
||||
e.getMessage());
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private List<ProcessInfoResponse> getProcessesFromOshi() {
|
||||
return _operatingSystem.getProcesses().stream()
|
||||
.map(process -> new ProcessInfoResponse(
|
||||
process.getProcessID(),
|
||||
process.getName(),
|
||||
process.getState().name(),
|
||||
0.0,
|
||||
process.getResidentMemory(),
|
||||
process.getVirtualSize(),
|
||||
Math.max(0L, (System.currentTimeMillis()
|
||||
- process.getStartTime()) / 1000L),
|
||||
process.getThreadCount()
|
||||
))
|
||||
.sorted(Comparator.comparingLong(
|
||||
ProcessInfoResponse::residentMemoryBytes
|
||||
).reversed())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private record ProcessSnapshot(
|
||||
int pid,
|
||||
String name,
|
||||
String state,
|
||||
long cpuTicks,
|
||||
long startTimeTicks,
|
||||
long virtualMemoryBytes,
|
||||
long residentMemoryBytes,
|
||||
long uptimeSeconds,
|
||||
int threadCount
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,131 @@
|
||||
|
||||
package com.htopweb.system.linux;
|
||||
|
||||
import oshi.SystemInfo;
|
||||
import oshi.software.os.OSFileStore;
|
||||
import oshi.software.os.OperatingSystem;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.FileStore;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class LinuxDiskService {
|
||||
|
||||
private final OperatingSystem _operatingSystem;
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(LinuxDiskService.class);
|
||||
|
||||
public LinuxDiskService() {
|
||||
SystemInfo systemInfo = new SystemInfo();
|
||||
_operatingSystem = systemInfo.getOperatingSystem();
|
||||
}
|
||||
private static final Path HOST_ROOT = Path.of("/host/root");
|
||||
private static final Path HOST_MOUNTS =
|
||||
HOST_ROOT.resolve("proc/1/mounts");
|
||||
|
||||
private static final Set<String> EXCLUDED_TYPES = Set.of(
|
||||
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs",
|
||||
"cgroup", "cgroup2", "overlay", "squashfs",
|
||||
"tracefs", "securityfs", "debugfs", "pstore",
|
||||
"configfs", "fusectl", "mqueue", "hugetlbfs",
|
||||
"rpc_pipefs", "autofs"
|
||||
);
|
||||
|
||||
public List<LinuxDiskResponse> getDisks() {
|
||||
return _operatingSystem.getFileSystem()
|
||||
.getFileStores()
|
||||
.stream()
|
||||
.filter(store -> store.getMount().startsWith("/"))
|
||||
.filter(store -> store.getTotalSpace() > 0)
|
||||
.map(this::toResponse)
|
||||
.sorted(Comparator.comparing(LinuxDiskResponse::mountPoint))
|
||||
.toList();
|
||||
if (!Files.isReadable(HOST_MOUNTS)) {
|
||||
log.error("Host mounts file is not readable: {}", HOST_MOUNTS);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try {
|
||||
List<LinuxDiskResponse> disks = Files.readAllLines(HOST_MOUNTS)
|
||||
.stream()
|
||||
.map(line -> line.trim().split("\\s+"))
|
||||
.filter(fields -> fields.length >= 3)
|
||||
.filter(fields -> fields[0].startsWith("/dev/"))
|
||||
.filter(fields -> !EXCLUDED_TYPES.contains(fields[2]))
|
||||
.map(this::toHostResponse)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(java.util.stream.Collectors.toMap(
|
||||
LinuxDiskResponse::mountPoint,
|
||||
disk -> disk,
|
||||
(first, second) -> first
|
||||
))
|
||||
.values()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(
|
||||
LinuxDiskResponse::mountPoint
|
||||
))
|
||||
.toList();
|
||||
|
||||
log.debug("Found {} host disks", disks.size());
|
||||
return disks;
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read host mounts: {}", e.getMessage(), e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private LinuxDiskResponse toResponse(OSFileStore store) {
|
||||
long total = Math.max(0L, store.getTotalSpace());
|
||||
long usable = Math.max(
|
||||
0L,
|
||||
Math.min(total, store.getUsableSpace())
|
||||
);
|
||||
long used = Math.max(0L, total - usable);
|
||||
private LinuxDiskResponse toHostResponse(String[] fields) {
|
||||
String device = unescapeMountField(fields[0]);
|
||||
String hostMount = unescapeMountField(fields[1]);
|
||||
String type = fields[2];
|
||||
|
||||
double usage = total > 0
|
||||
? Math.round((double) used / total * 1000.0) / 10.0
|
||||
: 0.0;
|
||||
Path accessiblePath = HOST_ROOT.resolve(
|
||||
hostMount.startsWith("/")
|
||||
? hostMount.substring(1)
|
||||
: hostMount
|
||||
).normalize();
|
||||
|
||||
return new LinuxDiskResponse(
|
||||
store.getName(),
|
||||
store.getMount(),
|
||||
store.getType(),
|
||||
total,
|
||||
usable,
|
||||
used,
|
||||
usage
|
||||
);
|
||||
if (!accessiblePath.startsWith(HOST_ROOT)
|
||||
|| !Files.exists(accessiblePath)) {
|
||||
log.warn("Host mount path is unavailable: {}", accessiblePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
FileStore store = Files.getFileStore(accessiblePath);
|
||||
|
||||
long total = Math.max(0L, store.getTotalSpace());
|
||||
long usable = Math.max(
|
||||
0L,
|
||||
Math.min(total, store.getUsableSpace())
|
||||
);
|
||||
|
||||
if (total == 0L) {
|
||||
log.warn("Host disk has zero capacity: {}", hostMount);
|
||||
return null;
|
||||
}
|
||||
|
||||
long used = total - usable;
|
||||
double usage = Math.round(
|
||||
(double) used / total * 1000.0
|
||||
) / 10.0;
|
||||
|
||||
return new LinuxDiskResponse(
|
||||
device,
|
||||
hostMount,
|
||||
type,
|
||||
total,
|
||||
usable,
|
||||
used,
|
||||
usage
|
||||
);
|
||||
} catch (IOException | SecurityException e) {
|
||||
log.warn(
|
||||
"Cannot read capacity for host mount {}: {}",
|
||||
hostMount,
|
||||
e.getMessage()
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String unescapeMountField(String value) {
|
||||
return value.replace("\\040", " ")
|
||||
.replace("\\011", "\t")
|
||||
.replace("\\012", "\n")
|
||||
.replace("\\134", "\\");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
|
||||
package com.htopweb.system.linux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record LinuxNetworkResponse(
|
||||
String name,
|
||||
String displayName,
|
||||
String macAddress,
|
||||
List<String> ipv4Addresses,
|
||||
List<String> ipv6Addresses,
|
||||
long bytesReceived,
|
||||
long bytesSent,
|
||||
long packetsReceived,
|
||||
|
||||
@@ -1,86 +1,186 @@
|
||||
|
||||
package com.htopweb.system.linux;
|
||||
|
||||
import oshi.SystemInfo;
|
||||
import oshi.hardware.NetworkIF;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class LinuxNetworkService {
|
||||
|
||||
private final SystemInfo _systemInfo;
|
||||
private static final Logger log =
|
||||
LoggerFactory.getLogger(LinuxNetworkService.class);
|
||||
|
||||
private static final Path HOST_ROOT = Path.of("/host/root");
|
||||
|
||||
private static final Path HOST_NET_DEV =
|
||||
HOST_ROOT.resolve("proc/1/net/dev");
|
||||
|
||||
private static final Path HOST_NET_CLASS =
|
||||
HOST_ROOT.resolve("sys/class/net");
|
||||
|
||||
private static final Set<String> EXCLUDED_INTERFACES =
|
||||
Set.of("lo", "docker0");
|
||||
|
||||
private final ConcurrentMap<String, NetworkSnapshot> _previousSnapshots =
|
||||
new ConcurrentHashMap<>();
|
||||
|
||||
public LinuxNetworkService() {
|
||||
_systemInfo = new SystemInfo();
|
||||
}
|
||||
|
||||
public List<LinuxNetworkResponse> getInterfaces() {
|
||||
return _systemInfo.getHardware()
|
||||
.getNetworkIFs()
|
||||
.stream()
|
||||
.map(this::toResponse)
|
||||
.toList();
|
||||
if (!Files.isReadable(HOST_NET_DEV)) {
|
||||
log.error("Host network counters unavailable: {}", HOST_NET_DEV);
|
||||
return List.of();
|
||||
}
|
||||
|
||||
try {
|
||||
List<LinuxNetworkResponse> result = new ArrayList<>();
|
||||
|
||||
for (String line : Files.readAllLines(HOST_NET_DEV)) {
|
||||
int separator = line.indexOf(':');
|
||||
|
||||
if (separator < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String name = line.substring(0, separator).trim();
|
||||
|
||||
if (name.isBlank() || !shouldIncludeInterface(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String[] values = line.substring(separator + 1)
|
||||
.trim()
|
||||
.split("\\s+");
|
||||
|
||||
if (values.length < 16) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.add(toResponse(
|
||||
name,
|
||||
parseCounter(values[0]),
|
||||
parseCounter(values[1]),
|
||||
parseCounter(values[2]),
|
||||
parseCounter(values[8]),
|
||||
parseCounter(values[9]),
|
||||
parseCounter(values[10])
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (IOException | NumberFormatException e) {
|
||||
log.error("Failed to read host network counters: {}",
|
||||
e.getMessage(), e);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private LinuxNetworkResponse toResponse(NetworkIF networkIF) {
|
||||
networkIF.updateAttributes();
|
||||
private boolean shouldIncludeInterface(String name) {
|
||||
if (EXCLUDED_INTERFACES.contains(name)
|
||||
|| name.startsWith("veth")
|
||||
|| name.startsWith("br-")
|
||||
|| name.startsWith("docker")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String name = networkIF.getName();
|
||||
long received = Math.max(0L, networkIF.getBytesRecv());
|
||||
long sent = Math.max(0L, networkIF.getBytesSent());
|
||||
Path interfacePath = HOST_NET_CLASS.resolve(name);
|
||||
|
||||
// Physical network interfaces normally expose a "device" symlink.
|
||||
if (Files.exists(interfacePath.resolve("device"))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep useful virtual interfaces such as WireGuard and tunnels.
|
||||
return Files.exists(interfacePath.resolve("wireless"))
|
||||
|| Files.exists(interfacePath.resolve("tun_flags"))
|
||||
|| name.startsWith("wg")
|
||||
|| name.startsWith("tun")
|
||||
|| name.startsWith("tap")
|
||||
|| name.startsWith("bond")
|
||||
|| name.startsWith("br");
|
||||
}
|
||||
|
||||
private LinuxNetworkResponse toResponse(
|
||||
String name,
|
||||
long received,
|
||||
long packetsReceived,
|
||||
long inboundErrors,
|
||||
long sent,
|
||||
long packetsSent,
|
||||
long outboundErrors
|
||||
) {
|
||||
long now = System.nanoTime();
|
||||
|
||||
NetworkSnapshot current = new NetworkSnapshot(received, sent, now);
|
||||
NetworkSnapshot previous = _previousSnapshots.put(name, current);
|
||||
NetworkSnapshot current =
|
||||
new NetworkSnapshot(received, sent, now);
|
||||
|
||||
NetworkSnapshot previous =
|
||||
_previousSnapshots.put(name, current);
|
||||
|
||||
double receiveBytesPerSecond = 0.0;
|
||||
double sendBytesPerSecond = 0.0;
|
||||
|
||||
if (previous != null && now > previous.timestampNanos()) {
|
||||
double elapsedSeconds =
|
||||
double elapsed =
|
||||
(now - previous.timestampNanos()) / 1_000_000_000.0;
|
||||
|
||||
if (received >= previous.bytesReceived()) {
|
||||
receiveBytesPerSecond =
|
||||
(received - previous.bytesReceived()) / elapsedSeconds;
|
||||
(received - previous.bytesReceived()) / elapsed;
|
||||
}
|
||||
|
||||
if (sent >= previous.bytesSent()) {
|
||||
sendBytesPerSecond =
|
||||
(sent - previous.bytesSent()) / elapsedSeconds;
|
||||
(sent - previous.bytesSent()) / elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
double receiveMbps = receiveBytesPerSecond * 8.0 / 1_000_000.0;
|
||||
double sendMbps = sendBytesPerSecond * 8.0 / 1_000_000.0;
|
||||
Path interfacePath = HOST_NET_CLASS.resolve(name);
|
||||
String alias = readFirstLine(interfacePath.resolve("ifalias"));
|
||||
String displayName = alias.isBlank() ? name : alias;
|
||||
|
||||
return new LinuxNetworkResponse(
|
||||
name,
|
||||
networkIF.getDisplayName(),
|
||||
networkIF.getMacaddr(),
|
||||
Arrays.asList(networkIF.getIPv4addr()),
|
||||
Arrays.asList(networkIF.getIPv6addr()),
|
||||
displayName,
|
||||
received,
|
||||
sent,
|
||||
Math.max(0L, networkIF.getPacketsRecv()),
|
||||
Math.max(0L, networkIF.getPacketsSent()),
|
||||
Math.max(0L, networkIF.getInErrors()),
|
||||
Math.max(0L, networkIF.getOutErrors()),
|
||||
packetsReceived,
|
||||
packetsSent,
|
||||
inboundErrors,
|
||||
outboundErrors,
|
||||
roundOne(receiveBytesPerSecond),
|
||||
roundOne(sendBytesPerSecond),
|
||||
roundOne(receiveMbps),
|
||||
roundOne(sendMbps)
|
||||
roundOne(receiveBytesPerSecond * 8.0 / 1_000_000.0),
|
||||
roundOne(sendBytesPerSecond * 8.0 / 1_000_000.0)
|
||||
);
|
||||
}
|
||||
|
||||
private long parseCounter(String value) {
|
||||
return Math.max(0L, Long.parseLong(value));
|
||||
}
|
||||
|
||||
private String readFirstLine(Path path) {
|
||||
if (!Files.isReadable(path)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try (Stream<String> lines = Files.lines(path)) {
|
||||
return lines.findFirst().orElse("").trim();
|
||||
} catch (IOException e) {
|
||||
log.debug("Cannot read {}: {}", path, e.getMessage());
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private double roundOne(double value) {
|
||||
return Math.round(value * 10.0) / 10.0;
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
<link rel="stylesheet" href="styles-5INURTSO.css"></head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<script src="main-Y67327X6.js" type="module"></script></body>
|
||||
<script src="main-WRDGP7C5.js" type="module"></script></body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user