бла бла бла

This commit is contained in:
2026-10-28 01:03:18 +01:00
parent 334abce357
commit 99185fa392
24 changed files with 1681 additions and 981 deletions
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PortForwardingSettings">
<ports>
<entry key="8080">
<ForwardedPortInfo>
<option name="hostPort" value="8080" />
<option name="readOnly" value="false" />
</ForwardedPortInfo>
</entry>
</ports>
</component>
</project>
+2 -1
View File
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/backend" />
<option name="gradleJvm" value="#JAVA_HOME" />
<option name="gradleJvm" value="21" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/backend" />
+4 -1
View File
@@ -1,7 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2">
<component name="FrameworkDetectionExcludesConfiguration">
<file type="web" url="file://$PROJECT_DIR$/backend" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>
+1
View File
@@ -18,6 +18,7 @@ repositories {
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
testImplementation 'org.springframework.boot:spring-boot-starter-webflux-test'
@@ -1,9 +1,7 @@
package com.htopweb.system;
import com.htopweb.system.linux.LinuxDetailsResponse;
import com.htopweb.system.linux.LinuxDetailsService;
import com.htopweb.system.linux.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@@ -15,12 +13,25 @@ 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;
public SystemMetricsController(
SystemMetricsService systemMetricsService,
LinuxDetailsService linuxDetailsService
LinuxDetailsService linuxDetailsService,
CpuDetailsService cpuDetailsService,
LinuxMemoryService linuxMemoryService, LinuxDiskService linuxDiskService, LinuxNetworkService linuxNetworkService
) {
_systemMetricsService = systemMetricsService;
_linuxDetailsService = linuxDetailsService;
_cpuDetailsService = cpuDetailsService;
_linuxMemoryService = linuxMemoryService;
_linuxDiskService = linuxDiskService;
_linuxNetworkService = linuxNetworkService;
}
@GetMapping("/metrics")
@@ -32,4 +43,24 @@ public class SystemMetricsController {
public LinuxDetailsResponse getLinuxDetails() {
return _linuxDetailsService.getDetails();
}
@GetMapping("/cpu")
public CpuDetailsResponse getCpuDetails() {
return _cpuDetailsService.getDetails();
}
@GetMapping("/linux/memory")
public LinuxMemoryResponse getLinuxMemory() {
return _linuxMemoryService.getDetails();
}
@GetMapping("/linux/disks")
public java.util.List<LinuxDiskResponse> getLinuxDisks() {
return _linuxDiskService.getDisks();
}
@GetMapping("/linux/network")
public java.util.List<LinuxNetworkResponse> getLinuxNetwork() {
return _linuxNetworkService.getInterfaces();
}
}
@@ -0,0 +1,14 @@
package com.htopweb.system.linux;
import java.util.List;
public record CpuDetailsResponse(
double totalUsagePercent,
int logicalProcessors,
List<CpuCoreResponse> cores
) {
public record CpuCoreResponse(
int core,
double usagePercent
) {}
}
@@ -0,0 +1,68 @@
package com.htopweb.system.linux;
import oshi.SystemInfo;
import oshi.hardware.CentralProcessor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
@Service
public class CpuDetailsService {
private final CentralProcessor _processor;
private long[][] _previousCoreTicks;
private long[] _previousSystemTicks;
public CpuDetailsService() {
SystemInfo systemInfo = new SystemInfo();
_processor = systemInfo.getHardware().getProcessor();
_previousCoreTicks = _processor.getProcessorCpuLoadTicks();
_previousSystemTicks = _processor.getSystemCpuLoadTicks();
}
public synchronized CpuDetailsResponse getDetails() {
long[][] currentCoreTicks = _processor.getProcessorCpuLoadTicks();
long[] currentSystemTicks = _processor.getSystemCpuLoadTicks();
double[] coreLoads = _processor.getProcessorCpuLoadBetweenTicks(
_previousCoreTicks,
currentCoreTicks
);
double systemLoad = _processor.getSystemCpuLoadBetweenTicks(
_previousSystemTicks,
currentSystemTicks
);
_previousCoreTicks = currentCoreTicks;
_previousSystemTicks = currentSystemTicks;
List<CpuDetailsResponse.CpuCoreResponse> cores =
new ArrayList<>(coreLoads.length);
for (int i = 0; i < coreLoads.length; i++) {
cores.add(new CpuDetailsResponse.CpuCoreResponse(
i,
toPercent(coreLoads[i] * 100.0)
));
}
return new CpuDetailsResponse(
toPercent(systemLoad * 100.0),
_processor.getLogicalProcessorCount(),
List.copyOf(cores)
);
}
private double toPercent(double value) {
if (!Double.isFinite(value)) {
return 0.0;
}
return Math.round(
Math.max(0.0, Math.min(100.0, value)) * 10.0
) / 10.0;
}
}
@@ -0,0 +1,11 @@
package com.htopweb.system.linux;
public record LinuxDiskResponse(
String name,
String mountPoint,
String fileSystem,
long totalBytes,
long usableBytes,
long usedBytes,
double usagePercent
) {}
@@ -0,0 +1,54 @@
package com.htopweb.system.linux;
import oshi.SystemInfo;
import oshi.software.os.OSFileStore;
import oshi.software.os.OperatingSystem;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.List;
@Service
public class LinuxDiskService {
private final OperatingSystem _operatingSystem;
public LinuxDiskService() {
SystemInfo systemInfo = new SystemInfo();
_operatingSystem = systemInfo.getOperatingSystem();
}
public List<LinuxDiskResponse> getDisks() {
return _operatingSystem.getFileSystem()
.getFileStores()
.stream()
.filter(store -> store.getMount().startsWith("/"))
.filter(store -> store.getTotalSpace() > 0)
.map(this::toResponse)
.sorted(Comparator.comparing(LinuxDiskResponse::mountPoint))
.toList();
}
private LinuxDiskResponse toResponse(OSFileStore store) {
long total = Math.max(0L, store.getTotalSpace());
long usable = Math.max(
0L,
Math.min(total, store.getUsableSpace())
);
long used = Math.max(0L, total - usable);
double usage = total > 0
? Math.round((double) used / total * 1000.0) / 10.0
: 0.0;
return new LinuxDiskResponse(
store.getName(),
store.getMount(),
store.getType(),
total,
usable,
used,
usage
);
}
}
@@ -0,0 +1,12 @@
package com.htopweb.system.linux;
public record LinuxMemoryResponse(
long totalRamBytes,
long usedRamBytes,
long availableRamBytes,
double ramUsagePercent,
long totalSwapBytes,
long usedSwapBytes,
long freeSwapBytes,
double swapUsagePercent
) {}
@@ -0,0 +1,53 @@
package com.htopweb.system.linux;
import oshi.SystemInfo;
import oshi.hardware.GlobalMemory;
import oshi.hardware.VirtualMemory;
import org.springframework.stereotype.Service;
@Service
public class LinuxMemoryService {
private final GlobalMemory _memory;
private final VirtualMemory _virtualMemory;
public LinuxMemoryService() {
SystemInfo systemInfo = new SystemInfo();
_memory = systemInfo.getHardware().getMemory();
_virtualMemory = _memory.getVirtualMemory();
}
public LinuxMemoryResponse getDetails() {
long totalRam = Math.max(0L, _memory.getTotal());
long availableRam = Math.max(0L, Math.min(totalRam, _memory.getAvailable()));
long usedRam = Math.max(0L, totalRam - availableRam);
long totalSwap = Math.max(0L, _virtualMemory.getSwapTotal());
long usedSwap = Math.max(
0L,
Math.min(totalSwap, _virtualMemory.getSwapUsed())
);
long freeSwap = Math.max(0L, totalSwap - usedSwap);
return new LinuxMemoryResponse(
totalRam,
usedRam,
availableRam,
percentage(usedRam, totalRam),
totalSwap,
usedSwap,
freeSwap,
percentage(usedSwap, totalSwap)
);
}
private double percentage(long used, long total) {
if (total <= 0) {
return 0.0;
}
return Math.round((double) used / total * 1000.0) / 10.0;
}
}
@@ -0,0 +1,22 @@
package com.htopweb.system.linux;
import java.util.List;
public record LinuxNetworkResponse(
String name,
String displayName,
String macAddress,
List<String> ipv4Addresses,
List<String> ipv6Addresses,
long bytesReceived,
long bytesSent,
long packetsReceived,
long packetsSent,
long inboundErrors,
long outboundErrors,
double receiveBytesPerSecond,
double sendBytesPerSecond,
double receiveMbps,
double sendMbps
) {}
@@ -0,0 +1,93 @@
package com.htopweb.system.linux;
import oshi.SystemInfo;
import oshi.hardware.NetworkIF;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@Service
public class LinuxNetworkService {
private final SystemInfo _systemInfo;
private final ConcurrentMap<String, NetworkSnapshot> _previousSnapshots =
new ConcurrentHashMap<>();
public LinuxNetworkService() {
_systemInfo = new SystemInfo();
}
public List<LinuxNetworkResponse> getInterfaces() {
return _systemInfo.getHardware()
.getNetworkIFs()
.stream()
.map(this::toResponse)
.toList();
}
private LinuxNetworkResponse toResponse(NetworkIF networkIF) {
networkIF.updateAttributes();
String name = networkIF.getName();
long received = Math.max(0L, networkIF.getBytesRecv());
long sent = Math.max(0L, networkIF.getBytesSent());
long now = System.nanoTime();
NetworkSnapshot current = new NetworkSnapshot(received, sent, now);
NetworkSnapshot previous = _previousSnapshots.put(name, current);
double receiveBytesPerSecond = 0.0;
double sendBytesPerSecond = 0.0;
if (previous != null && now > previous.timestampNanos()) {
double elapsedSeconds =
(now - previous.timestampNanos()) / 1_000_000_000.0;
if (received >= previous.bytesReceived()) {
receiveBytesPerSecond =
(received - previous.bytesReceived()) / elapsedSeconds;
}
if (sent >= previous.bytesSent()) {
sendBytesPerSecond =
(sent - previous.bytesSent()) / elapsedSeconds;
}
}
double receiveMbps = receiveBytesPerSecond * 8.0 / 1_000_000.0;
double sendMbps = sendBytesPerSecond * 8.0 / 1_000_000.0;
return new LinuxNetworkResponse(
name,
networkIF.getDisplayName(),
networkIF.getMacaddr(),
Arrays.asList(networkIF.getIPv4addr()),
Arrays.asList(networkIF.getIPv6addr()),
received,
sent,
Math.max(0L, networkIF.getPacketsRecv()),
Math.max(0L, networkIF.getPacketsSent()),
Math.max(0L, networkIF.getInErrors()),
Math.max(0L, networkIF.getOutErrors()),
roundOne(receiveBytesPerSecond),
roundOne(sendBytesPerSecond),
roundOne(receiveMbps),
roundOne(sendMbps)
);
}
private double roundOne(double value) {
return Math.round(value * 10.0) / 10.0;
}
private record NetworkSnapshot(
long bytesReceived,
long bytesSent,
long timestampNanos
) {}
}
+1 -1
View File
@@ -9,5 +9,5 @@
<link rel="stylesheet" href="styles-5INURTSO.css"></head>
<body>
<app-root></app-root>
<script src="main-7OF6PXJ5.js" type="module"></script></body>
<script src="main-Y67327X6.js" type="module"></script></body>
</html>
+1 -1
View File
@@ -44,7 +44,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "10kb",
"maximumError": "12kb"
"maximumError": "30kb"
}
],
"outputHashing": "all"
@@ -0,0 +1,10 @@
export interface CpuCore {
core: number;
usagePercent: number;
}
export interface CpuDetails {
totalUsagePercent: number;
logicalProcessors: number;
cores: CpuCore[];
}
@@ -0,0 +1,11 @@
export interface LinuxDetails {
linux: boolean;
hostname: string;
distribution: string;
kernelVersion: string;
uptimeSeconds: number;
loadAverage1Min: number;
loadAverage5Min: number;
loadAverage15Min: number;
logicalProcessors: number;
}
@@ -0,0 +1,9 @@
export interface LinuxDisk {
name: string;
mountPoint: string;
fileSystem: string;
totalBytes: number;
usableBytes: number;
usedBytes: number;
usagePercent: number;
}
@@ -0,0 +1,10 @@
export interface LinuxMemory {
totalRamBytes: number;
usedRamBytes: number;
availableRamBytes: number;
ramUsagePercent: number;
totalSwapBytes: number;
usedSwapBytes: number;
freeSwapBytes: number;
swapUsagePercent: number;
}
@@ -0,0 +1,17 @@
export interface LinuxNetwork {
name: string;
displayName: string;
macAddress: string;
ipv4Addresses: string[];
ipv6Addresses: string[];
bytesReceived: number;
bytesSent: number;
packetsReceived: number;
packetsSent: number;
inboundErrors: number;
outboundErrors: number;
receiveBytesPerSecond: number;
sendBytesPerSecond: number;
receiveMbps: number;
sendMbps: number;
}
@@ -1,9 +1,14 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, timer, switchMap, shareReplay, catchError, of } from 'rxjs';
import { Observable, timer, switchMap, shareReplay, catchError, of, map } from 'rxjs';
import { SystemMetrics } from '../models/system-metrics.model';
import { SystemProcess } from '../models/system-process.model';
import { LinuxDetails } from '../models/linux-details.model';
import { CpuDetails } from '../models/cpu-details.model';
import { LinuxMemory } from '../models/linux-memory.model';
import { LinuxDisk } from '../models/linux-disk.model';
import { LinuxNetwork } from '../models/linux-network.model';
@Injectable({
providedIn: 'root',
@@ -11,22 +16,95 @@ import { SystemProcess } from '../models/system-process.model';
export class SystemMetricsService {
private readonly http = inject(HttpClient);
readonly metrics$: Observable<SystemMetrics> = timer(0, 1000).pipe(
switchMap(() => this.http.get<SystemMetrics>('/api/system/metrics')),
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);
}),
),
),
// Ignore failed requests without terminating the stream.
map((value) => value),
// Keep the last successful response for subscribers.
shareReplay({ bufferSize: 1, refCount: true }),
// The public type remains SystemMetrics below via the typed getter.
map((value) => value as SystemMetrics),
);
readonly processes$: Observable<SystemProcess[]> = timer(0, 1000).pipe(
readonly processes$: Observable<SystemProcess[]> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<SystemProcess[] | { value: SystemProcess[] }>('/api/system/processes').pipe(
map((response) => (Array.isArray(response) ? response : (response.value ?? []))),
catchError((error: unknown) => {
console.error('Failed to load system processes', error);
return of([] as SystemProcess[]);
}),
),
),
// Normalize either a plain array or a response containing "value".
switchMap((response) => of(Array.isArray(response) ? response : response.value)),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly linux$: Observable<LinuxDetails | null> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<LinuxDetails>('/api/system/linux').pipe(
catchError((error: unknown) => {
console.error('Failed to load Linux details', error);
return of(null);
}),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly cpu$: Observable<CpuDetails | null> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<CpuDetails>('/api/system/cpu').pipe(
catchError((error: unknown) => {
console.error('Failed to load CPU details', error);
return of(null);
}),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly memory$: Observable<LinuxMemory | null> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<LinuxMemory>('/api/system/linux/memory').pipe(
catchError((error: unknown) => {
console.error('Failed to load memory details', error);
return of(null);
}),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly disks$: Observable<LinuxDisk[]> = timer(0, 10_000).pipe(
switchMap(() =>
this.http.get<LinuxDisk[]>('/api/system/linux/disks').pipe(
catchError((error: unknown) => {
console.error('Failed to load disk details', error);
return of([] as LinuxDisk[]);
}),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
readonly network$: Observable<LinuxNetwork[]> = timer(0, this.refreshInterval).pipe(
switchMap(() =>
this.http.get<LinuxNetwork[]>('/api/system/linux/network').pipe(
catchError((error: unknown) => {
console.error('Failed to load network details', error);
return of([] as LinuxNetwork[]);
}),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
}
+363 -254
View File
@@ -1,297 +1,406 @@
<main class="dashboard" [class.htop-mode]="viewMode === 'htop'">
<header class="page-header">
<main
class="dashboard"
[class.htop-mode]="viewMode === 'htop'"
[class.htop-original-mode]="viewMode === 'htop-original'"
>
<nav class="view-switcher">
<button
type="button"
[class.view-active]="viewMode === 'monitor'"
(click)="setViewMode('monitor')"
>Monitor</button>
<button
type="button"
[class.view-active]="viewMode === 'htop'"
(click)="setViewMode('htop')"
>htop</button>
<button
type="button"
[class.view-active]="viewMode === 'htop-original'"
(click)="setViewMode('htop-original')"
>htop-original</button>
</nav>
@if (viewMode === 'monitor') {
<section class="monitor-view">
@if (metrics$ | async; as metrics) {
<header class="monitor-heading">
<div>
<h1>htop-web</h1>
<p>System monitoring dashboard</p>
<span class="eyebrow">SYSTEM MONITOR</span>
<h1>{{ metrics.hostname }}</h1>
<p>{{ metrics.operatingSystem }} · {{ metrics.osVersion }}</p>
</div>
<div class="header-actions">
<div class="view-switcher" role="group" aria-label="Dashboard view">
<button
type="button"
[class.active]="viewMode === 'monitor'"
[attr.aria-pressed]="viewMode === 'monitor'"
(click)="setViewMode('monitor')">
<span>▦</span>
Monitor
</button>
<button
type="button"
[class.active]="viewMode === 'htop'"
[attr.aria-pressed]="viewMode === 'htop'"
(click)="setViewMode('htop')">
<span>▤</span>
htop
</button>
</div>
<span class="live-indicator">
<span class="live-dot"></span>
Live monitoring
</span>
<div class="status-indicator">
<span></span> Live
</div>
</header>
@if (metrics$ | async; as metrics) {
@if (viewMode === 'monitor') {
<section class="system-info">
<div>
<span class="label">HOSTNAME</span>
<strong>{{ metrics.hostname }}</strong>
</div>
<div>
<span class="label">OPERATING SYSTEM</span>
<strong>{{ metrics.operatingSystem }} {{ metrics.osVersion }}</strong>
</div>
<div>
<span class="label">PROCESSOR</span>
<strong>{{ metrics.processor }}</strong>
<span class="muted">
{{ metrics.logicalProcessors }} logical processors
</span>
</div>
</section>
<section class="metrics-grid">
<article class="metric-card">
<div class="card-heading">
<span class="metric-icon cpu-icon">CPU</span>
<span class="muted">Processor load</span>
</div>
<div class="metric-value">
{{ metrics.cpuUsagePercent | number:'1.1-1' }}<span>%</span>
</div>
<div
class="progress-track"
role="progressbar"
aria-label="CPU usage"
[attr.aria-valuenow]="metrics.cpuUsagePercent"
aria-valuemin="0"
aria-valuemax="100">
<div class="monitor-cards">
<article class="monitor-card">
<span class="card-label">CPU USAGE</span>
<strong>{{ metrics.cpuUsagePercent | number:'1.1-1' }}%</strong>
<div class="progress-track">
<div
class="progress-fill cpu-fill"
[style.width.%]="clampPercent(metrics.cpuUsagePercent)">
[style.width.%]="clampPercent(metrics.cpuUsagePercent)"
></div>
</div>
</div>
<p class="card-footer">Total CPU utilization</p>
<span class="card-caption">{{ metrics.logicalProcessors }} logical processors</span>
</article>
<article class="metric-card">
<div class="card-heading">
<span class="metric-icon memory-icon">RAM</span>
<span class="muted">Physical memory</span>
</div>
<div class="metric-value">
{{ metrics.memoryUsagePercent | number:'1.1-1' }}<span>%</span>
</div>
<div
class="progress-track"
role="progressbar"
aria-label="Memory usage"
[attr.aria-valuenow]="metrics.memoryUsagePercent"
aria-valuemin="0"
aria-valuemax="100">
<article class="monitor-card">
<span class="card-label">MEMORY USAGE</span>
<strong>{{ metrics.memoryUsagePercent | number:'1.1-1' }}%</strong>
<div class="progress-track">
<div
class="progress-fill memory-fill"
[style.width.%]="clampPercent(metrics.memoryUsagePercent)">
[style.width.%]="clampPercent(metrics.memoryUsagePercent)"
></div>
</div>
</div>
<div class="memory-details">
<span>Used: {{ formatBytes(metrics.usedMemoryBytes) }}</span>
<span>Total: {{ formatBytes(metrics.totalMemoryBytes) }}</span>
</div>
<p class="card-footer">
Available: {{ formatBytes(metrics.availableMemoryBytes) }}
</p>
<span class="card-caption">
{{ formatBytes(metrics.usedMemoryBytes) }} / {{ formatBytes(metrics.totalMemoryBytes) }}
</span>
</article>
<article class="monitor-card">
<span class="card-label">AVAILABLE MEMORY</span>
<strong>{{ formatBytes(metrics.availableMemoryBytes) }}</strong>
<span class="card-caption">Currently available</span>
</article>
</div>
@if (cpu$ | async; as cpu) {
<section class="monitor-section">
<h2>CPU cores</h2>
<div class="core-cards">
@for (core of cpu.cores; track core.core) {
<article class="core-card">
<div class="core-card-heading">
<span>Core {{ core.core }}</span>
<strong>{{ core.usagePercent | number:'1.1-1' }}%</strong>
</div>
<div class="progress-track">
<div
class="progress-fill cpu-fill"
[style.width.%]="clampPercent(core.usagePercent)"
></div>
</div>
</article>
}
</div>
</section>
}
} @else {
<section class="htop-system-panel">
<div class="htop-system-heading">
<div>
<span class="htop-brand">HTOP-WEB</span>
<h2>{{ metrics.hostname }}</h2>
</div>
<p class="loading-state">Waiting for system metrics…</p>
}
<div class="htop-os">
{{ metrics.operatingSystem }} {{ metrics.osVersion }}
@if (linux$ | async; as linux) {
<section class="monitor-section system-info">
<h2>System information</h2>
<div class="system-info-grid">
<div><span>Hostname</span><strong>{{ linux.hostname }}</strong></div>
<div><span>Distribution</span><strong>{{ linux.distribution }}</strong></div>
<div><span>Kernel</span><strong>{{ linux.kernelVersion }}</strong></div>
<div><span>Uptime</span><strong>{{ formatUptime(linux.uptimeSeconds) }}</strong></div>
<div><span>Load 1 min</span><strong>{{ linux.loadAverage1Min | number:'1.2-2' }}</strong></div>
<div><span>Load 5 min</span><strong>{{ linux.loadAverage5Min | number:'1.2-2' }}</strong></div>
<div><span>Load 15 min</span><strong>{{ linux.loadAverage15Min | number:'1.2-2' }}</strong></div>
</div>
</div>
<div class="htop-resource">
<div class="htop-resource-label">
<span>CPU</span>
<strong>{{ metrics.cpuUsagePercent | number:'1.1-1' }}%</strong>
</div>
</section>
}
</section>
} @else if (viewMode === 'htop') {
<section class="classic-htop">
@if (cpu$ | async; as cpu) {
<div class="classic-cpu-grid">
@for (core of cpu.cores; track core.core) {
<div class="classic-meter-row">
<span class="classic-label">{{ core.core }}</span>
<div class="classic-meter-track">
<div
class="htop-bar"
role="progressbar"
aria-label="CPU usage"
[attr.aria-valuenow]="metrics.cpuUsagePercent"
aria-valuemin="0"
aria-valuemax="100">
<div
class="htop-bar-fill htop-cpu-fill"
[style.width.%]="clampPercent(metrics.cpuUsagePercent)">
class="classic-meter-fill"
[style.width.%]="clampPercent(core.usagePercent)"
></div>
</div>
<strong>{{ core.usagePercent | number:'1.0-0' }}%</strong>
</div>
}
</div>
}
<div class="htop-resource-meta">
{{ metrics.processor }}
· {{ metrics.logicalProcessors }} logical processors
</div>
</div>
<div class="htop-resource">
<div class="htop-resource-label">
@if (memory$ | async; as memory) {
<div class="classic-memory-row">
<span>Mem</span>
<strong>
{{ formatBytes(metrics.usedMemoryBytes) }}
/ {{ formatBytes(metrics.totalMemoryBytes) }}
</strong>
</div>
<div class="classic-meter-track">
<div
class="htop-bar"
role="progressbar"
aria-label="Memory usage"
[attr.aria-valuenow]="metrics.memoryUsagePercent"
aria-valuemin="0"
aria-valuemax="100">
class="classic-memory-fill"
[style.width.%]="clampPercent(memory.ramUsagePercent)"
></div>
</div>
<strong>{{ formatBytes(memory.usedRamBytes) }} / {{ formatBytes(memory.totalRamBytes) }}</strong>
</div>
<div class="classic-memory-row">
<span>Swp</span>
<div class="classic-meter-track">
<div
class="htop-bar-fill htop-memory-fill"
[style.width.%]="clampPercent(metrics.memoryUsagePercent)">
class="classic-swap-fill"
[style.width.%]="clampPercent(memory.swapUsagePercent)"
></div>
</div>
<strong>{{ formatBytes(memory.usedSwapBytes) }} / {{ formatBytes(memory.totalSwapBytes) }}</strong>
</div>
<div class="htop-resource-meta">
{{ metrics.memoryUsagePercent | number:'1.1-1' }}% used
· {{ formatBytes(metrics.availableMemoryBytes) }} available
</div>
</div>
</section>
}
} @else {
<section class="loading">
<div class="spinner"></div>
<p>Connecting to system metrics...</p>
</section>
}
<section class="processes-section">
<header class="processes-header">
<div>
<h2>{{ viewMode === 'htop' ? 'Tasks / Processes' : 'Processes' }}</h2>
<p class="muted">
{{ viewMode === 'htop'
? 'Interactive process overview'
: 'Live system process monitor' }}
</p>
@if (linux$ | async; as linux) {
<div class="classic-system-line">
<span>Load average</span>
{{ linux.loadAverage1Min | number:'1.2-2' }}
{{ linux.loadAverage5Min | number:'1.2-2' }}
{{ linux.loadAverage15Min | number:'1.2-2' }}
<span>Uptime</span>
{{ formatUptime(linux.uptimeSeconds) }}
</div>
@if (processes$ | async; as processes) {
<span class="process-count">
{{ getSortedProcesses(processes).length }} processes
</span>
}
<section class="classic-processes">
<header class="classic-process-heading">
<span>PID</span><span>Command</span><span>CPU%</span><span>MEMORY</span><span>STATE</span>
</header>
<label class="process-search">
<span>Search</span>
<input
#processSearch
type="search"
placeholder="Process name or PID..."
[value]="processFilter"
(input)="processFilter = processSearch.value">
</label>
@if (processes$ | async; as processes) {
<div class="table-scroll">
<table class="process-table">
<thead>
<tr>
<th>
<button type="button" (click)="sortBy('pid')">PID</button>
</th>
<th>
<button type="button" (click)="sortBy('name')">Process</button>
</th>
<th>State</th>
<th class="numeric">
<button type="button" (click)="sortBy('cpu')">CPU %</button>
</th>
<th class="numeric">
<button type="button" (click)="sortBy('memory')">Memory</button>
</th>
<th class="numeric">Virtual memory</th>
<th class="numeric">Threads</th>
<th>Uptime</th>
</tr>
</thead>
<tbody>
@for (process of getSortedProcesses(processes); track process.pid) {
<tr>
<td class="pid-cell">{{ process.pid }}</td>
<td class="process-name">{{ process.name }}</td>
<td>
<span class="process-state">{{ process.state }}</span>
</td>
<td class="numeric">
<span
class="cpu-value"
[class.cpu-high]="process.cpuUsagePercent >= 80">
{{ process.cpuUsagePercent | number:'1.1-1' }}%
</span>
<div class="mini-progress">
<div
class="mini-progress-fill"
[style.width.%]="clampPercent(process.cpuUsagePercent)">
<div class="classic-process-row">
<span>{{ process.pid }}</span>
<span class="classic-command">{{ process.name }}</span>
<span>{{ process.cpuUsagePercent | number:'1.1-1' }}</span>
<span>{{ formatBytes(process.residentMemoryBytes) }}</span>
<span>{{ process.state }}</span>
</div>
</div>
</td>
<td class="numeric">
{{ formatBytes(process.residentMemoryBytes) }}
</td>
<td class="numeric">
{{ formatBytes(process.virtualMemoryBytes) }}
</td>
<td class="numeric">{{ process.threadCount }}</td>
<td>{{ formatUptime(process.uptimeSeconds) }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="empty-processes">
No matching processes found.
</td>
</tr>
<p class="loading-state">No processes available</p>
}
</tbody>
</table>
</div>
} @else {
<div class="loading">
<p>Loading processes...</p>
</div>
}
</section>
</section>
} @else {
<!-- htop-original: styling follows the supplied reference -->
<section class="htop-original">
@if (cpu$ | async; as cpu) {
@if (memory$ | async; as memory) {
@if (linux$ | async; as linux) {
<header class="original-header">
<div class="original-meters">
<div class="original-cpu-grid">
@for (core of cpu.cores; track core.core) {
<div class="original-meter-row">
<span class="original-cpu-number">{{ core.core }}</span>
<span class="meter-bracket">[</span>
<span class="original-meter-bar">
@for (segment of meterSegments20; track segment) {
<span
[class.pipe-used]="segment < core.usagePercent / 5"
[class.pipe-bg]="segment >= core.usagePercent / 5"
>|</span>
}
</span>
<span class="meter-bracket">]</span>
<span class="original-meter-value">{{ core.usagePercent | number:'1.1-1' }}%</span>
</div>
}
</div>
<footer class="dashboard-footer">
<span>htop-web</span>
<span>Refresh interval: 500 ms</span>
<div class="original-meter-row">
<span class="original-meter-label">Mem</span>
<span class="meter-bracket">[</span>
<span class="original-meter-bar">
@for (segment of meterSegments35; track segment) {
<span
[class.pipe-used]="segment < memory.ramUsagePercent / 100 * 35"
[class.pipe-bg]="segment >= memory.ramUsagePercent / 100 * 35"
>|</span>
}
</span>
<span class="meter-bracket">]</span>
<span class="original-meter-value">
{{ formatBytes(memory.usedRamBytes) }}/{{ formatBytes(memory.totalRamBytes) }}
</span>
</div>
<div class="original-meter-row">
<span class="original-meter-label">Swp</span>
<span class="meter-bracket">[</span>
<span class="original-meter-bar">
@for (segment of meterSegments15; track segment) {
<span
[class.pipe-used]="memory.totalSwapBytes > 0 && segment < memory.swapUsagePercent / 100 * 15"
[class.pipe-bg]="memory.totalSwapBytes <= 0 || segment >= memory.swapUsagePercent / 100 * 15"
>|</span>
}
</span>
<span class="meter-bracket">]</span>
<span class="original-meter-value">
{{ formatBytes(memory.usedSwapBytes) }}/{{ formatBytes(memory.totalSwapBytes) }}
</span>
</div>
</div>
<div class="original-system-summary">
@if (processes$ | async; as processes) {
<div>
<span class="original-label">Tasks:</span>
<span class="original-white">{{ processes.length }}</span>,
<span class="original-white">—</span>
<span class="original-dim">thr</span>;
<span class="original-white">—</span>
<span class="original-dim">kthr</span>;
<span class="original-green">{{ countRunningProcesses(processes) }} running</span>
</div>
}
<div class="original-summary-row">
<span class="original-label">Load average:</span>
<span class="original-white">{{ linux.loadAverage1Min | number:'1.2-2' }}</span>
<span class="original-white">{{ linux.loadAverage5Min | number:'1.2-2' }}</span>
<span class="original-white">{{ linux.loadAverage15Min | number:'1.2-2' }}</span>
</div>
<div class="original-summary-row">
<span class="original-label">Uptime:</span>
<span class="original-green">{{ formatUptime(linux.uptimeSeconds) }}</span>
</div>
<div class="original-navigation-hint">
<span>Navigation:</span> ↑/↓ (or J/K) Select |
<span>/</span> Filter |
<span>K</span> Kill |
<span>P</span> CPU Sort |
<span>M</span> MEM Sort
</div>
</div>
</header>
}
}
}
<nav class="original-tabs" role="tablist" aria-label="htop views">
<button
type="button"
role="tab"
[attr.aria-selected]="htopOriginalTab === 'main'"
[class.original-tab-active]="htopOriginalTab === 'main'"
[class.original-tab-inactive]="htopOriginalTab !== 'main'"
(click)="setHtopOriginalTab('main')"
>Main</button>
<button
type="button"
role="tab"
[attr.aria-selected]="htopOriginalTab === 'io'"
[class.original-tab-active]="htopOriginalTab === 'io'"
[class.original-tab-inactive]="htopOriginalTab !== 'io'"
(click)="setHtopOriginalTab('io')"
>I/O</button>
<input
id="original-process-filter"
class="original-filter-input"
type="text"
placeholder="Type to filter..."
[value]="processFilter"
(input)="onOriginalFilterInput($event)"
/>
@if (processFilter) {
<button type="button" class="original-clear-filter" (click)="clearProcessFilter()">✕</button>
}
</nav>
@if (htopOriginalTab === 'main') {
<section class="original-process-panel">
<div class="original-process-grid original-process-heading">
<button type="button" (click)="sortBy('pid')">PID</button>
<span>USER</span>
<span>PRI</span>
<span>NI</span>
<span>VIRT</span>
<span>RES</span>
<span>SHR</span>
<span>S</span>
<button type="button" (click)="sortBy('cpu')">
CPU% {{ processSort === 'cpu' ? (sortDescending ? '▼' : '▲') : '' }}
</button>
<button type="button" (click)="sortBy('memory')">
MEM% {{ processSort === 'memory' ? (sortDescending ? '▼' : '▲') : '' }}
</button>
<span>TIME+</span>
<span>Command</span>
</div>
<div
class="original-process-list"
tabindex="0"
(keydown)="onOriginalProcessKeydown($event)"
>
@if (memory$ | async; as memory) {
@if (processes$ | async; as processes) {
@for (process of getSortedProcessesForOriginal(processes); track process.pid; let i = $index) {
<div
class="original-process-grid original-process-row"
[class.original-process-selected]="i === selectedOriginalProcessIndex"
(click)="selectOriginalProcess(i)"
>
<span class="original-teal">{{ process.pid }}</span>
<span class="original-dim">—</span>
<span class="original-dim">—</span>
<span class="original-dim">—</span>
<span class="original-teal">{{ formatBytes(process.virtualMemoryBytes) }}</span>
<span class="original-green">{{ formatBytes(process.residentMemoryBytes) }}</span>
<span class="original-teal">—</span>
<span [class.original-green]="process.state === 'R'">{{ process.state }}</span>
<span
[class.original-red]="process.cpuUsagePercent > 10"
[class.original-yellow]="process.cpuUsagePercent > 5 && process.cpuUsagePercent <= 10"
[class.original-green]="process.cpuUsagePercent <= 5"
>{{ process.cpuUsagePercent | number:'1.1-1' }}</span>
<span>{{ getProcessMemoryPercent(process, memory.totalRamBytes) | number:'1.1-1' }}</span>
<span class="original-teal">{{ formatUptime(process.uptimeSeconds) }}</span>
<span class="original-command" [title]="process.name">{{ process.name }}</span>
</div>
} @empty {
<div class="original-empty">No matching processes found</div>
}
}
}
</div>
</section>
} @else {
<section class="original-io-panel">
<h3>Disks</h3>
@if (disks$ | async; as disks) {
@for (disk of disks; track $index) {
<pre class="original-json-row">{{ disk | json }}</pre>
} @empty {
<p class="original-dim">No disk information available</p>
}
}
<h3>Network</h3>
@if (network$ | async; as networks) {
@for (network of networks; track $index) {
<pre class="original-json-row">{{ network | json }}</pre>
} @empty {
<p class="original-dim">No network information available</p>
}
}
</section>
}
<footer class="original-function-keys">
@for (key of originalFunctionKeys; track key.key) {
<button type="button" (click)="handleOriginalFunctionKey(key.key)">
<strong>{{ key.key }}</strong>
<span>{{ key.label }}</span>
</button>
}
</footer>
</section>
}
</main>
File diff suppressed because it is too large Load Diff
+120 -21
View File
@@ -1,15 +1,17 @@
import { AsyncPipe, DecimalPipe } from '@angular/common';
import { AsyncPipe, DecimalPipe, JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { SystemMetricsService } from '../core/services/system-metrics.service';
import { SystemProcess } from '../core/models/system-process.model';
type ProcessSort = 'cpu' | 'memory' | 'name' | 'pid';
type DashboardView = 'monitor' | 'htop';
type DashboardView = 'monitor' | 'htop' | 'htop-original';
type OriginalTab = 'main' | 'io';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [AsyncPipe, DecimalPipe],
imports: [AsyncPipe, DecimalPipe, JsonPipe],
templateUrl: './dashboard.html',
styleUrl: './dashboard.scss',
})
@@ -18,26 +20,54 @@ export class Dashboard {
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$;
readonly meterSegments20 = Array.from({ length: 20 }, (_, index) => index);
readonly meterSegments35 = Array.from({ length: 35 }, (_, index) => index);
readonly meterSegments15 = Array.from({ length: 15 }, (_, index) => index);
readonly originalFunctionKeys = [
{ key: 'F1', label: 'Help' },
{ key: 'F2', label: 'Setup' },
{ key: 'F3', label: 'Search' },
{ key: 'F4', label: 'Filter' },
{ key: 'F5', label: 'Tree' },
{ key: 'F6', label: 'SortBy' },
{ key: 'F7', label: 'Nice-' },
{ key: 'F8', label: 'Nice+' },
{ key: 'F9', label: 'Kill' },
{ key: 'F10', label: 'Quit' },
];
viewMode: DashboardView = 'monitor';
htopOriginalTab: OriginalTab = 'main';
processFilter = '';
processSort: ProcessSort = 'cpu';
sortDescending = true;
viewMode: DashboardView = 'monitor';
selectedOriginalProcessIndex = 0;
setViewMode(mode: DashboardView): void {
this.viewMode = mode;
}
setHtopOriginalTab(tab: OriginalTab): void {
this.htopOriginalTab = tab;
}
getSortedProcesses(processes: SystemProcess[]): SystemProcess[] {
const query = this.processFilter.trim().toLowerCase();
const filtered = processes.filter(
return processes
.filter(
(process) =>
process.name.toLowerCase().includes(query) || String(process.pid).includes(query),
);
return [...filtered].sort((a, b) => {
)
.sort((a, b) => {
let comparison: number;
switch (this.processSort) {
@@ -59,25 +89,48 @@ export class Dashboard {
});
}
getSortedProcessesForOriginal(processes: SystemProcess[]): SystemProcess[] {
return this.getSortedProcesses(processes);
}
sortBy(column: ProcessSort): void {
if (this.processSort === column) {
this.sortDescending = !this.sortDescending;
return;
}
} else {
this.processSort = column;
this.sortDescending = true;
}
}
countRunningProcesses(processes: SystemProcess[]): number {
return processes.filter((process) => process.state === 'R').length;
}
selectOriginalProcess(index: number): void {
this.selectedOriginalProcessIndex = index;
}
getProcessMemoryPercent(process: SystemProcess, totalMemoryBytes: number): number {
if (totalMemoryBytes <= 0) {
return 0;
}
return (process.residentMemoryBytes / totalMemoryBytes) * 100;
}
clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
}
formatBytes(bytes: number): string {
if (bytes <= 0) {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B';
}
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, index)).toFixed(2)} ${units[index]}`;
return `${(bytes / Math.pow(1024, index)).toFixed(1)}${units[index]}`;
}
formatUptime(seconds: number): string {
@@ -85,19 +138,65 @@ export class Dashboard {
const days = Math.floor(safeSeconds / 86400);
const hours = Math.floor((safeSeconds % 86400) / 3600);
const minutes = Math.floor((safeSeconds % 3600) / 60);
const remainingSeconds = safeSeconds % 60;
if (days > 0) {
return `${days}d ${hours}h`;
const time = [
String(hours).padStart(2, '0'),
String(minutes).padStart(2, '0'),
String(remainingSeconds).padStart(2, '0'),
].join(':');
return days > 0 ? `${days} days, ${time}` : time;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
handleOriginalFunctionKey(key: string): void {
switch (key) {
case 'F3':
case 'F4':
document.getElementById('original-process-filter')?.focus();
break;
case 'F6':
this.sortBy('cpu');
break;
case 'F10':
this.setViewMode('htop');
break;
default:
break;
}
}
return `${minutes}m`;
onOriginalFilterInput(event: Event): void {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.processFilter = target.value;
this.selectedOriginalProcessIndex = 0;
}
}
clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
clearProcessFilter(): void {
this.processFilter = '';
this.selectedOriginalProcessIndex = 0;
}
onOriginalProcessKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowDown' || event.key.toLowerCase() === 'j') {
event.preventDefault();
this.selectedOriginalProcessIndex++;
} else if (event.key === 'ArrowUp' || event.key.toLowerCase() === 'k') {
event.preventDefault();
this.selectedOriginalProcessIndex = Math.max(0, this.selectedOriginalProcessIndex - 1);
} else if (event.key === '/') {
event.preventDefault();
document.getElementById('original-process-filter')?.focus();
} else if (event.key.toLowerCase() === 'p') {
this.sortBy('cpu');
} else if (event.key.toLowerCase() === 'm') {
this.sortBy('memory');
}
}
}