бла бла бла

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
@@ -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 }),
);
}
+386 -277
View File
@@ -1,297 +1,406 @@
<main class="dashboard" [class.htop-mode]="viewMode === 'htop'">
<header class="page-header">
<div>
<h1>htop-web</h1>
<p>System monitoring dashboard</p>
</div>
<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>
<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>
</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="progress-fill cpu-fill"
[style.width.%]="clampPercent(metrics.cpuUsagePercent)">
</div>
</div>
<p class="card-footer">Total CPU utilization</p>
</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">
<div
class="progress-fill memory-fill"
[style.width.%]="clampPercent(metrics.memoryUsagePercent)">
</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>
</article>
</section>
} @else {
<section class="htop-system-panel">
<div class="htop-system-heading">
@if (viewMode === 'monitor') {
<section class="monitor-view">
@if (metrics$ | async; as metrics) {
<header class="monitor-heading">
<div>
<span class="htop-brand">HTOP-WEB</span>
<h2>{{ metrics.hostname }}</h2>
<span class="eyebrow">SYSTEM MONITOR</span>
<h1>{{ metrics.hostname }}</h1>
<p>{{ metrics.operatingSystem }} · {{ metrics.osVersion }}</p>
</div>
<div class="htop-os">
{{ metrics.operatingSystem }} {{ metrics.osVersion }}
<div class="status-indicator">
<span></span> Live
</div>
</div>
</header>
<div class="htop-resource">
<div class="htop-resource-label">
<span>CPU</span>
<div class="monitor-cards">
<article class="monitor-card">
<span class="card-label">CPU USAGE</span>
<strong>{{ metrics.cpuUsagePercent | number:'1.1-1' }}%</strong>
</div>
<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)">
<div class="progress-track">
<div
class="progress-fill cpu-fill"
[style.width.%]="clampPercent(metrics.cpuUsagePercent)"
></div>
</div>
</div>
<span class="card-caption">{{ metrics.logicalProcessors }} logical processors</span>
</article>
<div class="htop-resource-meta">
{{ metrics.processor }}
· {{ metrics.logicalProcessors }} logical processors
</div>
<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)"
></div>
</div>
<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>
<div class="htop-resource">
<div class="htop-resource-label">
<span>Mem</span>
<strong>
{{ formatBytes(metrics.usedMemoryBytes) }}
/ {{ formatBytes(metrics.totalMemoryBytes) }}
</strong>
</div>
<div
class="htop-bar"
role="progressbar"
aria-label="Memory usage"
[attr.aria-valuenow]="metrics.memoryUsagePercent"
aria-valuemin="0"
aria-valuemax="100">
<div
class="htop-bar-fill htop-memory-fill"
[style.width.%]="clampPercent(metrics.memoryUsagePercent)">
@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>
</div>
</section>
}
} @else {
<p class="loading-state">Waiting for system metrics…</p>
}
<div class="htop-resource-meta">
{{ metrics.memoryUsagePercent | number:'1.1-1' }}% used
· {{ formatBytes(metrics.availableMemoryBytes) }} available
@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>
</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="classic-meter-fill"
[style.width.%]="clampPercent(core.usagePercent)"
></div>
</div>
<strong>{{ core.usagePercent | number:'1.0-0' }}%</strong>
</div>
}
</div>
}
@if (memory$ | async; as memory) {
<div class="classic-memory-row">
<span>Mem</span>
<div class="classic-meter-track">
<div
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="classic-swap-fill"
[style.width.%]="clampPercent(memory.swapUsagePercent)"
></div>
</div>
<strong>{{ formatBytes(memory.usedSwapBytes) }} / {{ formatBytes(memory.totalSwapBytes) }}</strong>
</div>
}
@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>
}
<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>
@if (processes$ | async; as processes) {
@for (process of getSortedProcesses(processes); track process.pid) {
<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>
} @empty {
<p class="loading-state">No processes available</p>
}
}
</section>
}
</section>
} @else {
<section class="loading">
<div class="spinner"></div>
<p>Connecting to system metrics...</p>
<!-- 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>
<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>
}
<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>
</div>
@if (processes$ | async; as processes) {
<span class="process-count">
{{ getSortedProcesses(processes).length }} processes
</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>
</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>
}
</tbody>
</table>
</div>
} @else {
<div class="loading">
<p>Loading processes...</p>
</div>
}
</section>
<footer class="dashboard-footer">
<span>htop-web</span>
<span>Refresh interval: 500 ms</span>
</footer>
</main>
File diff suppressed because it is too large Load Diff
+141 -42
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,66 +20,117 @@ 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(
(process) =>
process.name.toLowerCase().includes(query) || String(process.pid).includes(query),
);
return processes
.filter(
(process) =>
process.name.toLowerCase().includes(query) || String(process.pid).includes(query),
)
.sort((a, b) => {
let comparison: number;
return [...filtered].sort((a, b) => {
let comparison: number;
switch (this.processSort) {
case 'cpu':
comparison = a.cpuUsagePercent - b.cpuUsagePercent;
break;
case 'memory':
comparison = a.residentMemoryBytes - b.residentMemoryBytes;
break;
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'pid':
comparison = a.pid - b.pid;
break;
}
switch (this.processSort) {
case 'cpu':
comparison = a.cpuUsagePercent - b.cpuUsagePercent;
break;
case 'memory':
comparison = a.residentMemoryBytes - b.residentMemoryBytes;
break;
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'pid':
comparison = a.pid - b.pid;
break;
}
return this.sortDescending ? -comparison : comparison;
});
}
return this.sortDescending ? -comparison : comparison;
});
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;
}
this.processSort = column;
this.sortDescending = true;
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(':');
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
return days > 0 ? `${days} days, ${time}` : time;
}
clampPercent(value: number): number {
return Math.max(0, Math.min(100, value));
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;
}
}
onOriginalFilterInput(event: Event): void {
const target = event.target;
if (target instanceof HTMLInputElement) {
this.processFilter = target.value;
this.selectedOriginalProcessIndex = 0;
}
}
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');
}
}
}