28 Commits
Author SHA1 Message Date
Liosha84 46cd7c39c0 Merge remote-tracking branch 'origin/master'
continuous-integration/drone/push Build is passing
2026-11-04 00:54:10 +01:00
Liosha84 6b44066843 Added ARTICLE.md 2026-11-04 00:53:46 +01:00
Liosha84 ffc1026ce1 Merge pull request 'fix(drone): use 'master' branch instead of 'main' for build-and-push trigger' (#6) from develop into master
continuous-integration/drone/push Build is passing
Reviewed-on: #6
2026-10-29 01:14:03 +01:00
Liosha84andJunie 0680557886 fix(drone): use 'master' branch instead of 'main' for build-and-push trigger
continuous-integration/drone/pr Build is failing
Co-authored-by: Junie <junie@jetbrains.com>
2026-10-29 01:13:10 +01:00
Liosha84 ea453bd409 Merge pull request 'fix(docker): use correct Debian adduser syntax for non-interactive user creation' (#5) from develop into master
Reviewed-on: #5
2026-10-29 01:06:38 +01:00
Liosha84 3c85936c73 fix(docker): use correct Debian adduser syntax for non-interactive user creation
continuous-integration/drone/pr Build is failing
2026-10-29 00:59:44 +01:00
Liosha84 6666fca194 Merge pull request 'fix(ci): split drone into two separate pipelines for PR and main' (#4) from develop into master
Reviewed-on: #4
2026-10-29 00:56:56 +01:00
Liosha84 9d03b0977d fix(ci): split drone into two separate pipelines for PR and main
continuous-integration/drone/pr Build is failing
2026-10-29 00:55:51 +01:00
Liosha84 3a0c22abf0 Merge pull request 'Develop' (#3) from develop into master
continuous-integration/drone/push Build is failing
Reviewed-on: #3
2026-10-29 00:52:19 +01:00
Liosha84 ed48cacfbc Revert "chore: translate README.md to Russian"
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
This reverts commit 3f9ecc1113.
2026-10-29 00:50:55 +01:00
Liosha84 3f9ecc1113 chore: translate README.md to Russian
continuous-integration/drone/push Build is failing
2026-10-29 00:48:43 +01:00
Liosha84 eb73c55ac2 fix(drone): use full commit SHA for Docker tag and fix non-interactive adduser
continuous-integration/drone/push Build is failing
- Replace bash substring syntax ${DRONE_COMMIT_SHA:0:7} with
  ${DRONE_COMMIT_SHA} which is supported by Drone YAML variables
- Use 'adduser -D -g ""' in Dockerfile to skip interactive prompts
  for user information during Docker image build
2026-10-29 00:47:28 +01:00
Liosha84 8369b2a6ba Merge pull request 'Develop->main' (#2) from develop into master
continuous-integration/drone/push Build is failing
Reviewed-on: #2
2026-10-29 00:43:36 +01:00
Liosha84 d4a6726930 feat: automatic releases on merge to main
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
- Drone now triggers on push to main branch instead of Git tags
- Each merge produces two Docker tags: main-{commit-sha} and main-latest
- No manual Git tags needed — merging PR develop → main is the release
- Updated docs/DEVELOPMENT.md with new Git-flow workflow
- Updated CHANGELOG.md with automatic releases feature
2026-10-29 00:39:31 +01:00
Liosha84andJunie 0d6e0fa565 fix(docker): skip frontend Gradle tasks when SKIP_FRONTEND is set
Add onlyIf predicate to installFrontendDependencies and buildFrontend tasks
so they are skipped when SKIP_FRONTEND env var is present. This fixes the
Docker multi-stage build which failed because Gradle validated input files
(package.json, package-lock.json) that don't exist in the builder stage.

Also moved the skipFrontend variable definition before task registration
for cleaner code organization.

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-29 00:25:37 +01:00
Liosha84 3e78dcff0f Merge pull request 'feat(drone): add pull request trigger for CI/CD testing' (#1) from develop into master
Reviewed-on: #1
2026-10-29 00:20:20 +01:00
Liosha84andJunie 291167e683 feat(drone): add pull request trigger for CI/CD testing
continuous-integration/drone/pr Build is failing
Co-authored-by: Junie <junie@jetbrains.com>
2026-10-29 00:19:31 +01:00
Liosha84 42f606610a remake of REST Controllers 2026-10-29 00:11:08 +01:00
Liosha84andJunie dc8c6b0dc6 Release v0.3.0: migrate CI/CD to Drone
Co-authored-by: Junie <junie@jetbrains.com>
2026-10-29 00:02:14 +01:00
Liosha84andJunie 7f2ea4f3b4 Fix Drone CI/CD: use from_secret instead of from for secrets
The correct Drone syntax for referencing secrets is 'from_secret:',
not 'from:'. Without this fix, Drone cannot read the registry
credentials and the docker login step will fail with 401 Unauthorized.

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-29 00:00:17 +01:00
Liosha84andJunie 923b1e7c57 Migrate CI/CD from Gitea Actions to Drone
Replace .gitea/workflows/docker.yml with .drone.yml using the Drone
Docker plugin with auto_tag enabled. On Git tag pushes (v0.2.0),
Drone automatically creates Docker image tags: full version (0.2.0),
minor version (0.2), and latest.

Updated docs/DEVELOPMENT.md to document Drone CI/CD setup instead
of Gitea Actions. Updated CHANGELOG.md to reflect the Drone migration.

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-28 23:57:30 +01:00
Liosha84andJunie ee263d6224 Fix: use GITHUB_OUTPUT instead of GITEA_OUTPUT in CI/CD workflow
Docker Build and Push / build-and-push (push) Canceled after 0s
Gitea Actions uses GITHUB_OUTPUT to set step outputs, not GITEA_OUTPUT.
This was causing version and minor variables to be empty, so only the
'latest' tag was pushed to the registry. Added debug logging to help
diagnose future issues.

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-28 23:50:23 +01:00
Liosha84andJunie cb1c8b9a2c Fix: update Gitea token menu name to 'Manage Access Tokens'
Docker Build and Push / build-and-push (push) Canceled after 0s
The actual Gitea UI shows 'Manage Access Tokens' with the subtitle
'These tokens grant access to your account using the Gitea API',
not 'Authorization tokens'. Updated docs/DEVELOPMENT.md to match.

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-28 23:43:13 +01:00
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
Liosha84 539175654c Merge remote-tracking branch 'origin/master' 2026-10-28 22:04:52 +01:00
Liosha84 04ee269f23 remake of REST Controllers 2026-10-28 22:04:40 +01:00
28 changed files with 2019 additions and 166 deletions
+27 -7
View File
@@ -1,9 +1,29 @@
# Docker build context exclusions
.git .git
.github
.gitea
docs
*.md
!.markdownlint.yaml
.idea .idea
**/.idea *.iml
**/node_modules node_modules
.gradle
**/dist build
**/.gradle dist
**/.angular .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.*
+38
View File
@@ -0,0 +1,38 @@
kind: pipeline
type: docker
name: build-test
trigger:
event:
- pull_request
steps:
- name: build-test
image: plugins/docker
settings:
dockerfile: Dockerfile
context: .
---
kind: pipeline
type: docker
name: build-and-push
trigger:
event:
- push
branch:
- master
steps:
- name: build-and-push
image: plugins/docker
settings:
dockerfile: Dockerfile
context: .
registry: gitea.jambotron.com
repo: gitea.jambotron.com/liosha84/htop-web
tags: ${DRONE_COMMIT_SHA},main-latest
username:
from_secret: REGISTRY_USERNAME
password:
from_secret: REGISTRY_PASSWORD
+7
View File
@@ -1,5 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <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"> <component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE"> <option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="backend" options="-parameters" /> <module name="backend" options="-parameters" />
+25 -2
View File
@@ -2,8 +2,31 @@
All notable project changes should be documented here. 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 ## Unreleased
- Documentation structure established. - **Automatic releases on merge** — Drone now builds and pushes Docker images automatically when a Pull Request is merged into `main`. No manual Git tags are needed. Each merge produces two tags: `main-{commit-sha}` for traceability and `main-latest` for the most recent stable build.
- **Git-flow workflow** — development happens in the `develop` branch. When code is ready for release, create a Pull Request from `develop` → `main`. Merging the PR triggers the automatic release.
## v0.3.0
- **CI/CD migrated to Drone** — replaced Gitea Actions with Drone CI/CD (`.drone.yml`). The Docker plugin `auto_tag` feature automatically creates Semantic Versioning tags (`0.3.0`, `0.3`) on Git tag pushes. Configure `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets in the Gitea repository settings.
## v0.2.0
- **CI/CD with Drone** — automated Docker image build and push on Git tags using Drone CI/CD. 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 FROM eclipse-temurin:21-jre
WORKDIR /app 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 --gecos "" appuser && \
chown appuser:appuser /app
USER appuser
EXPOSE 8080 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"] 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 ## Simple Scenario of using
```bash ```yaml
services: services:
htop-web: htop-web:
image: gitea.jambotron.com/liosha84/htop-web:latest image: gitea.jambotron.com/liosha84/htop-web:latest
container_name: htop-web container_name: htop-web
restart: unless-stopped restart: unless-stopped
pid: host pid: host
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes: volumes:
- /proc:/host/proc:ro
- /sys:/host/sys: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: labels:
caddy: htop-web.jambotron.com caddy: htop-web.jambotron.com
caddy.reverse_proxy: "{{upstreams 8080}}" caddy.reverse_proxy: "{{upstreams 8080}}"
@@ -93,13 +106,28 @@ docker compose up -d
### Requirements ### Requirements
- Java 21 and a compatible Node.js/npm installation for local builds.
- Docker Engine or Docker Desktop. - Docker Engine or Docker Desktop.
- Docker Compose. - 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:** **Linux:**
@@ -119,16 +147,6 @@ cd ..
The configured Gradle build builds the frontend and packages its production assets with the backend. 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 ### 3. Inspect logs
```bash ```bash
+6
View File
@@ -35,7 +35,11 @@ def frontendDir = file('../frontend')
def frontendDistDir = file('../frontend/dist/frontend/browser') def frontendDistDir = file('../frontend/dist/frontend/browser')
def springStaticDir = file('src/main/resources/static') def springStaticDir = file('src/main/resources/static')
// 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('installFrontendDependencies', Exec) { tasks.register('installFrontendDependencies', Exec) {
onlyIf { !skipFrontend }
workingDir frontendDir workingDir frontendDir
inputs.file(file('../frontend/package.json')) inputs.file(file('../frontend/package.json'))
@@ -50,6 +54,7 @@ tasks.register('installFrontendDependencies', Exec) {
} }
tasks.register('buildFrontend', Exec) { tasks.register('buildFrontend', Exec) {
onlyIf { !skipFrontend }
dependsOn 'installFrontendDependencies' dependsOn 'installFrontendDependencies'
workingDir frontendDir workingDir frontendDir
@@ -67,6 +72,7 @@ tasks.register('buildFrontend', Exec) {
} }
tasks.register('copyFrontend', Copy) { tasks.register('copyFrontend', Copy) {
onlyIf { !skipFrontend }
dependsOn 'buildFrontend' dependsOn 'buildFrontend'
outputs.upToDateWhen { false } 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()
));
}
}
@@ -5,6 +5,10 @@ import com.htopweb.system.linux.*;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
@RestController @RestController
@RequestMapping("/api/system") @RequestMapping("/api/system")
@@ -12,9 +16,7 @@ public class SystemMetricsController {
private final SystemMetricsService _systemMetricsService; private final SystemMetricsService _systemMetricsService;
private final LinuxDetailsService _linuxDetailsService; private final LinuxDetailsService _linuxDetailsService;
private final CpuDetailsService _cpuDetailsService; private final CpuDetailsService _cpuDetailsService;
private final LinuxMemoryService _linuxMemoryService; private final LinuxMemoryService _linuxMemoryService;
private final LinuxDiskService _linuxDiskService; private final LinuxDiskService _linuxDiskService;
private final LinuxNetworkService _linuxNetworkService; private final LinuxNetworkService _linuxNetworkService;
@@ -23,8 +25,9 @@ public class SystemMetricsController {
SystemMetricsService systemMetricsService, SystemMetricsService systemMetricsService,
LinuxDetailsService linuxDetailsService, LinuxDetailsService linuxDetailsService,
CpuDetailsService cpuDetailsService, CpuDetailsService cpuDetailsService,
LinuxMemoryService linuxMemoryService, LinuxDiskService linuxDiskService, LinuxNetworkService linuxNetworkService LinuxMemoryService linuxMemoryService,
LinuxDiskService linuxDiskService,
LinuxNetworkService linuxNetworkService
) { ) {
_systemMetricsService = systemMetricsService; _systemMetricsService = systemMetricsService;
_linuxDetailsService = linuxDetailsService; _linuxDetailsService = linuxDetailsService;
@@ -35,32 +38,38 @@ public class SystemMetricsController {
} }
@GetMapping("/metrics") @GetMapping("/metrics")
public SystemMetricsResponse getMetrics() { public Mono<SystemMetricsResponse> getMetrics() {
return _systemMetricsService.getMetrics(); return Mono.fromCallable(_systemMetricsService::getMetrics)
.subscribeOn(Schedulers.boundedElastic());
} }
@GetMapping("/linux") @GetMapping("/linux")
public LinuxDetailsResponse getLinuxDetails() { public Mono<LinuxDetailsResponse> getLinuxDetails() {
return _linuxDetailsService.getDetails(); return Mono.fromCallable(_linuxDetailsService::getDetails)
.subscribeOn(Schedulers.boundedElastic());
} }
@GetMapping("/cpu") @GetMapping("/cpu")
public CpuDetailsResponse getCpuDetails() { public Mono<CpuDetailsResponse> getCpuDetails() {
return _cpuDetailsService.getDetails(); return Mono.fromCallable(_cpuDetailsService::getDetails)
.subscribeOn(Schedulers.boundedElastic());
} }
@GetMapping("/linux/memory") @GetMapping("/linux/memory")
public LinuxMemoryResponse getLinuxMemory() { public Mono<LinuxMemoryResponse> getLinuxMemory() {
return _linuxMemoryService.getDetails(); return Mono.fromCallable(_linuxMemoryService::getDetails)
.subscribeOn(Schedulers.boundedElastic());
} }
@GetMapping("/linux/disks") @GetMapping("/linux/disks")
public java.util.List<LinuxDiskResponse> getLinuxDisks() { public Mono<List<LinuxDiskResponse>> getLinuxDisks() {
return _linuxDiskService.getDisks(); return Mono.fromCallable(_linuxDiskService::getDisks)
.subscribeOn(Schedulers.boundedElastic());
} }
@GetMapping("/linux/network") @GetMapping("/linux/network")
public java.util.List<LinuxNetworkResponse> getLinuxNetwork() { public Mono<List<LinuxNetworkResponse>> getLinuxNetwork() {
return _linuxNetworkService.getInterfaces(); return Mono.fromCallable(_linuxNetworkService::getInterfaces)
.subscribeOn(Schedulers.boundedElastic());
} }
} }
@@ -16,18 +16,19 @@ import java.nio.file.Path;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
@Service @Service
public class SystemMetricsService { public class SystemMetricsService {
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_PROC = HOST_ROOT.resolve("proc"); private static final Path HOST_ETC = Path.of("/host/etc");
private final OperatingSystem _operatingSystem; private final OperatingSystem _operatingSystem;
private final CentralProcessor _processor; private final CentralProcessor _processor;
private final GlobalMemory _memory; private final GlobalMemory _memory;
private CpuTicks _previousHostCpuTicks; private final AtomicReference<CpuTicks> _previousHostCpuTicks = new AtomicReference<>();
private static final Logger log = private static final Logger log =
LoggerFactory.getLogger(SystemMetricsService.class); LoggerFactory.getLogger(SystemMetricsService.class);
@@ -40,16 +41,17 @@ public class SystemMetricsService {
_processor = hardware.getProcessor(); _processor = hardware.getProcessor();
_memory = hardware.getMemory(); _memory = hardware.getMemory();
_previousHostCpuTicks = readHostCpuTicks(); _previousHostCpuTicks.set(readHostCpuTicks());
} }
public synchronized SystemMetricsResponse getMetrics() { public SystemMetricsResponse getMetrics() {
CpuTicks currentTicks = readHostCpuTicks(); CpuTicks currentTicks = readHostCpuTicks();
double cpuUsage = 0.0; double cpuUsage = 0.0;
if (currentTicks != null && _previousHostCpuTicks != null) { CpuTicks previous = _previousHostCpuTicks.get();
long totalDelta = currentTicks.total() - _previousHostCpuTicks.total(); if (currentTicks != null && previous != null) {
long idleDelta = currentTicks.idle() - _previousHostCpuTicks.idle(); long totalDelta = currentTicks.total() - previous.total();
long idleDelta = currentTicks.idle() - previous.idle();
if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) { if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) {
cpuUsage = (double) (totalDelta - idleDelta) / totalDelta; cpuUsage = (double) (totalDelta - idleDelta) / totalDelta;
@@ -57,7 +59,7 @@ public class SystemMetricsService {
} }
if (currentTicks != null) { if (currentTicks != null) {
_previousHostCpuTicks = currentTicks; _previousHostCpuTicks.set(currentTicks);
} }
Map<String, Long> hostMemory = readHostMemory(); Map<String, Long> hostMemory = readHostMemory();
@@ -165,8 +167,8 @@ public class SystemMetricsService {
values.put(key, Long.parseLong(parts[0]) * 1024L); values.put(key, Long.parseLong(parts[0]) * 1024L);
} }
} }
} catch (IOException | NumberFormatException ignored) { } catch (IOException | NumberFormatException e) {
// Use OSHI values when host meminfo is unavailable. log.debug("Failed to read host memory info: {}", e.getMessage());
} }
return values; return values;
@@ -189,8 +191,8 @@ public class SystemMetricsService {
return count; return count;
} }
} }
} catch (IOException ignored) { } catch (IOException e) {
// Fall back to OSHI. log.debug("Failed to read host processor count: {}", e.getMessage());
} }
return _processor.getLogicalProcessorCount(); return _processor.getLogicalProcessorCount();
@@ -198,7 +200,7 @@ public class SystemMetricsService {
private String readHostHostname() { private String readHostHostname() {
try { try {
Path file = HOST_ROOT.resolve("etc/hostname"); Path file = HOST_ETC.resolve("hostname");
if (Files.isRegularFile(file)) { if (Files.isRegularFile(file)) {
String hostname = Files.readString(file).trim(); String hostname = Files.readString(file).trim();
@@ -207,8 +209,8 @@ public class SystemMetricsService {
return hostname; return hostname;
} }
} }
} catch (IOException ignored) { } catch (IOException e) {
// Fall back to OSHI. log.debug("Failed to read host hostname: {}", e.getMessage());
} }
return _operatingSystem.getNetworkParams().getHostName(); return _operatingSystem.getNetworkParams().getHostName();
@@ -218,7 +220,7 @@ public class SystemMetricsService {
Map<String, String> values = new HashMap<>(); Map<String, String> values = new HashMap<>();
try { try {
Path file = HOST_ROOT.resolve("etc/os-release"); Path file = HOST_ETC.resolve("os-release");
if (!Files.isRegularFile(file)) { if (!Files.isRegularFile(file)) {
return values; return values;
@@ -242,8 +244,8 @@ public class SystemMetricsService {
values.put(key, value); values.put(key, value);
} }
} catch (IOException ignored) { } catch (IOException e) {
// Fall back to OSHI. log.debug("Failed to read host os-release: {}", e.getMessage());
} }
return values; return values;
@@ -1,8 +1,11 @@
package com.htopweb.system; package com.htopweb.system;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
@@ -19,7 +22,8 @@ public class SystemProcessesController {
} }
@GetMapping("/processes") @GetMapping("/processes")
public List<ProcessInfoResponse> getProcesses() { public Mono<List<ProcessInfoResponse>> getProcesses() {
return _systemProcessesService.getProcesses(); return Mono.fromCallable(_systemProcessesService::getProcesses)
.subscribeOn(Schedulers.boundedElastic());
} }
} }
@@ -16,6 +16,8 @@ import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@Service @Service
public class SystemProcessesService { public class SystemProcessesService {
@@ -23,7 +25,7 @@ public class SystemProcessesService {
private static final Logger log = private static final Logger log =
LoggerFactory.getLogger(SystemProcessesService.class); 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 static final Path PROC = Path.of("/proc");
private final OperatingSystem _operatingSystem; private final OperatingSystem _operatingSystem;
@@ -31,10 +33,10 @@ public class SystemProcessesService {
private final long _clockTicksPerSecond; private final long _clockTicksPerSecond;
private final long _pageSizeBytes; private final long _pageSizeBytes;
private final Map<Integer, ProcessSnapshot> _previousProcesses = private final AtomicReference<Map<Integer, ProcessSnapshot>> _previousProcesses =
new HashMap<>(); new AtomicReference<>(new HashMap<>());
private long _previousSampleNanos = System.nanoTime(); private final AtomicLong _previousSampleNanos = new AtomicLong(System.nanoTime());
public SystemProcessesService() { public SystemProcessesService() {
SystemInfo systemInfo = new SystemInfo(); 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; Path procRoot = Files.isDirectory(HOST_PROC) ? HOST_PROC : PROC;
if (!Files.isReadable(procRoot)) { if (!Files.isReadable(procRoot)) {
@@ -63,14 +65,15 @@ public class SystemProcessesService {
} }
long nowNanos = System.nanoTime(); long nowNanos = System.nanoTime();
long prevNanos = _previousSampleNanos.getAndSet(nowNanos);
double elapsedSeconds = double elapsedSeconds =
(nowNanos - _previousSampleNanos) / 1_000_000_000.0; (nowNanos - prevNanos) / 1_000_000_000.0;
_previousSampleNanos = nowNanos;
long bootTimeSeconds = readBootTime(procRoot); long bootTimeSeconds = readBootTime(procRoot);
Map<Integer, ProcessSnapshot> currentProcesses = new HashMap<>(); Map<Integer, ProcessSnapshot> currentProcesses = new HashMap<>();
List<ProcessInfoResponse> result = new ArrayList<>(); List<ProcessInfoResponse> result = new ArrayList<>();
Map<Integer, ProcessSnapshot> previousMap = _previousProcesses.get();
try (var entries = Files.list(procRoot)) { try (var entries = Files.list(procRoot)) {
entries.filter(path -> entries.filter(path ->
path.getFileName().toString().matches("\\d+")) path.getFileName().toString().matches("\\d+"))
@@ -90,8 +93,7 @@ public class SystemProcessesService {
currentProcesses.put(pid, current); currentProcesses.put(pid, current);
ProcessSnapshot previous = ProcessSnapshot previous = previousMap.get(pid);
_previousProcesses.get(pid);
double cpuUsage = 0.0; double cpuUsage = 0.0;
@@ -126,8 +128,8 @@ public class SystemProcessesService {
current.threadCount() current.threadCount()
)); ));
} catch (IOException | NumberFormatException } catch (IOException | NumberFormatException
| SecurityException ignored) { | SecurityException e) {
// A process may exit or become inaccessible. log.debug("Process {} became inaccessible: {}", path.getFileName(), e.getMessage());
} }
}); });
} catch (IOException e) { } catch (IOException e) {
@@ -136,8 +138,7 @@ public class SystemProcessesService {
return getProcessesFromOshi(); return getProcessesFromOshi();
} }
_previousProcesses.clear(); _previousProcesses.set(currentProcesses);
_previousProcesses.putAll(currentProcesses);
result.sort(Comparator.comparingDouble( result.sort(Comparator.comparingDouble(
ProcessInfoResponse::cpuUsagePercent ProcessInfoResponse::cpuUsagePercent
@@ -210,8 +211,8 @@ public class SystemProcessesService {
); );
} }
} }
} catch (IOException | NumberFormatException ignored) { } catch (IOException | NumberFormatException e) {
// The process may exit while status is being read. log.debug("Failed to read thread count: {}", e.getMessage());
} }
return 0; return 0;
@@ -224,8 +225,8 @@ public class SystemProcessesService {
return Long.parseLong(line.substring(6).trim()); return Long.parseLong(line.substring(6).trim());
} }
} }
} catch (IOException | NumberFormatException ignored) { } catch (IOException | NumberFormatException e) {
// Boot time unavailable. log.debug("Failed to read boot time: {}", e.getMessage());
} }
return 0L; return 0L;
@@ -1,61 +1,121 @@
package com.htopweb.system.linux; package com.htopweb.system.linux;
import oshi.SystemInfo; import org.slf4j.Logger;
import oshi.hardware.CentralProcessor; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
@Service @Service
public class CpuDetailsService { public class CpuDetailsService {
private final CentralProcessor _processor; private static final Path HOST_PROC = Path.of("/host/proc");
private long[][] _previousCoreTicks; private static final Logger log = LoggerFactory.getLogger(CpuDetailsService.class);
private long[] _previousSystemTicks;
private static final String CPU_LINE_PREFIX = "cpu";
private final AtomicReference<CpuTicks[]> _previousCoreTicks = new AtomicReference<>();
public CpuDetailsService() { public CpuDetailsService() {
SystemInfo systemInfo = new SystemInfo(); _previousCoreTicks.set(readHostCpuTicks());
_processor = systemInfo.getHardware().getProcessor();
_previousCoreTicks = _processor.getProcessorCpuLoadTicks();
_previousSystemTicks = _processor.getSystemCpuLoadTicks();
} }
public synchronized CpuDetailsResponse getDetails() { public CpuDetailsResponse getDetails() {
long[][] currentCoreTicks = _processor.getProcessorCpuLoadTicks(); CpuTicks[] currentTicks = readHostCpuTicks();
long[] currentSystemTicks = _processor.getSystemCpuLoadTicks(); CpuTicks[] previousTicks = _previousCoreTicks.getAndSet(currentTicks);
double[] coreLoads = _processor.getProcessorCpuLoadBetweenTicks( List<CpuDetailsResponse.CpuCoreResponse> cores = new ArrayList<>();
_previousCoreTicks,
currentCoreTicks
);
double systemLoad = _processor.getSystemCpuLoadBetweenTicks( if (previousTicks == null || currentTicks == null) {
_previousSystemTicks, // First call — return zeros.
currentSystemTicks if (currentTicks != null) {
); for (int i = 0; i < currentTicks.length; i++) {
cores.add(new CpuDetailsResponse.CpuCoreResponse(i, 0.0));
_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)
));
} }
}
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( return new CpuDetailsResponse(
toPercent(systemLoad * 100.0), toPercent(avgTotal),
_processor.getLogicalProcessorCount(), coreCount,
List.copyOf(cores) 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) { private double toPercent(double value) {
if (!Double.isFinite(value)) { if (!Double.isFinite(value)) {
return 0.0; return 0.0;
@@ -65,4 +125,7 @@ public class CpuDetailsService {
Math.max(0.0, Math.min(100.0, value)) * 10.0 Math.max(0.0, Math.min(100.0, value)) * 10.0
) / 10.0; ) / 10.0;
} }
private record CpuTicks(long total, long idle) {
}
} }
@@ -20,9 +20,10 @@ public class LinuxDiskService {
private static final Logger log = private static final Logger log =
LoggerFactory.getLogger(LinuxDiskService.class); 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 = 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( private static final Set<String> EXCLUDED_TYPES = Set.of(
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "proc", "sysfs", "devtmpfs", "devpts", "tmpfs",
@@ -79,6 +80,7 @@ public class LinuxDiskService {
).normalize(); ).normalize();
if (!accessiblePath.startsWith(HOST_ROOT) if (!accessiblePath.startsWith(HOST_ROOT)
|| accessiblePath.equals(HOST_ROOT)
|| !Files.exists(accessiblePath)) { || !Files.exists(accessiblePath)) {
log.warn("Host mount path is unavailable: {}", accessiblePath); log.warn("Host mount path is unavailable: {}", accessiblePath);
return null; return null;
@@ -1,34 +1,34 @@
package com.htopweb.system.linux; package com.htopweb.system.linux;
import oshi.SystemInfo; import org.slf4j.Logger;
import oshi.hardware.GlobalMemory; import org.slf4j.LoggerFactory;
import oshi.hardware.VirtualMemory;
import org.springframework.stereotype.Service; 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 @Service
public class LinuxMemoryService { public class LinuxMemoryService {
private final GlobalMemory _memory; private static final Path HOST_PROC = Path.of("/host/proc");
private final VirtualMemory _virtualMemory; private static final Logger log = LoggerFactory.getLogger(LinuxMemoryService.class);
public LinuxMemoryService() { public LinuxMemoryService() {
SystemInfo systemInfo = new SystemInfo();
_memory = systemInfo.getHardware().getMemory();
_virtualMemory = _memory.getVirtualMemory();
} }
public LinuxMemoryResponse getDetails() { public LinuxMemoryResponse getDetails() {
long totalRam = Math.max(0L, _memory.getTotal()); Map<String, Long> meminfo = readHostMeminfo();
long availableRam = Math.max(0L, Math.min(totalRam, _memory.getAvailable())); 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 usedRam = Math.max(0L, totalRam - availableRam);
long totalSwap = Math.max(0L, _virtualMemory.getSwapTotal()); long totalSwap = swaps.getOrDefault("total", 0L);
long usedSwap = Math.max( long usedSwap = swaps.getOrDefault("used", 0L);
0L,
Math.min(totalSwap, _virtualMemory.getSwapUsed())
);
long freeSwap = Math.max(0L, totalSwap - usedSwap); long freeSwap = Math.max(0L, totalSwap - usedSwap);
return new LinuxMemoryResponse( 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) { private double percentage(long used, long total) {
if (total <= 0) { if (total <= 0) {
return 0.0; return 0.0;
@@ -21,13 +21,14 @@ public class LinuxNetworkService {
private static final Logger log = private static final Logger log =
LoggerFactory.getLogger(LinuxNetworkService.class); 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 = 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 = 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 = private static final Set<String> EXCLUDED_INTERFACES =
Set.of("lo", "docker0"); Set.of("lo", "docker0");
+1 -1
View File
@@ -9,5 +9,5 @@
<link rel="stylesheet" href="styles-5INURTSO.css"></head> <link rel="stylesheet" href="styles-5INURTSO.css"></head>
<body> <body>
<app-root></app-root> <app-root></app-root>
<script src="main-WRDGP7C5.js" type="module"></script></body> <script src="main-KCY2LTAZ.js" type="module"></script></body>
</html> </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: # ports:
# - "8088:8000" # - "8088:8000"
pid: host pid: host
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes: volumes:
- /proc:/host/proc:ro
- /sys:/host/sys: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: labels:
caddy: htop-web.jambotron.com caddy: htop-web.jambotron.com
caddy.reverse_proxy: "{{upstreams 8080}}" 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 ## 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 ## Packaging
+637
View File
@@ -0,0 +1,637 @@
# htop-web: Веб-интерфейс мониторинга системных ресурсов на базе Spring Boot WebFlux и Angular
---
## Аннотация
В статье рассматривается архитектура и реализация **htop-web** — самохостимого веб-приложения для мониторинга системных ресурсов Linux-серверов в реальном времени. Приложение объединяет реактивный бэкенд на Spring Boot WebFlux с фронтендом на Angular 22, упаковывая оба компонента в единый Docker-контейнер. Ключевая особенность решения — сбор метрик хост-системы из контейнеризованной среды через монтирование файловых систем `/proc` и `/sys` хоста, что позволяет получить точные данные о CPU, памяти, дисках и сетевых интерфейсах реального сервера, а не виртуальной среды контейнера.
**Ключевые слова:** мониторинг, Spring Boot, WebFlux, Angular, Docker, контейнеризация, OSHI, системные метрики.
---
## 1. Введение
Мониторинг системных ресурсов является критически важной задачей при администрировании серверной инфраструктуры. Классические инструменты, такие как `htop`, `top`, `vmstat` и `iostat`, предоставляют детальную информацию, однако требуют доступа к терминалу и не подходят для удалённого мониторинга через веб-браузер.
Существующие веб-решения мониторинга (Grafana + Prometheus, Netdata, Zabbix) предлагают мощные возможности визуализации и агрегации, но часто требуют значительных ресурсов и сложной настройки. Для небольших инфраструктур и индивидуальных серверов возникает потребность в лёгком, самодостаточном решении, которое:
- Разворачивается за несколько команд;
- Не требует внешних баз данных или брокеров сообщений;
- Предоставляет информацию в реальном времени;
- Занимает минимальные ресурсы хоста.
**htop-web** отвечает этим требованиям, предоставляя компактный веб-дашборд, который собирает метрики напрямую из файловых систем Linux и отображает их через REST API и реактивный фронтенд.
---
## 2. Обзор существующих решений
| Решение | Тип | Ресурсы | Настройка | Особенности |
|---|---|---|---|---|
| **htop / top** | CLI | Минимальные | Нет | Требует терминала, нет веб-интерфейса |
| **Grafana + Prometheus** | Веб | Высокие | Сложная | Агрегация, алерты, требует Node Exporter |
| **Netdata** | Веб | Средние | Средняя | Real-time, но тяжёлый агент |
| **Zabbix** | Веб | Высокие | Сложная | Корпоративный мониторинг, алертинг |
| **Glances** | Веб/CLI | Низкие | Простая | Python-зависимости, ограниченный UI |
| **htop-web** | Веб | Низкие | Простая | Один контейнер, нет внешних зависимостей |
htop-web занимает нишу между CLI-инструментами и тяжёлыми системами мониторинга, предлагая минималистичный подход с фокусом на простоту развёртывания.
---
## 3. Архитектура системы
### 3.1 Общая схема
```
┌─────────────────────────────────────────────────────┐
│ Веб-браузер │
│ (Angular 22, TypeScript) │
└──────────────────────┬──────────────────────────────┘
│ HTTP (polling, 1 сек)
▼
┌─────────────────────────────────────────────────────┐
│ Docker-контейнер │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Spring Boot WebFlux (Java 21) │ │
│ │ │ │
│ │ REST API: /api/system/* │ │
│ │ ├─ /metrics — CPU, RAM, ОС │ │
│ │ ├─ /processes — список процессов │ │
│ │ ├─ /linux — uptime, load average │ │
│ │ ├─ /cpu — детали по ядрам │ │
│ │ ├─ /linux/memory — детальная память │ │
│ │ ├─ /linux/disks — диски и файловые сист. │ │
│ │ └─ /linux/network — сетевые интерфейсы │ │
│ │ │ │
│ │ Сервисы сбора метрик: │ │
│ │ ├─ SystemMetricsService │ │
│ │ ├─ SystemProcessesService │ │
│ │ ├─ LinuxDetailsService │ │
│ │ ├─ CpuDetailsService │ │
│ │ ├─ LinuxMemoryService │ │
│ │ ├─ LinuxDiskService │ │
│ │ └─ LinuxNetworkService │ │
│ │ │ │
│ │ Статические ресурсы Angular │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Монтированные тома хоста: │
│ /host/root ← / (корень хоста, read-only) │
│ /host/sys ← /sys (системные данные, read-only) │
└─────────────────────────────────────────────────────┘
```
### 3.2 Ключевые принципы архитектуры
1. **Single-container deployment** — фронтенд и бэкенд упакованы в один JAR-файл и один Docker-контейнер.
2. **Host filesystem mounting** — метрики хоста получаются через монтирование `/proc` и `/sys` хоста в контейнер.
3. **Reactive backend** — Spring WebFlux обеспечивает неблокирующую обработку запросов.
4. **RxJS polling** — фронтенд использует реактивные потоки для периодического опроса API.
5. **Fallback mechanism** — при недоступности файлов хоста используется библиотека OSHI как запасной вариант.
---
## 4. Реализация бэкенда
### 4.1 Технологический стек
| Компонент | Технология | Версия |
|---|---|---|
| Язык | Java | 21 |
| Фреймворк | Spring Boot | 4.1.2 |
| Реактивность | Spring WebFlux | 4.1.2 |
| Системные метрики | OSHI | 7.7.2 |
| Сборка | Gradle | 8.x |
### 4.2 REST API контроллер
Контроллер `SystemMetricsController` экспонирует шесть REST-эндпоинтов, каждый из которых делегирует сбор данных соответствующему сервису:
```java
@RestController
@RequestMapping("/api/system")
public class SystemMetricsController {
private final SystemMetricsService _systemMetricsService;
private final LinuxDetailsService _linuxDetailsService;
private final CpuDetailsService _cpuDetailsService;
private final LinuxMemoryService _linuxMemoryService;
private final LinuxDiskService _linuxDiskService;
private final LinuxNetworkService _linuxNetworkService;
@GetMapping("/metrics")
public Mono<SystemMetricsResponse> getMetrics() {
return Mono.fromCallable(_systemMetricsService::getMetrics)
.subscribeOn(Schedulers.boundedElastic());
}
@GetMapping("/linux")
public Mono<LinuxDetailsResponse> getLinuxDetails() {
return Mono.fromCallable(_linuxDetailsService::getDetails)
.subscribeOn(Schedulers.boundedElastic());
}
// ... остальные эндпоинты
}
```
Каждый метод оборачивает синхронный вызов сервиса в `Mono` и выполняет его на `Schedulers.boundedElastic()`, что предотвращает блокировку реактивного пула потоков при чтении файловых систем.
### 4.3 Сбор метрик CPU и памяти
Сервис `SystemMetricsService` демонстрирует ключевой паттерн проекта — чтение метрик хоста из смонтированной файловой системы с fallback на OSHI:
```java
@Service
public class SystemMetricsService {
private static final Path HOST_ROOT = Path.of("/host/root");
private static final Path HOST_PROC = HOST_ROOT.resolve("proc");
public synchronized 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();
if (totalDelta > 0 && idleDelta >= 0 && idleDelta <= totalDelta) {
cpuUsage = (double) (totalDelta - idleDelta) / totalDelta;
}
}
// ... расчёт памяти и формирование ответа
}
private CpuTicks readHostCpuTicks() {
Path statFile = HOST_PROC.resolve("stat");
// Чтение /host/root/proc/stat и парсинг строки "cpu ..."
// Возвращает null при ошибке — триггер для fallback на OSHI
}
}
```
Алгоритм расчёта загрузки CPU основан на сравнении тиков процессора между двумя последовательными чтениями файла `/proc/stat`. Формула:
$$CPU_{usage} = \frac{\Delta_{total} - \Delta_{idle}}{\Delta_{total}}$$
где $\Delta_{total}$ — разница суммарных тиков всех состояний процессора, а $\Delta_{idle}$ — разница тиков в состоянии idle.
### 4.4 Сбор информации о процессах
Сервис `SystemProcessesService` парсит `/proc/[pid]/stat` для каждого процесса, вычисляя загрузку CPU на основе разницы тиков между двумя снимками:
```java
@Service
public class SystemProcessesService {
public synchronized List<ProcessInfoResponse> getProcesses() {
Path procRoot = Files.isDirectory(HOST_PROC) ? HOST_PROC : PROC;
long nowNanos = System.nanoTime();
double elapsedSeconds =
(nowNanos - _previousSampleNanos) / 1_000_000_000.0;
try (var entries = Files.list(procRoot)) {
entries.filter(path ->
path.getFileName().toString().matches("\\d+"))
.forEach(path -> {
int pid = Integer.parseInt(path.getFileName().toString());
ProcessSnapshot current = readProcess(path, pid, bootTimeSeconds);
// Расчёт CPU usage на основе delta тиков
// ...
});
}
// Fallback: getProcessesFromOshi() при недоступности /proc
}
}
```
Расчёт CPU usage процесса:
$$CPU_{process} = \frac{\Delta_{ticks}}{CLK\_TCK \times \Delta_{seconds}} \times \frac{100}{N_{cores}}$$
где $CLK\_TCK$ — количество тиков системы в секунду (обычно 100), а $N_{cores}$ — количество логических ядер.
### 4.5 Детали Linux-системы
Сервис `LinuxDetailsService` предоставляет информацию о системе через OSHI:
```java
@Service
public class LinuxDetailsService {
public LinuxDetailsResponse getDetails() {
String hostname = _operatingSystem.getNetworkParams().getHostName();
String distribution = _operatingSystem.getFamily();
String kernelVersion = _operatingSystem.getVersionInfo().getVersion();
long uptimeSeconds = _operatingSystem.getSystemUptime();
double[] loadAverages = _processor.getSystemLoadAverage(3);
return new LinuxDetailsResponse(
linux, hostname, distribution, kernelVersion,
uptimeSeconds,
safeLoad(loadAverages, 0), // 1 мин
safeLoad(loadAverages, 1), // 5 мин
safeLoad(loadAverages, 2), // 15 мин
_logicalProcessors
);
}
}
```
---
## 5. Реализация фронтенда
### 5.1 Технологический стек
| Компонент | Технология | Версия |
|---|---|---|
| Фреймворк | Angular | 22 |
| Язык | TypeScript | 5.x |
| Реактивность | RxJS | 7.x |
| Стили | SCSS | — |
### 5.2 Сервис опроса метрик
Фронтенд использует RxJS `timer` для периодического опроса API с интервалом 1 секуннда:
```typescript
@Injectable({ providedIn: 'root' })
export class SystemMetricsService {
private readonly http = inject(HttpClient);
private readonly refreshInterval = 1000;
readonly metrics$: Observable<SystemMetrics> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<SystemMetrics>('/api/system/metrics').pipe(
catchError((error: unknown) => {
console.error('Failed to load system metrics', error);
return of(null);
}),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly processes$: Observable<SystemProcess[]> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<SystemProcess[]>('/api/system/processes').pipe(
catchError((error: unknown) => of([] as SystemProcess[])),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
// ... остальные потоки: linux$, cpu$, memory$, disks$, network$
}
```
Оператор `shareReplay` обеспечивает многоподписку: все компоненты, подписанные на один поток, получают одни и те же данные без дублирования HTTP-запросов.
### 5.3 Компонент дашборда
Компонент `Dashboard` инъецирует сервис метрик и предоставляет данные для шаблона:
```typescript
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [AsyncPipe, DecimalPipe, JsonPipe],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
export class Dashboard {
private readonly _systemMetricsService = inject(SystemMetricsService);
readonly metrics$ = this._systemMetricsService.metrics$;
readonly processes$ = this._systemMetricsService.processes$;
readonly linux$ = this._systemMetricsService.linux$;
readonly cpu$ = this._systemMetricsService.cpu$;
readonly memory$ = this._systemMetricsService.memory$;
readonly disks$ = this._systemMetricsService.disks$;
readonly network$ = this._systemMetricsService.network$;
getSortedProcesses(processes: SystemProcess[]): SystemProcess[] {
return processes
.filter(p => p.name.toLowerCase().includes(this.processFilter))
.sort((a, b) => {
switch (this.processSort) {
case 'cpu': return a.cpuUsagePercent - b.cpuUsagePercent;
case 'memory': return a.residentMemoryBytes - b.residentMemoryBytes;
case 'name': return a.name.localeCompare(b.name);
case 'pid': return a.pid - b.pid;
}
});
}
}
```
### 5.4 Три режима отображения
Дашборд поддерживает три режима визуализации:
1. **Monitor** — карточный интерфейс с метриками CPU, памяти, дисков и сети.
2. **htop** — табличное представление процессов, стилизованное под классический htop.
3. **htop-original** — имитация терминального интерфейса htop с функциональными клавишами (F3 — поиск, F4 — фильтр, F6 — сортировка, F10 — выход).
```html
<nav class="view-switcher">
<button (click)="setViewMode('monitor')">Monitor</button>
<button (click)="setViewMode('htop')">htop</button>
<button (click)="setViewMode('htop-original')">htop-original</button>
</nav>
```
---
## 6. Сборка и упаковка
### 6.1 Gradle-сборка
Процесс сборки автоматически строит фронтенд и упаковывает его в бэкенд:
```groovy
tasks.register('buildFrontend', Exec) {
dependsOn 'installFrontendDependencies'
workingDir file('../frontend')
commandLine 'npm', 'run', 'build', '--', '--configuration', 'production'
}
tasks.register('copyFrontend', Copy) {
dependsOn 'buildFrontend'
from(file('../frontend/dist/frontend/browser'))
into(file('src/main/resources/static'))
}
tasks.named('processResources') {
dependsOn 'copyFrontend'
}
```
Порядок выполнения:
1. `npm ci` — установка зависимостей фронтенда;
2. `npm run build` — production-сборка Angular;
3. `copyFrontend` — копирование статических файлов в `src/main/resources/static`;
4. `bootJar` — упаковка JAR с фронтендом внутри.
### 6.2 Docker-образ
Dockerfile минимален и использует официальный образ Eclipse Temurin:
```dockerfile
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY backend/build/libs/backend-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
```
Образ весит около 300 МБ и не содержит инструментов разработки.
---
## 7. Развёртывание через Docker Compose
### 7.1 Базовая конфигурация
Файл `docker-compose.yml` определяет сервис с доступом к файловой системе хоста:
```yaml
services:
htop-web:
image: htop-web:latest
build:
context: .
dockerfile: Dockerfile
container_name: htop-web
restart: unless-stopped
pid: host
volumes:
- /sys:/host/sys:ro
- /:/host/root:ro
ports:
- "8088:8080"
```
**Ключевые параметры:**
| Параметр | Назначение |
|---|---|
| `pid: host` | Разделение PID-пространства хоста для видимости процессов |
| `/:/host/root:ro` | Чтение `/proc` хоста для метрик CPU, памяти и процессов |
| `/sys:/host/sys:ro` | Доступ к системным данным ядра |
| `restart: unless-stopped` | Автоматический перезапуск при сбое |
### 7.2 Пошаговая инструкция
#### Шаг 1: Клонирование репозитория
```bash
git clone https://gitea.jambotron.com/liosha84/htop-web.git
cd htop-web
```
#### Шаг 2: Сборка приложения
```bash
cd backend
./gradlew clean build
cd ..
```
Gradle автоматически соберёт Angular-фронтенд и упакует его в JAR-файл бэкенда.
#### Шаг 3: Запуск контейнера
```bash
docker compose up -d --build
```
Приложение доступно по адресу `http://localhost:8088`.
#### Шаг 4: Проверка статуса
```bash
# Статус контейнера
docker compose ps
# Логи в реальном времени
docker compose logs -f htop-web
```
#### Шаг 5: Остановка
```bash
docker compose down
```
### 7.3 Продвинутое развёртывание с Caddy
Для продакшн-развёртывания рекомендуется использовать обратный прокси с автоматическим HTTPS:
```yaml
services:
htop-web:
image: gitea.jambotron.com/liosha84/htop-web:latest
container_name: htop-web
restart: unless-stopped
pid: host
volumes:
- /sys:/host/sys:ro
- /:/host/root:ro
labels:
caddy: htop-web.example.com
caddy.reverse_proxy: "{{upstreams 8080}}"
networks:
- jambotron-net
networks:
jambotron-net:
external: true
```
Caddy автоматически выдаёт TLS-сертификат через Let's Encrypt и маршрутизирует запросы на контейнер.
### 7.4 Развёртывание на различных платформах
#### Linux-сервер
На Linux-сервере контейнер получает прямой доступ к `/proc` и `/sys` хоста, что обеспечивает точные метрики:
```bash
docker compose up -d --build
```
#### Docker Desktop на Windows
Docker Desktop на Windows запускает Linux-контейнеры в виртуальной машине (WSL2 или Hyper-V). В этом случае метрики отражают состояние виртуальной машины, а не хостовой Windows-системы:
> **Важно:** Метрики из контейнера не представляют полную картину Windows-хоста. Для мониторинга Windows рекомендуется использовать нативные инструменты или развёртывание на Linux.
#### Raspberry Pi и ARM-устройства
htop-web поддерживает ARM-архитектуру благодаря кроссплатформенности Java и OSHI. Сборка выполняется аналогично:
```bash
docker compose up -d --build
```
Образ `eclipse-temurin:21-jre` доступен для `arm64` и `arm/v7`.
---
## 8. Безопасность
Мониторинг системы раскрывает конфиденциальную информацию. При развёртывании необходимо учитывать:
1. **Аутентификация** — не размещайте htop-web в открытом доступе без защиты. Используйте обратный прокси с базовой аутентификацией или OAuth.
2. **HTTPS** — всегда используйте TLS при доступе из внешних сетей.
3. **Минимальные привилегии** — контейнер запускается с read-only доступом к файловой системе хоста, что снижает риски.
4. **Сетевая изоляция** — ограничьте доступ к порту 8088 через firewall или Docker-сети.
---
## 9. Сравнение с аналогами
### 9.1 Функциональное сравнение
| Функция | htop-web | Netdata | Glances | Grafana+Prometheus |
|---|---|---|---|---|
| Веб-интерфейс | ✓ | ✓ | ✓ | ✓ |
| CPU-метрики | ✓ | ✓ | ✓ | ✓ |
| Память | ✓ | ✓ | ✓ | ✓ |
| Процессы | ✓ | ✓ | ✓ | ✗ |
| Диски | ✓ | ✓ | ✓ | ✓ |
| Сеть | ✓ | ✓ | ✓ | ✓ |
| Развёртывание | 1 контейнер | 1 контейнер | pip install | 3+ компонента |
| Ресурсы | ~100 МБ RAM | ~200 МБ RAM | ~50 МБ RAM | ~500+ МБ RAM |
| История данных | ✗ | ✓ | ✗ | ✓ |
| Алертинг | ✗ | ✓ | ✓ | ✓ |
| Графики | Прогресс-бары | Графики | Прогресс-бары | Графики |
### 9.2 Позиционирование
htop-web ориентирован на сценарии, где важны:
- **Простота** — одна команда для запуска;
- **Минимальные ресурсы** — контейнер потребляет ~100 МБ RAM;
- **Отсутствие внешних зависимостей** — нет баз данных, брокеров, агентов;
- **Визуальное сходство с htop** — привычный интерфейс для системных администраторов.
Для задач, требующих исторических данных, алертинга и мультихостного мониторинга, рекомендуется использовать Grafana + Prometheus или Netdata.
---
## 10. Заключение
htop-web демонстрирует, что эффективный инструмент мониторинга может быть реализован как компактное самодостаточное приложение. Ключевые достижения проекта:
1. **Единый контейнер** — Angular-фронтенд и Spring Boot-бэкенд упакованы в один JAR, что упрощает развёртывание и обновление.
2. **Точные метрики хоста** — монтирование `/proc` и `/sys` хоста позволяет контейнеризованному приложению получать реальные данные сервера, а не метрики виртуальной среды контейнера.
3. **Реактивная архитектура** — Spring WebFlux на бэкенде и RxJS на фронтенде обеспечивают эффективную обработку данных в реальном времени.
4. **Fallback-механизм** — библиотека OSHI служит запасным источником метрик при недоступности файлов хоста.
5. **Минимальные требования** — приложение работает на устройствах с 512 МБ RAM, включая Raspberry Pi.
Перспективы развития включают добавление исторического хранения метрик, алертинга по пороговым значениям и поддержки WebSocket для push-обновлений вместо polling.
---
## Список литературы
1. Spring Boot Documentation. — https://docs.spring.io/spring-boot/docs/current/reference/html/
2. Spring WebFlux Reactive Web. — https://docs.spring.io/spring-framework/reference/web/reactive.html
3. Angular Documentation. — https://angular.dev/
4. RxJS Documentation. — https://rxjs.dev/
5. OSHI — Operating System and Hardware Information. — https://github.com/oshi/oshi
6. Docker Documentation. — https://docs.docker.com/
7. Linux `/proc` filesystem. — man 5 proc
8. The htop source code. — https://github.com/htop-dev/htop
9. Netdata — Distributed real-time monitoring. — https://github.com/netdata/netdata
10. Grafana — The open observability platform. — https://github.com/grafana/grafana
---
## 11. Скачивание и демо
### Живое демо
Попробовать htop-web в действии можно по адресу:
**https://htop-web.jambotron.com/**
Демо-инстанс развёрнут на Linux-сервере с доступом к реальным системным метрикам.
### Репозиторий
Исходный код проекта доступен на Gitea:
**https://gitea.jambotron.com/liosha84/htop-web**
Для клонирования:
```bash
git clone https://gitea.jambotron.com/liosha84/htop-web.git
cd htop-web
```
### Docker-образ
Готовый Docker-образ опубликован в контейнерном реестре Gitea:
**gitea.jambotron.com/liosha84/htop-web:latest**
Быстрый запуск одним командой:
```bash
docker run -d --name htop-web --pid host \
-v /:/host/root:ro -v /sys:/host/sys:ro \
-p 8088:8080 \
gitea.jambotron.com/liosha84/htop-web:latest
```
---
*Статья подготовлена на основе исходного кода проекта htop-web, версия 0.0.1-SNAPSHOT.*
+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. 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. 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. Do not expose the monitoring interface to untrusted networks without appropriate access controls and network security.
+190 -1
View File
@@ -8,6 +8,8 @@
## Build and test ## Build and test
### Option 1: Gradle build (requires Java 21 and Node.js)
From the repository root: From the repository root:
```bash ```bash
@@ -17,6 +19,21 @@ cd backend
This invokes the configured Gradle build, including frontend build tasks. 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 development
Frontend source code is located in `frontend/`. Frontend source code is located in `frontend/`.
@@ -38,13 +55,185 @@ If the project does not define a `start` script, inspect `frontend/package.json`
## Run the container ## Run the container
From the repository root, after a successful build: ### With Docker Compose (recommended)
From the repository root:
```bash ```bash
docker compose up -d --build docker compose up -d --build
docker compose logs -f htop-web 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
### Workflow overview
The project uses a **Git-flow** workflow with automatic releases:
1. All development happens in the **`develop`** branch.
2. When code is ready for release, create a **Pull Request** from `develop` → `main`.
3. **Merge the PR** into `main` — this triggers an automatic release.
4. **Drone** builds the Docker image and pushes it to the Gitea Container Registry.
**No manual Git tags are needed** — merging into `main` is the release.
```
develop → PR → merge into main → Drone builds → Docker image pushed
```
### Prerequisites
- Drone CI/CD runner must be configured and running at `drone.jambotron.com`.
- Gitea Container Registry must be enabled on the server.
- Repository secrets `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` must be set in Drone.
### Enabling Container Registry on Gitea server
Container Registry is a built-in feature of Gitea 1.17+. It is **not** configured through the web admin panel — it is enabled via the `app.ini` configuration file on the server.
**1. Find `app.ini` on your server.** The location depends on your installation method:
| Installation | Path to `app.ini` |
|--------------|-------------------|
| Docker (official image) | `/data/gitea/conf/app.ini` inside the container |
| System package (APT/YUM) | `/etc/gitea/app.ini` |
| Manual binary install | `<working-dir>/custom/conf/app.ini` |
You can also find the path from Gitea web interface: **Site Administration → Configuration Summary** — look for `CustomConf` value.
**2. Edit `app.ini`** and add the `[packages]` section:
```ini
[packages]
ENABLED = true
```
If the `[packages]` section already exists, make sure `ENABLED = true`.
**3. Restart Gitea** to apply the changes:
```bash
# Docker
docker compose restart gitea
# System service
sudo systemctl restart gitea
```
**4. Verify** that the registry is working by visiting `https://gitea.jambotron.com/user/packages` — you should see a "Packages" tab.
### Configuring Drone CI/CD
The repository uses **Drone** for CI/CD. The configuration file `.drone.yml` is already included in the repository. Drone is hosted at a separate domain: `drone.jambotron.com`.
**1. Log in to Drone.**
Open `https://drone.jambotron.com` and sign in with your Gitea account.
**2. Activate the repository in Drone.**
After logging in, find `Liosha84/htop-web` in the repository list and click **Activate**.
**3. Configure Drone secrets.**
In Drone, open the repository and go to **Settings → Secrets**. Add two secrets:
**First secret:**
- **Name:** `REGISTRY_USERNAME`
- **Value:** `Liosha84` (your Gitea username)
**Second secret:**
- **Name:** `REGISTRY_PASSWORD`
- **Value:** paste the Access Token with `write:package` and `read:package` scopes.
### Creating an Access Token for CI/CD
The CI/CD workflow cannot use your account password. You need a Personal Access Token with package permissions:
1. Log in to Gitea as `Liosha84`.
2. Go to **Settings** (your avatar → Settings).
3. In the left menu: **Applications → Manage Access Tokens**.
4. You'll see the section: *"These tokens grant access to your account using the Gitea API."*
5. Click **Generate token**.
6. Fill in:
- **Token name:** `ci-docker-push`
- **Expiration:** select **No expiration** (or set a date, but you'll need to update the secret later).
- **Scopes:** check **`write:package`** and **`read:package`**.
7. Click **Generate**.
8. **Copy the token** — it will be shown only once! Save it securely.
### Creating a release
1. **Update `CHANGELOG.md`** with changes for the new version in the `develop` branch.
2. **Commit and push** your changes to `develop`:
```bash
git checkout develop
git add -A
git commit -m "Feature: new functionality"
git push origin develop
```
3. **Create a Pull Request** from `develop` → `main`:
Open `https://gitea.jambotron.com/jambotron_gang/htop-web/compare/develop...main` and click **Create Pull Request**.
4. **Merge the PR** into `main`.
5. **Drone** will automatically:
- Build the Docker image using the multi-stage Dockerfile
- Tag it with the short commit SHA (e.g., `abc1234`) and `main-latest`
- Push both tags to the Gitea Container Registry
### Docker image tags
Each merge into `main` produces two Docker image tags:
| Tag | Example | Description |
|-----|---------|-------------|
| Commit SHA | `gitea.jambotron.com/liosha84/htop-web:abc1234` | Exact commit, use for reproducibility |
| Main latest | `gitea.jambotron.com/liosha84/htop-web:main-latest` | Most recent merge into main |
### Using a specific version in production
In `docker-compose.yml`, pin to a specific commit SHA:
```yaml
services:
htop-web:
image: gitea.jambotron.com/liosha84/htop-web:abc1234
```
Use `main-latest` for development/testing environments. For production, always use the commit SHA tag so you can roll back to a specific version.
## Contribution guidelines ## Contribution guidelines
- Keep changes focused and reviewable. - Keep changes focused and reviewable.
+42 -15
View File
@@ -4,11 +4,35 @@
- Docker Engine - Docker Engine
- Docker Compose - 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 ```bash
cd backend 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. 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 ## Check status and logs
```bash ```bash
@@ -43,16 +57,29 @@ docker compose down
## Simple Scenario of using ## Simple Scenario of using
```bash ```yaml
services: services:
htop-web: htop-web:
image: gitea.jambotron.com/liosha84/htop-web:latest image: gitea.jambotron.com/liosha84/htop-web:latest
container_name: htop-web container_name: htop-web
restart: unless-stopped restart: unless-stopped
pid: host pid: host
read_only: true
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=64m
volumes: volumes:
- /proc:/host/proc:ro
- /sys:/host/sys: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: labels:
caddy: htop-web.jambotron.com caddy: htop-web.jambotron.com
caddy.reverse_proxy: "{{upstreams 8080}}" caddy.reverse_proxy: "{{upstreams 8080}}"
@@ -18,7 +18,7 @@ export class SystemMetricsService {
private readonly refreshInterval = 1000; private readonly refreshInterval = 1000;
readonly metrics$: Observable<SystemMetrics> = timer(0, this.refreshInterval).pipe( readonly metrics$: Observable<SystemMetrics | null> = timer(0, this.refreshInterval).pipe(
switchMap(() => switchMap(() =>
this.http.get<SystemMetrics>('/api/system/metrics').pipe( this.http.get<SystemMetrics>('/api/system/metrics').pipe(
catchError((error: unknown) => { 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 }), 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( readonly processes$: Observable<SystemProcess[]> = timer(0, this.refreshInterval).pipe(