Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46cd7c39c0 | ||
|
|
6b44066843 | ||
|
|
ffc1026ce1 | ||
|
|
0680557886 | ||
|
|
ea453bd409 | ||
|
|
3c85936c73 | ||
|
|
6666fca194 | ||
|
|
9d03b0977d | ||
|
|
3a0c22abf0 | ||
|
|
ed48cacfbc | ||
|
|
3f9ecc1113 | ||
|
|
eb73c55ac2 | ||
|
|
8369b2a6ba | ||
|
|
d4a6726930 | ||
|
|
0d6e0fa565 | ||
|
|
3e78dcff0f | ||
|
|
291167e683 | ||
|
|
42f606610a | ||
|
|
dc8c6b0dc6 | ||
|
|
7f2ea4f3b4 | ||
|
|
923b1e7c57 | ||
|
|
ee263d6224 | ||
|
|
cb1c8b9a2c | ||
|
|
02d43bd94a | ||
|
|
f3e095dafc | ||
|
|
597d8f380a | ||
|
|
539175654c | ||
|
|
04ee269f23 |
+27
-7
@@ -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
@@ -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
|
||||||
Generated
+7
@@ -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
@@ -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
@@ -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"]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
}
|
||||||
|
return new CpuDetailsResponse(0.0, cores.size(), List.copyOf(cores));
|
||||||
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)
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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.*
|
||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user