бла бла бла
This commit is contained in:
Generated
+13
@@ -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>
|
||||
Generated
+2
-1
@@ -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" />
|
||||
|
||||
Generated
+4
-1
@@ -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>
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user