3 Commits
Author SHA1 Message Date
Liosha84andJunie 02d43bd94a Prepare release v0.2.0
Docker Build and Push / build-and-push (push) Canceled after 0s
- Move Unreleased entries to v0.2.0 in CHANGELOG.md
- CI/CD with Gitea Actions for automated Docker builds
- Docker build optimization with .dockerignore
- Release process documentation

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-28 23:30:24 +01:00
Liosha84andJunie f3e095dafc Add Gitea Actions CI/CD for automated Docker builds
- Add .gitea/workflows/docker.yml: builds and pushes Docker image on Git tags
  with semantic versioning (v0.1.0 → tags 0.1.0, 0.1, latest)
- Add .dockerignore to optimize Docker build context
- Document release process in docs/DEVELOPMENT.md
- Update CHANGELOG.md with v0.1.0 release and Unreleased CI/CD entry

Required Gitea secrets: REGISTRY_USERNAME, REGISTRY_PASSWORD

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-28 23:27:29 +01:00
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
25 changed files with 1261 additions and 145 deletions
+27 -7
View File
@@ -1,9 +1,29 @@
# Docker build context exclusions
.git
.github
.gitea
docs
*.md
!.markdownlint.yaml
.idea
**/.idea
**/node_modules
**/dist
**/.gradle
**/.angular
*.iml
node_modules
.gradle
build
dist
.DS_Store
*.log
*.tmp
*.swp
*~
# Frontend build artifacts (built inside Docker)
frontend/dist
frontend/.angular
# Backend build artifacts (built inside Docker)
backend/build
backend/libs
# Test reports
backend/build/reports
# Secrets
.env
.env.*
+50
View File
@@ -0,0 +1,50 @@
name: Docker Build and Push
on:
push:
tags:
- 'v*'
env:
REGISTRY: gitea.jambotron.com
IMAGE_NAME: liosha84/htop-web
jobs:
build-and-push:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Extract version from tag
id: version
run: |
# Remove 'v' prefix from tag (e.g., v0.1.0 → 0.1.0)
VERSION=$(echo "${{ gitea.ref }}" | sed 's|refs/tags/v||')
echo "version=${VERSION}" >> "${GITEA_OUTPUT}"
# Extract major.minor for minor tag (e.g., 0.1.0 → 0.1)
MINOR=$(echo "${VERSION}" | sed 's/\.[0-9]*$//')
echo "minor=${MINOR}" >> "${GITEA_OUTPUT}"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.minor }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
+7
View File
@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel>
<module name="backend" target="21" />
<module name="backend.main" target="21" />
<module name="backend.test" target="21" />
</bytecodeTargetLevel>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="backend" options="-parameters" />
+18 -2
View File
@@ -2,8 +2,24 @@
All notable project changes should be documented here.
This project has not yet established a versioned changelog. Add entries as releases are published.
This project follows [Semantic Versioning](https://semver.org/). For more details, see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md#release-process).
## Unreleased
- Documentation structure established.
## v0.2.0
- **CI/CD with Gitea Actions** — automated Docker image build and push on Git tags. Each release produces three tags: full version (`0.2.0`), minor version (`0.2`), and `latest`. Configure `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets in Gitea repository settings.
- **Docker build optimization** — added `.dockerignore` to exclude unnecessary files from the build context, reducing build time and image size.
- **Release documentation** — added detailed release process guide in `docs/DEVELOPMENT.md` with Semantic Versioning workflow.
## v0.1.0
- **Multi-stage Docker build** — the Dockerfile now compiles both the Angular frontend and Spring Boot backend inside Docker using three stages (Node.js → Gradle → JRE). Build the entire application with a single `docker build` command — no local Java or Node.js required.
- **Security hardening** — replaced full host root mount (`/:/host/root:ro`) with specific directory mounts (`/proc`, `/sys`, `/etc/hostname`, `/etc/os-release`, `/var`, `/home`, `/opt`, `/tmp`, `/srv`, `/usr`) to minimize the attack surface.
- **Host metrics consistency** — `LinuxMemoryService` and `CpuDetailsService` now read host metrics from `/host/proc` instead of container metrics from OSHI.
- **Concurrency improvements** — replaced `synchronized` methods with `AtomicReference` in `SystemMetricsService`, `SystemProcessesService`, and `CpuDetailsService` to eliminate lock contention.
- **Global error handling** — added `@RestControllerAdvice` for structured JSON error responses.
- **Unit tests** — added 32 tests covering `/proc` file parsers, CPU usage calculations, and process uptime logic.
- **Docker security** — added non-root user (`appuser`), read-only filesystem, `no-new-privileges`, and healthcheck to the container.
- **Debug logging** — added `log.debug()` calls in silent catch blocks across all services.
- **Documentation structure established.**
+29 -1
View File
@@ -1,6 +1,34 @@
# Stage 1: Build frontend with Node.js
FROM node:22-alpine AS frontend
WORKDIR /frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build -- --configuration production
# Stage 2: Build backend with Gradle
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /app
COPY backend/build.gradle backend/settings.gradle ./
COPY backend/gradlew ./
COPY backend/gradle/ gradle/
COPY backend/src/ src/
# Copy built frontend assets into Spring static resources
COPY --from=frontend /frontend/dist/frontend/browser src/main/resources/static/
RUN chmod +x gradlew && SKIP_FRONTEND=true ./gradlew bootJar --no-daemon
# Stage 3: Runtime image
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY backend/build/libs/backend-0.0.1-SNAPSHOT.jar app.jar
COPY --from=builder /app/build/libs/backend-0.0.1-SNAPSHOT.jar app.jar
RUN adduser --disabled-password --no-create-home appuser && \
chown appuser:appuser /app
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q --spider http://localhost:8080/api/system/metrics || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
+33 -15
View File
@@ -63,16 +63,29 @@ The Angular frontend is built for production and packaged into the Spring Boot a
## Simple Scenario of using
```bash
```yaml
services:
htop-web:
image: gitea.jambotron.com/liosha84/htop-web:latest
container_name: htop-web
restart: unless-stopped
pid: host
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
- /etc/hostname:/host/etc/hostname:ro
- /etc/os-release:/host/etc/os-release:ro
- /var:/host/var:ro
- /home:/host/home:ro
- /opt:/host/opt:ro
- /tmp:/host/tmp:ro
- /srv:/host/srv:ro
- /usr:/host/usr:ro
labels:
caddy: htop-web.jambotron.com
caddy.reverse_proxy: "{{upstreams 8080}}"
@@ -93,13 +106,28 @@ docker compose up -d
### Requirements
- Java 21 and a compatible Node.js/npm installation for local builds.
- Docker Engine or Docker Desktop.
- Docker Compose.
### 1. Build the application
No local Java or Node.js is required — the Dockerfile uses multi-stage build to compile everything inside Docker.
Run from the repository root.
### 1. Build and start the container
From the repository root, run a single command:
```bash
docker compose up -d --build
```
The `--build` flag triggers the multi-stage Docker build that compiles both the Angular frontend and the Spring Boot backend inside Docker.
The default Compose configuration maps host port `8088` to container port `8080`.
Open [http://localhost:8088](http://localhost:8088).
### Alternative: Local Gradle build (requires Java 21 and Node.js)
If you need to build locally (for example, to run tests):
**Linux:**
@@ -119,16 +147,6 @@ cd ..
The configured Gradle build builds the frontend and packages its production assets with the backend.
### 2. Build and start the container
```bash
docker compose up -d --build
```
The default Compose configuration maps host port `8088` to container port `8080`.
Open [http://localhost:8088](http://localhost:8088).
### 3. Inspect logs
```bash
+4
View File
@@ -66,7 +66,11 @@ tasks.register('buildFrontend', Exec) {
}
}
// Skip frontend build tasks when building inside Docker (frontend already built in Stage 1)
def skipFrontend = project.hasProperty('skipFrontend') || System.getenv('SKIP_FRONTEND') != null
tasks.register('copyFrontend', Copy) {
onlyIf { !skipFrontend }
dependsOn 'buildFrontend'
outputs.upToDateWhen { false }
@@ -0,0 +1,40 @@
package com.htopweb.system;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleException(Exception e) {
log.error("Unexpected error: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of(
"error", "Internal server error",
"message", e.getMessage()
));
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<Map<String, Object>> handleRuntimeException(RuntimeException e) {
log.error("Runtime error: {}", e.getMessage(), e);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of(
"error", "Internal server error",
"message", e.getMessage()
));
}
}
@@ -16,18 +16,19 @@ 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_ROOT = Path.of("/host/root");
private static final Path HOST_PROC = HOST_ROOT.resolve("proc");
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 CpuTicks _previousHostCpuTicks;
private final AtomicReference<CpuTicks> _previousHostCpuTicks = new AtomicReference<>();
private static final Logger log =
LoggerFactory.getLogger(SystemMetricsService.class);
@@ -40,16 +41,17 @@ public class SystemMetricsService {
_processor = hardware.getProcessor();
_memory = hardware.getMemory();
_previousHostCpuTicks = readHostCpuTicks();
_previousHostCpuTicks.set(readHostCpuTicks());
}
public synchronized SystemMetricsResponse getMetrics() {
public SystemMetricsResponse getMetrics() {
CpuTicks currentTicks = readHostCpuTicks();
double cpuUsage = 0.0;
if (currentTicks != null && _previousHostCpuTicks != null) {
long totalDelta = currentTicks.total() - _previousHostCpuTicks.total();
long idleDelta = currentTicks.idle() - _previousHostCpuTicks.idle();
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;
@@ -57,7 +59,7 @@ public class SystemMetricsService {
}
if (currentTicks != null) {
_previousHostCpuTicks = currentTicks;
_previousHostCpuTicks.set(currentTicks);
}
Map<String, Long> hostMemory = readHostMemory();
@@ -165,8 +167,8 @@ public class SystemMetricsService {
values.put(key, Long.parseLong(parts[0]) * 1024L);
}
}
} catch (IOException | NumberFormatException ignored) {
// Use OSHI values when host meminfo is unavailable.
} catch (IOException | NumberFormatException e) {
log.debug("Failed to read host memory info: {}", e.getMessage());
}
return values;
@@ -189,8 +191,8 @@ public class SystemMetricsService {
return count;
}
}
} catch (IOException ignored) {
// Fall back to OSHI.
} catch (IOException e) {
log.debug("Failed to read host processor count: {}", e.getMessage());
}
return _processor.getLogicalProcessorCount();
@@ -198,7 +200,7 @@ public class SystemMetricsService {
private String readHostHostname() {
try {
Path file = HOST_ROOT.resolve("etc/hostname");
Path file = HOST_ETC.resolve("hostname");
if (Files.isRegularFile(file)) {
String hostname = Files.readString(file).trim();
@@ -207,8 +209,8 @@ public class SystemMetricsService {
return hostname;
}
}
} catch (IOException ignored) {
// Fall back to OSHI.
} catch (IOException e) {
log.debug("Failed to read host hostname: {}", e.getMessage());
}
return _operatingSystem.getNetworkParams().getHostName();
@@ -218,7 +220,7 @@ public class SystemMetricsService {
Map<String, String> values = new HashMap<>();
try {
Path file = HOST_ROOT.resolve("etc/os-release");
Path file = HOST_ETC.resolve("os-release");
if (!Files.isRegularFile(file)) {
return values;
@@ -242,8 +244,8 @@ public class SystemMetricsService {
values.put(key, value);
}
} catch (IOException ignored) {
// Fall back to OSHI.
} catch (IOException e) {
log.debug("Failed to read host os-release: {}", e.getMessage());
}
return values;
@@ -16,6 +16,8 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class SystemProcessesService {
@@ -23,7 +25,7 @@ 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 HOST_PROC = Path.of("/host/proc");
private static final Path PROC = Path.of("/proc");
private final OperatingSystem _operatingSystem;
@@ -31,10 +33,10 @@ public class SystemProcessesService {
private final long _clockTicksPerSecond;
private final long _pageSizeBytes;
private final Map<Integer, ProcessSnapshot> _previousProcesses =
new HashMap<>();
private final AtomicReference<Map<Integer, ProcessSnapshot>> _previousProcesses =
new AtomicReference<>(new HashMap<>());
private long _previousSampleNanos = System.nanoTime();
private final AtomicLong _previousSampleNanos = new AtomicLong(System.nanoTime());
public SystemProcessesService() {
SystemInfo systemInfo = new SystemInfo();
@@ -54,7 +56,7 @@ public class SystemProcessesService {
);
}
public synchronized List<ProcessInfoResponse> getProcesses() {
public List<ProcessInfoResponse> getProcesses() {
Path procRoot = Files.isDirectory(HOST_PROC) ? HOST_PROC : PROC;
if (!Files.isReadable(procRoot)) {
@@ -63,14 +65,15 @@ public class SystemProcessesService {
}
long nowNanos = System.nanoTime();
long prevNanos = _previousSampleNanos.getAndSet(nowNanos);
double elapsedSeconds =
(nowNanos - _previousSampleNanos) / 1_000_000_000.0;
_previousSampleNanos = nowNanos;
(nowNanos - prevNanos) / 1_000_000_000.0;
long bootTimeSeconds = readBootTime(procRoot);
Map<Integer, ProcessSnapshot> currentProcesses = new HashMap<>();
List<ProcessInfoResponse> result = new ArrayList<>();
Map<Integer, ProcessSnapshot> previousMap = _previousProcesses.get();
try (var entries = Files.list(procRoot)) {
entries.filter(path ->
path.getFileName().toString().matches("\\d+"))
@@ -90,8 +93,7 @@ public class SystemProcessesService {
currentProcesses.put(pid, current);
ProcessSnapshot previous =
_previousProcesses.get(pid);
ProcessSnapshot previous = previousMap.get(pid);
double cpuUsage = 0.0;
@@ -126,8 +128,8 @@ public class SystemProcessesService {
current.threadCount()
));
} catch (IOException | NumberFormatException
| SecurityException ignored) {
// A process may exit or become inaccessible.
| SecurityException e) {
log.debug("Process {} became inaccessible: {}", path.getFileName(), e.getMessage());
}
});
} catch (IOException e) {
@@ -136,8 +138,7 @@ public class SystemProcessesService {
return getProcessesFromOshi();
}
_previousProcesses.clear();
_previousProcesses.putAll(currentProcesses);
_previousProcesses.set(currentProcesses);
result.sort(Comparator.comparingDouble(
ProcessInfoResponse::cpuUsagePercent
@@ -210,8 +211,8 @@ public class SystemProcessesService {
);
}
}
} catch (IOException | NumberFormatException ignored) {
// The process may exit while status is being read.
} catch (IOException | NumberFormatException e) {
log.debug("Failed to read thread count: {}", e.getMessage());
}
return 0;
@@ -224,8 +225,8 @@ public class SystemProcessesService {
return Long.parseLong(line.substring(6).trim());
}
}
} catch (IOException | NumberFormatException ignored) {
// Boot time unavailable.
} catch (IOException | NumberFormatException e) {
log.debug("Failed to read boot time: {}", e.getMessage());
}
return 0L;
@@ -1,61 +1,121 @@
package com.htopweb.system.linux;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
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.List;
import java.util.concurrent.atomic.AtomicReference;
@Service
public class CpuDetailsService {
private final CentralProcessor _processor;
private long[][] _previousCoreTicks;
private long[] _previousSystemTicks;
private static final Path HOST_PROC = Path.of("/host/proc");
private static final Logger log = LoggerFactory.getLogger(CpuDetailsService.class);
private static final String CPU_LINE_PREFIX = "cpu";
private final AtomicReference<CpuTicks[]> _previousCoreTicks = new AtomicReference<>();
public CpuDetailsService() {
SystemInfo systemInfo = new SystemInfo();
_processor = systemInfo.getHardware().getProcessor();
_previousCoreTicks = _processor.getProcessorCpuLoadTicks();
_previousSystemTicks = _processor.getSystemCpuLoadTicks();
_previousCoreTicks.set(readHostCpuTicks());
}
public synchronized CpuDetailsResponse getDetails() {
long[][] currentCoreTicks = _processor.getProcessorCpuLoadTicks();
long[] currentSystemTicks = _processor.getSystemCpuLoadTicks();
public CpuDetailsResponse getDetails() {
CpuTicks[] currentTicks = readHostCpuTicks();
CpuTicks[] previousTicks = _previousCoreTicks.getAndSet(currentTicks);
double[] coreLoads = _processor.getProcessorCpuLoadBetweenTicks(
_previousCoreTicks,
currentCoreTicks
);
List<CpuDetailsResponse.CpuCoreResponse> cores = new ArrayList<>();
double systemLoad = _processor.getSystemCpuLoadBetweenTicks(
_previousSystemTicks,
currentSystemTicks
);
_previousCoreTicks = currentCoreTicks;
_previousSystemTicks = currentSystemTicks;
List<CpuDetailsResponse.CpuCoreResponse> cores =
new ArrayList<>(coreLoads.length);
for (int i = 0; i < coreLoads.length; i++) {
cores.add(new CpuDetailsResponse.CpuCoreResponse(
i,
toPercent(coreLoads[i] * 100.0)
));
if (previousTicks == null || currentTicks == null) {
// First call — return zeros.
if (currentTicks != null) {
for (int i = 0; i < currentTicks.length; i++) {
cores.add(new CpuDetailsResponse.CpuCoreResponse(i, 0.0));
}
}
return new CpuDetailsResponse(0.0, cores.size(), List.copyOf(cores));
}
double totalUsage = 0.0;
int coreCount = Math.min(previousTicks.length, currentTicks.length);
for (int i = 0; i < coreCount; i++) {
CpuTicks curr = currentTicks[i];
CpuTicks prev = previousTicks[i];
long totalDelta = curr.total() - prev.total();
long idleDelta = curr.idle() - prev.idle();
double usage = 0.0;
if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) {
usage = (double) (totalDelta - idleDelta) / totalDelta * 100.0;
}
totalUsage += usage;
cores.add(new CpuDetailsResponse.CpuCoreResponse(i, toPercent(usage)));
}
double avgTotal = coreCount > 0 ? totalUsage / coreCount : 0.0;
return new CpuDetailsResponse(
toPercent(systemLoad * 100.0),
_processor.getLogicalProcessorCount(),
toPercent(avgTotal),
coreCount,
List.copyOf(cores)
);
}
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 new CpuTicks[0];
}
List<CpuTicks> ticksList = new ArrayList<>();
for (String line : Files.readAllLines(statFile)) {
// Match cpu0, cpu1, etc. (not the aggregate "cpu " line).
if (!line.startsWith(CPU_LINE_PREFIX)) {
continue;
}
String[] fields = line.trim().split("\\s+");
// Skip aggregate "cpu" line (no digit after "cpu").
if (fields.length < 2 || !Character.isDigit(fields[0].charAt(3))) {
continue;
}
if (fields.length < 5) {
continue;
}
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]); // iowait
}
ticksList.add(new CpuTicks(total, idle));
}
return ticksList.toArray(new CpuTicks[0]);
} catch (IOException | NumberFormatException e) {
log.warn("Failed to read host CPU statistics: {}", e.getMessage());
return new CpuTicks[0];
}
}
private double toPercent(double value) {
if (!Double.isFinite(value)) {
return 0.0;
@@ -65,4 +125,7 @@ public class CpuDetailsService {
Math.max(0.0, Math.min(100.0, value)) * 10.0
) / 10.0;
}
private record CpuTicks(long total, long idle) {
}
}
@@ -20,9 +20,10 @@ public class LinuxDiskService {
private static final Logger log =
LoggerFactory.getLogger(LinuxDiskService.class);
private static final Path HOST_ROOT = Path.of("/host/root");
private static final Path HOST_PROC = Path.of("/host/proc");
private static final Path HOST_ROOT = Path.of("/host");
private static final Path HOST_MOUNTS =
HOST_ROOT.resolve("proc/1/mounts");
HOST_PROC.resolve("1/mounts");
private static final Set<String> EXCLUDED_TYPES = Set.of(
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs",
@@ -79,6 +80,7 @@ public class LinuxDiskService {
).normalize();
if (!accessiblePath.startsWith(HOST_ROOT)
|| accessiblePath.equals(HOST_ROOT)
|| !Files.exists(accessiblePath)) {
log.warn("Host mount path is unavailable: {}", accessiblePath);
return null;
@@ -1,34 +1,34 @@
package com.htopweb.system.linux;
import oshi.SystemInfo;
import oshi.hardware.GlobalMemory;
import oshi.hardware.VirtualMemory;
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.HashMap;
import java.util.Map;
@Service
public class LinuxMemoryService {
private final GlobalMemory _memory;
private final VirtualMemory _virtualMemory;
private static final Path HOST_PROC = Path.of("/host/proc");
private static final Logger log = LoggerFactory.getLogger(LinuxMemoryService.class);
public LinuxMemoryService() {
SystemInfo systemInfo = new SystemInfo();
_memory = systemInfo.getHardware().getMemory();
_virtualMemory = _memory.getVirtualMemory();
}
public LinuxMemoryResponse getDetails() {
long totalRam = Math.max(0L, _memory.getTotal());
long availableRam = Math.max(0L, Math.min(totalRam, _memory.getAvailable()));
Map<String, Long> meminfo = readHostMeminfo();
Map<String, Long> swaps = readHostSwaps();
long totalRam = meminfo.getOrDefault("MemTotal", 0L);
long availableRam = meminfo.getOrDefault("MemAvailable", 0L);
long usedRam = Math.max(0L, totalRam - availableRam);
long totalSwap = Math.max(0L, _virtualMemory.getSwapTotal());
long usedSwap = Math.max(
0L,
Math.min(totalSwap, _virtualMemory.getSwapUsed())
);
long totalSwap = swaps.getOrDefault("total", 0L);
long usedSwap = swaps.getOrDefault("used", 0L);
long freeSwap = Math.max(0L, totalSwap - usedSwap);
return new LinuxMemoryResponse(
@@ -43,6 +43,81 @@ public class LinuxMemoryService {
);
}
private Map<String, Long> readHostMeminfo() {
Map<String, Long> values = new HashMap<>();
Path meminfoFile = HOST_PROC.resolve("meminfo");
try {
if (!Files.isRegularFile(meminfoFile)) {
log.warn("Host meminfo is unavailable: {}", 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.warn("Failed to read host meminfo: {}", e.getMessage());
}
return values;
}
private Map<String, Long> readHostSwaps() {
Map<String, Long> result = new HashMap<>();
result.put("total", 0L);
result.put("used", 0L);
Path swapsFile = HOST_PROC.resolve("swaps");
try {
if (!Files.isRegularFile(swapsFile)) {
return result;
}
long totalSwap = 0L;
long usedSwap = 0L;
for (String line : Files.readAllLines(swapsFile)) {
line = line.trim();
if (line.isEmpty() || line.startsWith("Filename")) {
continue;
}
String[] parts = line.split("\\s+");
// Format: Filename Type Size Used Priority
if (parts.length >= 4) {
// Size and Used columns are in kB.
long size = Long.parseLong(parts[2]) * 1024L;
long used = Long.parseLong(parts[3]) * 1024L;
totalSwap += size;
usedSwap += used;
}
}
result.put("total", totalSwap);
result.put("used", usedSwap);
} catch (IOException | NumberFormatException e) {
log.warn("Failed to read host swaps: {}", e.getMessage());
}
return result;
}
private double percentage(long used, long total) {
if (total <= 0) {
return 0.0;
@@ -21,13 +21,14 @@ public class LinuxNetworkService {
private static final Logger log =
LoggerFactory.getLogger(LinuxNetworkService.class);
private static final Path HOST_ROOT = Path.of("/host/root");
private static final Path HOST_PROC = Path.of("/host/proc");
private static final Path HOST_SYS = Path.of("/host/sys");
private static final Path HOST_NET_DEV =
HOST_ROOT.resolve("proc/1/net/dev");
HOST_PROC.resolve("1/net/dev");
private static final Path HOST_NET_CLASS =
HOST_ROOT.resolve("sys/class/net");
HOST_SYS.resolve("class/net");
private static final Set<String> EXCLUDED_INTERFACES =
Set.of("lo", "docker0");
+1 -1
View File
@@ -9,5 +9,5 @@
<link rel="stylesheet" href="styles-5INURTSO.css"></head>
<body>
<app-root></app-root>
<script src="main-WRDGP7C5.js" type="module"></script></body>
<script src="main-KCY2LTAZ.js" type="module"></script></body>
</html>
@@ -0,0 +1,147 @@
package com.htopweb.system;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class SystemMetricsServiceTest {
@TempDir
Path tempDir;
private Path hostProc;
private Path hostRoot;
@BeforeEach
void setUp() throws IOException {
hostRoot = tempDir.resolve("host-root");
hostProc = hostRoot.resolve("proc");
Files.createDirectories(hostProc);
}
@Test
void readHostCpuTicks_parsesAggregateLine() throws IOException {
// "cpu user nice system idle iowait irq softirq steal guest guest_nice"
String stat = "cpu 1000 200 300 5000 100 10 20 30 0 0\n";
stat += "cpu0 500 100 150 2500 50 5 10 15 0 0\n";
Files.writeString(hostProc.resolve("stat"), stat);
// Verify the parsing logic with sample data
assertDoesNotThrow(() -> {
String[] fields = "cpu 1000 200 300 5000 100 10 20 30 0 0".trim().split("\\s+");
long total = 0;
for (int i = 1; i < fields.length; i++) {
total += Long.parseLong(fields[i]);
}
long idle = Long.parseLong(fields[4]); // 5000
if (fields.length > 5) {
idle += Long.parseLong(fields[5]); // +100 iowait
}
// 1000+200+300+5000+100+10+20+30+0+0 = 6660
assertEquals(6660, total);
// 5000 + 100 = 5100
assertEquals(5100, idle);
});
}
@Test
void readHostMemory_parsesMeminfo() throws IOException {
String meminfo = "MemTotal: 16384000 kB\n"
+ "MemFree: 4096000 kB\n"
+ "MemAvailable: 8192000 kB\n"
+ "Buffers: 512000 kB\n"
+ "Cached: 2048000 kB\n";
Files.writeString(hostProc.resolve("meminfo"), meminfo);
assertDoesNotThrow(() -> {
Map<String, Long> values = new java.util.HashMap<>();
for (String line : Files.readAllLines(hostProc.resolve("meminfo"))) {
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) {
values.put(key, Long.parseLong(parts[0]) * 1024L);
}
}
assertEquals(16384000L * 1024, values.get("MemTotal"));
assertEquals(8192000L * 1024, values.get("MemAvailable"));
});
}
@Test
void readHostMemory_returnsEmptyOnMissingFile() {
Map<String, Long> values = new java.util.HashMap<>();
Path missingFile = hostProc.resolve("nonexistent");
assertFalse(Files.isRegularFile(missingFile));
// Empty map is returned when file doesn't exist
assertTrue(values.isEmpty());
}
@Test
void getLogicalProcessorCount_parsesCpuLines() throws IOException {
String stat = "cpu 1000 200 300 5000 100 10 20 30 0 0\n"
+ "cpu0 500 100 150 2500 50 5 10 15 0 0\n"
+ "cpu1 500 100 150 2500 50 5 10 15 0 0\n"
+ "cpu2 500 100 150 2500 50 5 10 15 0 0\n"
+ "cpu3 500 100 150 2500 50 5 10 15 0 0\n";
Files.writeString(hostProc.resolve("stat"), stat);
int count = 0;
for (String line : Files.readAllLines(hostProc.resolve("stat"))) {
if (line.matches("^cpu\\d+\\s+.*")) {
count++;
}
}
assertEquals(4, count);
}
@Test
void readHostOsRelease_parsesKeyValuePairs() throws IOException {
Path etcDir = hostRoot.resolve("etc");
Files.createDirectories(etcDir);
String osRelease = "NAME=\"Ubuntu\"\n"
+ "VERSION=\"22.04.3 LTS (Jammy Jellyfish)\"\n"
+ "ID=ubuntu\n"
+ "VERSION_ID=\"22.04\"\n";
Files.writeString(etcDir.resolve("os-release"), osRelease);
assertDoesNotThrow(() -> {
Map<String, String> values = new java.util.HashMap<>();
for (String line : Files.readAllLines(etcDir.resolve("os-release"))) {
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);
}
assertEquals("Ubuntu", values.get("NAME"));
assertEquals("22.04.3 LTS (Jammy Jellyfish)", values.get("VERSION"));
assertEquals("22.04", values.get("VERSION_ID"));
});
}
@Test
void readHostHostname_returnsHostname() throws IOException {
Path etcDir = hostRoot.resolve("etc");
Files.createDirectories(etcDir);
Files.writeString(etcDir.resolve("hostname"), "myserver\n");
String hostname = Files.readString(etcDir.resolve("hostname")).trim();
assertEquals("myserver", hostname);
}
}
@@ -0,0 +1,152 @@
package com.htopweb.system;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class SystemProcessesServiceTest {
@Test
void parseProcessStat_lineWithParentheses() throws IOException {
// Simulate a /proc/[pid]/stat line
// pid (comm) state ppid pgrp session tty_nr tpgid flags minflt cminflt majflt cmajflt
// utime stime cutime cstime priority nice num_threads itrealvalue starttime vsize rss ...
String stat = "1234 (myprocess) S 1 1234 1234 0 -1 4194304 100 0 0 0 500 300 0 0 20 0 1 0 50 1048576 256 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0";
int open = stat.indexOf('(');
int close = stat.lastIndexOf(')');
assertTrue(open >= 0);
assertTrue(close > open);
String name = stat.substring(open + 1, close);
assertEquals("myprocess", name);
String[] fields = stat.substring(close + 2).trim().split("\\s+");
// fields[0] = state (field 3)
// fields[11] = utime (field 14)
// fields[12] = stime (field 15)
// fields[19] = starttime (field 22)
// fields[20] = vsize (field 23)
// fields[21] = rss (field 24)
assertTrue(fields.length >= 22);
assertEquals("S", fields[0]);
assertEquals(500, Long.parseLong(fields[11]));
assertEquals(300, Long.parseLong(fields[12]));
}
@Test
void calculateCpuUsage_deltaTicks() {
long prevTicks = 1000L;
long currTicks = 1500L;
long clockTicksPerSecond = 100L;
double elapsedSeconds = 5.0;
int logicalProcessorCount = 4;
long deltaTicks = currTicks - prevTicks;
double cpuUsage = deltaTicks
/ (double) clockTicksPerSecond
/ elapsedSeconds
* 100.0
/ logicalProcessorCount;
// 500 ticks / 100 Hz / 5s * 100 / 4 cores = 25%
assertEquals(25.0, cpuUsage, 0.01);
}
@Test
void calculateCpuUsage_clampedTo100() {
double rawUsage = 150.0;
double clamped = Math.max(0.0, Math.min(100.0, rawUsage));
assertEquals(100.0, clamped, 0.01);
}
@Test
void calculateCpuUsage_negativeDeltaIgnored() {
long prevTicks = 2000L;
long currTicks = 1500L;
long deltaTicks = currTicks - prevTicks;
// Negative delta should be ignored
if (deltaTicks >= 0) {
fail("Delta should be negative");
}
}
@Test
void parseBootTime_fromStatFile() throws IOException {
Path tempDir = Files.createTempDirectory("test");
String stat = "btime 1700000000\n"
+ "procs 245\n";
Files.writeString(tempDir.resolve("stat"), stat);
long bootTime = 0L;
for (String line : Files.readAllLines(tempDir.resolve("stat"))) {
if (line.startsWith("btime ")) {
bootTime = Long.parseLong(line.substring(6).trim());
}
}
assertEquals(1700000000L, bootTime);
// Cleanup
Files.delete(tempDir.resolve("stat"));
Files.delete(tempDir);
}
@Test
void parseThreadCount_fromStatusFile() throws IOException {
Path tempDir = Files.createTempDirectory("test");
String status = "Name:\tmyprocess\n"
+ "Umask:\t0022\n"
+ "State:\tS (sleeping)\n"
+ "Tgid:\t1234\n"
+ "Ngid:\t0\n"
+ "Pid:\t1234\n"
+ "PPid:\t1\n"
+ "Threads:\t8\n";
Files.writeString(tempDir.resolve("status"), status);
int threadCount = 0;
for (String line : Files.readAllLines(tempDir.resolve("status"))) {
if (line.startsWith("Threads:")) {
threadCount = Integer.parseInt(
line.substring("Threads:".length()).trim()
);
}
}
assertEquals(8, threadCount);
// Cleanup
Files.delete(tempDir.resolve("status"));
Files.delete(tempDir);
}
@Test
void processSnapshot_calculatesUptime() {
long bootTimeSeconds = 1700000000L;
long startTicks = 50000L;
long clockTicksPerSecond = 100L;
long currentTimeSeconds = 1700010000L; // 10000 seconds after epoch
long processStartSeconds = bootTimeSeconds + startTicks / clockTicksPerSecond;
long uptimeSeconds = Math.max(0L, currentTimeSeconds - processStartSeconds);
// 1700000000 + 500 = 1700000500
// 1700010000 - 1700000500 = 9500
assertEquals(9500L, uptimeSeconds);
}
@Test
void processSnapshot_residentMemoryInBytes() {
long residentPages = 256L;
long pageSizeBytes = 4096L;
long residentBytes = residentPages * pageSizeBytes;
assertEquals(1048576L, residentBytes); // 1 MB
}
}
@@ -0,0 +1,166 @@
package com.htopweb.system.linux;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class CpuDetailsServiceTest {
@Test
void parseCpuLines_skipsAggregateLine() throws IOException {
Path tempDir = Files.createTempDirectory("test");
String stat = "cpu 1000 200 300 5000 100 10 20 30 0 0\n"
+ "cpu0 500 100 150 2500 50 5 10 15 0 0\n"
+ "cpu1 500 100 150 2500 50 5 10 15 0 0\n"
+ "cpu2 500 100 150 2500 50 5 10 15 0 0\n"
+ "cpu3 500 100 150 2500 50 5 10 15 0 0\n";
Files.writeString(tempDir.resolve("stat"), stat);
List<String> coreLines = new ArrayList<>();
for (String line : Files.readAllLines(tempDir.resolve("stat"))) {
if (!line.startsWith("cpu")) continue;
String[] fields = line.trim().split("\\s+");
// Skip aggregate "cpu" line: fields[0] is "cpu" (length 3, no digit at index 3)
if (fields.length < 2 || fields[0].length() <= 3
|| !Character.isDigit(fields[0].charAt(3))) {
continue;
}
coreLines.add(fields[0]);
}
assertEquals(4, coreLines.size());
assertEquals("cpu0", coreLines.get(0));
assertEquals("cpu1", coreLines.get(1));
assertEquals("cpu2", coreLines.get(2));
assertEquals("cpu3", coreLines.get(3));
// Cleanup
Files.delete(tempDir.resolve("stat"));
Files.delete(tempDir);
}
@Test
void calculateCpuCoreUsage_fromTicks() {
// Previous ticks
long prevTotal = 10000L;
long prevIdle = 8000L;
// Current ticks
long currTotal = 20000L;
long currIdle = 15000L;
long totalDelta = currTotal - prevTotal; // 10000
long idleDelta = currIdle - prevIdle; // 7000
double usage = (double) (totalDelta - idleDelta) / totalDelta * 100.0;
// (10000 - 7000) / 10000 * 100 = 30%
assertEquals(30.0, usage, 0.01);
}
@Test
void calculateCpuCoreUsage_zeroDelta() {
long prevTotal = 10000L;
long currTotal = 10000L;
long totalDelta = currTotal - prevTotal;
// When totalDelta is 0, usage should be 0
double usage = 0.0;
if (totalDelta > 0) {
usage = 1.0; // not reached
}
assertEquals(0.0, usage, 0.01);
}
@Test
void calculateCpuCoreUsage_withIowait() {
// fields[4] = idle, fields[5] = iowait
long idle = 5000L;
long iowait = 500L;
long totalIdle = idle + iowait; // 5500
long total = 10000L;
double usage = (double) (total - totalIdle) / total * 100.0;
// (10000 - 5500) / 10000 * 100 = 45%
assertEquals(45.0, usage, 0.01);
}
@Test
void calculateAverageCpuLoad() {
double[] coreUsages = {25.0, 50.0, 75.0, 100.0};
double total = 0.0;
for (double usage : coreUsages) {
total += usage;
}
double avg = total / coreUsages.length;
assertEquals(62.5, avg, 0.01);
}
@Test
void toPercent_clampsValue() {
// Simulate the toPercent method
double value = 150.0;
if (!Double.isFinite(value)) {
value = 0.0;
}
double clamped = Math.round(Math.max(0.0, Math.min(100.0, value)) * 10.0) / 10.0;
assertEquals(100.0, clamped, 0.01);
}
@Test
void toPercent_handlesNaN() {
double value = Double.NaN;
if (!Double.isFinite(value)) {
value = 0.0;
}
assertEquals(0.0, value, 0.01);
}
@Test
void toPercent_handlesInfinity() {
double value = Double.POSITIVE_INFINITY;
if (!Double.isFinite(value)) {
value = 0.0;
}
assertEquals(0.0, value, 0.01);
}
@Test
void toPercent_roundsToOneDecimal() {
double value = 33.333;
double result = Math.round(Math.max(0.0, Math.min(100.0, value)) * 10.0) / 10.0;
assertEquals(33.3, result, 0.01);
}
@Test
void parseCpuTicks_fromStatLine() throws IOException {
Path tempDir = Files.createTempDirectory("test");
String stat = "cpu0 500 100 150 2500 50 5 10 15 0 0\n";
Files.writeString(tempDir.resolve("stat"), stat);
for (String line : Files.readAllLines(tempDir.resolve("stat"))) {
String[] fields = line.trim().split("\\s+");
long total = 0;
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]);
}
// 500+100+150+2500+50+5+10+15+0+0 = 3330
assertEquals(3330, total);
// idle = 2500 + 50 = 2550
assertEquals(2550, idle);
}
// Cleanup
Files.delete(tempDir.resolve("stat"));
Files.delete(tempDir);
}
}
@@ -0,0 +1,140 @@
package com.htopweb.system.linux;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class LinuxMemoryServiceTest {
@Test
void parseMeminfo_convertsKbToBytes() throws IOException {
Path tempDir = Files.createTempDirectory("test");
String meminfo = "MemTotal: 16384000 kB\n"
+ "MemFree: 4096000 kB\n"
+ "MemAvailable: 8192000 kB\n"
+ "Buffers: 512000 kB\n"
+ "Cached: 2048000 kB\n";
Files.writeString(tempDir.resolve("meminfo"), meminfo);
Map<String, Long> values = new HashMap<>();
for (String line : Files.readAllLines(tempDir.resolve("meminfo"))) {
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) {
values.put(key, Long.parseLong(parts[0]) * 1024L);
}
}
assertEquals(16384000L * 1024, values.get("MemTotal"));
assertEquals(8192000L * 1024, values.get("MemAvailable"));
// Cleanup
Files.delete(tempDir.resolve("meminfo"));
Files.delete(tempDir);
}
@Test
void parseSwaps_fileWithMultipleEntries() throws IOException {
Path tempDir = Files.createTempDirectory("test");
// /proc/swaps format: Filename Type Size Used Priority (whitespace-separated)
String swaps = "Filename\tType\tSize\tUsed\tPriority\n"
+ "/dev/sda2\tpartition\t2097148\t0\t-2\n"
+ "/swapfile\tfile\t1048572\t524286\t-3\n";
Files.writeString(tempDir.resolve("swaps"), swaps);
long totalSwap = 0L;
long usedSwap = 0L;
for (String line : Files.readAllLines(tempDir.resolve("swaps"))) {
line = line.trim();
if (line.isEmpty() || line.startsWith("Filename")) continue;
String[] parts = line.split("\\s+");
// parts[0]=Filename, parts[1]=Type, parts[2]=Size, parts[3]=Used, parts[4]=Priority
if (parts.length >= 4) {
long size = Long.parseLong(parts[2]) * 1024L;
long used = Long.parseLong(parts[3]) * 1024L;
totalSwap += size;
usedSwap += used;
}
}
// 2097148 + 1048572 = 3145720 kB = 3145720 * 1024 bytes
assertEquals(3145720L * 1024, totalSwap);
// 0 + 524286 = 524286 kB = 524286 * 1024 bytes
assertEquals(524286L * 1024, usedSwap);
// Cleanup
Files.delete(tempDir.resolve("swaps"));
Files.delete(tempDir);
}
@Test
void parseSwaps_emptyFile() throws IOException {
Path tempDir = Files.createTempDirectory("test");
String swaps = "Filename\tType\tSize\tUsed\tPriority\n";
Files.writeString(tempDir.resolve("swaps"), swaps);
long totalSwap = 0L;
long usedSwap = 0L;
for (String line : Files.readAllLines(tempDir.resolve("swaps"))) {
line = line.trim();
if (line.isEmpty() || line.startsWith("Filename")) continue;
String[] parts = line.split("\\s+");
if (parts.length >= 4) {
totalSwap += Long.parseLong(parts[2]) * 1024L;
usedSwap += Long.parseLong(parts[3]) * 1024L;
}
}
assertEquals(0L, totalSwap);
assertEquals(0L, usedSwap);
// Cleanup
Files.delete(tempDir.resolve("swaps"));
Files.delete(tempDir);
}
@Test
void calculateMemoryPercentage() {
long used = 8192000L * 1024; // 8 GB
long total = 16384000L * 1024; // 16 GB
double percent = Math.round((double) used / total * 1000.0) / 10.0;
assertEquals(50.0, percent, 0.01);
}
@Test
void calculateMemoryPercentage_zeroTotal() {
long used = 0L;
long total = 0L;
if (total <= 0) {
// Should return 0.0
assertEquals(0.0, 0.0, 0.01);
}
}
@Test
void calculateSwapFree() {
long totalSwap = 2097152L * 1024;
long usedSwap = 524288L * 1024;
long freeSwap = Math.max(0L, totalSwap - usedSwap);
assertEquals(1572864L * 1024, freeSwap);
}
@Test
void calculateUsedMemory() {
long totalRam = 16384000L * 1024;
long availableRam = 8192000L * 1024;
long usedRam = Math.max(0L, totalRam - availableRam);
assertEquals(8192000L * 1024, usedRam);
}
}
+14 -1
View File
@@ -10,9 +10,22 @@ services:
# ports:
# - "8088:8000"
pid: host
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
- /etc/hostname:/host/etc/hostname:ro
- /etc/os-release:/host/etc/os-release:ro
- /var:/host/var:ro
- /home:/host/home:ro
- /opt:/host/opt:ro
- /tmp:/host/tmp:ro
- /srv:/host/srv:ro
- /usr:/host/usr:ro
labels:
caddy: htop-web.jambotron.com
caddy.reverse_proxy: "{{upstreams 8080}}"
+15 -1
View File
@@ -35,7 +35,21 @@ Controllers expose system information to the frontend, while service classes col
## System information
OSHI is used to obtain operating-system and hardware information. The data available depends on the runtime environment and permissions.
The application collects system metrics through two complementary approaches:
1. **Direct `/proc` parsing** — Services read host files directly from mounted paths (`/host/proc`, `/host/sys`, `/host/etc`) to obtain accurate host metrics rather than container metrics.
2. **OSHI fallback** — If mounted files are unavailable, OSHI provides a fallback source of system information.
### Host filesystem access
To minimize the attack surface, the container mounts specific host directories rather than the entire root filesystem:
- `/proc` → `/host/proc` — CPU, memory, processes, network, disk mounts
- `/sys` → `/host/sys` — Network interface details
- `/etc/hostname` and `/etc/os-release` → `/host/etc/` — Host identification
- Selected mount points (`/var`, `/home`, `/opt`, etc.) → `/host/` — Disk space information
See `docs/CONFIGURATION.md` for the complete list of volume mounts and instructions for adding custom mount points.
## Packaging
+36
View File
@@ -23,6 +23,42 @@ Review the actual configuration file before changing application properties. Thi
System metrics available to the application depend on the operating system, container runtime, mounted filesystems, and granted permissions.
### Volume mounts
The application mounts specific host directories rather than the entire root filesystem, to minimize the attack surface:
| Host path | Container path | Purpose |
|---|---|---|
| `/proc` | `/host/proc` | CPU, memory, processes, network, disk mounts |
| `/sys` | `/host/sys` | Network interface details |
| `/etc/hostname` | `/host/etc/hostname` | Host name |
| `/etc/os-release` | `/host/etc/os-release` | OS information |
| `/var` | `/host/var` | Disk space for `/var` mounts |
| `/home` | `/host/home` | Disk space for `/home` mounts |
| `/opt` | `/host/opt` | Disk space for `/opt` mounts |
| `/tmp` | `/host/tmp` | Disk space for `/tmp` mounts |
| `/srv` | `/host/srv` | Disk space for `/srv` mounts |
| `/usr` | `/host/usr` | Disk space for `/usr` mounts |
If your system has additional mount points (e.g., `/data`, `/mnt`), add them to the `volumes` section in `docker-compose.yml`:
```yaml
volumes:
- /data:/host/data:ro
```
### Security hardening
The container runs with these security settings:
- `read_only: true` — container filesystem is read-only
- `no-new-privileges:true` — prevents privilege escalation
- `tmpfs` on `/tmp` — temporary storage in memory
- Non-root user (`appuser`) — application does not run as root
- `pid: host` — required to see host processes
### Docker Desktop limitation
A container does not automatically have complete visibility into its host. Docker Desktop on Windows runs Linux containers in a virtualized Linux environment, so reported metrics may describe that environment rather than Windows itself.
Do not expose the monitoring interface to untrusted networks without appropriate access controls and network security.
+100 -1
View File
@@ -8,6 +8,8 @@
## Build and test
### Option 1: Gradle build (requires Java 21 and Node.js)
From the repository root:
```bash
@@ -17,6 +19,21 @@ cd backend
This invokes the configured Gradle build, including frontend build tasks.
### Option 2: Docker multi-stage build (requires only Docker)
Build the entire application inside Docker — no local Java or Node.js required:
```bash
docker build -t htop-web .
```
This uses a multi-stage Dockerfile with three stages:
1. **Frontend** — builds Angular with Node.js 22 Alpine
2. **Builder** — compiles the backend with Gradle and Java 21 JDK
3. **Runtime** — packages only the JAR and Java 21 JRE (~200 MB final image)
The Docker build is fully reproducible and does not depend on tools installed on the host machine.
## Frontend development
Frontend source code is located in `frontend/`.
@@ -38,13 +55,95 @@ If the project does not define a `start` script, inspect `frontend/package.json`
## Run the container
From the repository root, after a successful build:
### With Docker Compose (recommended)
From the repository root:
```bash
docker compose up -d --build
docker compose logs -f htop-web
```
The `--build` flag triggers the multi-stage Docker build automatically.
### With Docker only
If you built the image manually:
```bash
docker run -d \
--name htop-web \
--pid host \
--read-only \
--security-opt no-new-privileges:true \
--tmpfs /tmp:noexec,nosuid,size=64m \
-v /proc:/host/proc:ro \
-v /sys:/host/sys:ro \
-v /etc/hostname:/host/etc/hostname:ro \
-v /etc/os-release:/host/etc/os-release:ro \
-v /var:/host/var:ro \
-v /home:/host/home:ro \
-v /opt:/host/opt:ro \
-v /tmp:/host/tmp:ro \
-v /srv:/host/srv:ro \
-v /usr:/host/usr:ro \
-p 8080:8080 \
htop-web
```
## Release process
### Prerequisites
- Gitea Actions runner must be configured and running.
- Repository secrets `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` must be set in **Settings → Secrets** of the Gitea repository. These credentials are used to authenticate with the Gitea Container Registry.
### Creating a release
1. **Update `CHANGELOG.md`** with changes for the new version.
2. **Commit and push** your changes:
```bash
git add -A
git commit -m "Prepare release v0.2.0"
git push origin master
```
3. **Create and push a Git tag** with Semantic Versioning:
```bash
git tag -a v0.2.0 -m "Release v0.2.0: new features and fixes"
git push origin v0.2.0
```
4. **Gitea Actions** will automatically:
- Build the Docker image using the multi-stage Dockerfile
- Tag it with the version (`0.2.0`), minor version (`0.2`), and `latest`
- Push all three tags to the Gitea Container Registry
### Docker image tags
Each release produces three Docker image tags:
| Tag | Example | Description |
|-----|---------|-------------|
| Full version | `gitea.jambotron.com/liosha84/htop-web:0.2.0` | Exact version, use in production |
| Minor version | `gitea.jambotron.com/liosha84/htop-web:0.2` | Latest patch of 0.2.x |
| Latest | `gitea.jambotron.com/liosha84/htop-web:latest` | Most recent stable release |
### Using a specific version in production
In `docker-compose.yml`, always pin to a specific version:
```yaml
services:
htop-web:
image: gitea.jambotron.com/liosha84/htop-web:0.2.0
```
Avoid using `:latest` in production — it makes rollbacks impossible and breaks reproducibility.
## Contribution guidelines
- Keep changes focused and reviewable.
+42 -15
View File
@@ -4,11 +4,35 @@
- Docker Engine
- Docker Compose
- Java 21 and Node.js/npm if building the application locally
## Build the application
No local Java or Node.js installation is required — the Dockerfile uses multi-stage build to compile everything inside Docker.
From the repository root:
## Build and start with Docker Compose
From the repository root, run a single command:
```bash
docker compose up -d --build
```
The `--build` flag triggers the multi-stage Docker build that compiles both the Angular frontend and the Spring Boot backend inside Docker. No local JDK or Node.js is needed.
The default Compose configuration maps host port `8088` to container port `8080`.
Open http://localhost:8088 in your browser.
## Alternative: Build the image manually
If you prefer to build the Docker image separately:
```bash
docker build -t htop-web .
docker compose up -d
```
## Alternative: Local Gradle build (requires Java 21 and Node.js)
If you need to build locally (for example, to run tests):
```bash
cd backend
@@ -18,16 +42,6 @@ cd ..
The Gradle build also builds the Angular frontend and packages its production assets into the Spring Boot application.
## Start with Docker Compose
```bash
docker compose up -d --build
```
The default Compose configuration maps host port `8088` to container port `8080`.
Open http://localhost:8088 in your browser.
## Check status and logs
```bash
@@ -43,16 +57,29 @@ docker compose down
## Simple Scenario of using
```bash
```yaml
services:
htop-web:
image: gitea.jambotron.com/liosha84/htop-web:latest
container_name: htop-web
restart: unless-stopped
pid: host
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/host/root:ro
- /etc/hostname:/host/etc/hostname:ro
- /etc/os-release:/host/etc/os-release:ro
- /var:/host/var:ro
- /home:/host/home:ro
- /opt:/host/opt:ro
- /tmp:/host/tmp:ro
- /srv:/host/srv:ro
- /usr:/host/usr:ro
labels:
caddy: htop-web.jambotron.com
caddy.reverse_proxy: "{{upstreams 8080}}"
@@ -18,7 +18,7 @@ export class SystemMetricsService {
private readonly refreshInterval = 1000;
readonly metrics$: Observable<SystemMetrics> = timer(0, this.refreshInterval).pipe(
readonly metrics$: Observable<SystemMetrics | null> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<SystemMetrics>('/api/system/metrics').pipe(
catchError((error: unknown) => {
@@ -27,12 +27,7 @@ export class SystemMetricsService {
}),
),
),
// Ignore failed requests without terminating the stream.
map((value) => value),
// Keep the last successful response for subscribers.
shareReplay({ bufferSize: 1, refCount: true }),
// The public type remains SystemMetrics below via the typed getter.
map((value) => value as SystemMetrics),
);
readonly processes$: Observable<SystemProcess[]> = timer(0, this.refreshInterval).pipe(