mirror of
https://github.com/kunkunsh/kunkun-ext-neohtop.git
synced 2025-04-11 17:29:45 +00:00
435 lines
11 KiB
Svelte
435 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { onMount, onDestroy } from "svelte";
|
|
import StatsBar from "$lib/components/StatsBar.svelte";
|
|
import ToolBar from "$lib/components/ToolBar.svelte";
|
|
import ProcessTable from "$lib/components/ProcessTable.svelte";
|
|
import ProcessDetailsModal from "$lib/components/ProcessDetailsModal.svelte";
|
|
import KillProcessModal from "$lib/components/KillProcessModal.svelte";
|
|
import { formatMemorySize, formatStatus } from "$lib/utils";
|
|
import { themeStore } from "$lib/stores";
|
|
import type { Process, SystemStats, Column } from "$lib/types";
|
|
|
|
let processes: Process[] = [];
|
|
let systemStats: SystemStats | null = null;
|
|
let intervalId: number;
|
|
let error: string | null = null;
|
|
let searchTerm = "";
|
|
let isLoading = true;
|
|
let currentPage = 1;
|
|
let itemsPerPage = 15;
|
|
let pinnedProcesses: Set<string> = new Set();
|
|
let selectedProcess: Process | null = null;
|
|
let showInfoModal = false;
|
|
let showConfirmModal = false;
|
|
let processToKill: Process | null = null;
|
|
let isKilling = false;
|
|
let statusFilter = "all";
|
|
let refreshRate = 1000;
|
|
let isFrozen = false;
|
|
let selectedProcessPid: number | null = null;
|
|
|
|
let columns: Column[] = [
|
|
{ id: "name", label: "Process Name", visible: true, required: true },
|
|
{ id: "pid", label: "PID", visible: true, required: false },
|
|
{
|
|
id: "status",
|
|
label: "Status",
|
|
visible: true,
|
|
format: formatStatus,
|
|
},
|
|
{ id: "user", label: "User", visible: true },
|
|
{
|
|
id: "cpu_usage",
|
|
label: "CPU %",
|
|
visible: true,
|
|
format: (v) => v.toFixed(1) + "%",
|
|
},
|
|
{
|
|
id: "memory_usage",
|
|
label: "Memory",
|
|
visible: true,
|
|
format: (v) => (v / (1024 * 1024)).toFixed(1) + " MB",
|
|
},
|
|
{ id: "command", label: "Command", visible: false },
|
|
{ id: "ppid", label: "Parent PID", visible: false },
|
|
{ id: "environ", label: "Environment", visible: false },
|
|
{ id: "root", label: "Root", visible: false },
|
|
{
|
|
id: "virtual_memory",
|
|
label: "Virtual Memory",
|
|
visible: false,
|
|
format: (v) => formatMemorySize(v),
|
|
},
|
|
{
|
|
id: "start_time",
|
|
label: "Start Time",
|
|
visible: false,
|
|
format: (v) => new Date(v * 1000).toLocaleString(), // v is the time where the process was started (in seconds) from epoch
|
|
},
|
|
{
|
|
id: "run_time",
|
|
label: "Run Time",
|
|
visible: false,
|
|
format: (v) => {
|
|
const seconds = v; // v is the time the process has been running in seconds
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const remainingSeconds = seconds % 60;
|
|
return `${hours}h ${minutes}m ${remainingSeconds}s`; // Format as HH:MM:SS
|
|
},
|
|
},
|
|
{
|
|
id: "disk_usage",
|
|
label: "Disk Usage",
|
|
visible: false,
|
|
format: (v) =>
|
|
`${(v[0] / (1024 * 1024)).toFixed(1)} / ${(v[1] / (1024 * 1024)).toFixed(1)} MB`,
|
|
},
|
|
{ id: "session_id", label: "Session ID", visible: false },
|
|
];
|
|
|
|
let sortConfig = {
|
|
field: "cpu_usage" as keyof Process,
|
|
direction: "desc" as "asc" | "desc",
|
|
};
|
|
|
|
$: filteredProcesses = processes.filter((process) => {
|
|
let matchesSearch = searchTerm.length === 0;
|
|
searchTerm
|
|
.split(",")
|
|
.map((term) => term.trim())
|
|
.forEach((term) => {
|
|
const nameSubstringMatch = process.name
|
|
.toLowerCase()
|
|
.includes(term.toLowerCase());
|
|
const nameRegexMatch = (() => {
|
|
try {
|
|
return new RegExp(term, "i").test(process.name);
|
|
} catch {
|
|
return false;
|
|
}
|
|
})();
|
|
const commandMatch = process.command
|
|
.toLowerCase()
|
|
.includes(term.toLowerCase());
|
|
const pidMatch = process.pid.toString().includes(term);
|
|
matchesSearch ||=
|
|
nameSubstringMatch || nameRegexMatch || commandMatch || pidMatch;
|
|
});
|
|
|
|
const matchesStatus =
|
|
statusFilter === "all" ? true : process.status === statusFilter;
|
|
|
|
return matchesSearch && matchesStatus;
|
|
});
|
|
|
|
$: sortedProcesses = filteredProcesses.sort((a, b) => {
|
|
const aPin = pinnedProcesses.has(a.command);
|
|
const bPin = pinnedProcesses.has(b.command);
|
|
if (aPin && !bPin) return -1;
|
|
if (!aPin && bPin) return 1;
|
|
|
|
const aValue = a[sortConfig.field];
|
|
const bValue = b[sortConfig.field];
|
|
const direction = sortConfig.direction === "asc" ? 1 : -1;
|
|
|
|
if (typeof aValue === "string" && typeof bValue === "string") {
|
|
return direction * aValue.localeCompare(bValue);
|
|
}
|
|
return direction * (Number(aValue) - Number(bValue));
|
|
});
|
|
|
|
$: totalPages = Math.ceil(filteredProcesses.length / itemsPerPage);
|
|
$: paginatedProcesses = sortedProcesses.slice(
|
|
(currentPage - 1) * itemsPerPage,
|
|
currentPage * itemsPerPage,
|
|
);
|
|
|
|
$: {
|
|
// Reset to first page when filtering or changing items per page
|
|
if (searchTerm || itemsPerPage) {
|
|
currentPage = 1;
|
|
}
|
|
}
|
|
|
|
$: {
|
|
if (intervalId) clearInterval(intervalId);
|
|
if (!isFrozen) {
|
|
intervalId = setInterval(() => {
|
|
getProcesses();
|
|
}, refreshRate);
|
|
}
|
|
}
|
|
|
|
$: if (selectedProcessPid && processes.length > 0) {
|
|
selectedProcess =
|
|
processes.find((p) => p.pid === selectedProcessPid) || null;
|
|
}
|
|
|
|
async function getProcesses() {
|
|
try {
|
|
const result = await invoke<[Process[], SystemStats]>("get_processes");
|
|
processes = result[0];
|
|
systemStats = result[1];
|
|
error = null;
|
|
} catch (e: unknown) {
|
|
if (e instanceof Error) {
|
|
error = e.message;
|
|
} else {
|
|
error = String(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function killProcess(pid: number) {
|
|
try {
|
|
const success = await invoke<boolean>("kill_process", { pid });
|
|
if (success) {
|
|
await getProcesses();
|
|
}
|
|
} catch (e: unknown) {
|
|
if (e instanceof Error) {
|
|
error = e.message;
|
|
} else {
|
|
error = String(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleSort(field: keyof Process) {
|
|
if (sortConfig.field === field) {
|
|
sortConfig.direction = sortConfig.direction === "asc" ? "desc" : "asc";
|
|
} else {
|
|
sortConfig.field = field;
|
|
sortConfig.direction = "desc";
|
|
}
|
|
}
|
|
|
|
function togglePin(command: string) {
|
|
if (pinnedProcesses.has(command)) {
|
|
pinnedProcesses.delete(command);
|
|
} else {
|
|
pinnedProcesses.add(command);
|
|
}
|
|
pinnedProcesses = pinnedProcesses; // Trigger reactivity
|
|
}
|
|
|
|
function showProcessDetails(process: Process) {
|
|
selectedProcessPid = process.pid;
|
|
selectedProcess = process;
|
|
showInfoModal = true;
|
|
}
|
|
|
|
function confirmKillProcess(process: Process) {
|
|
processToKill = process;
|
|
showConfirmModal = true;
|
|
}
|
|
|
|
async function handleConfirmKill() {
|
|
if (processToKill) {
|
|
isKilling = true;
|
|
try {
|
|
await killProcess(processToKill.pid);
|
|
} finally {
|
|
isKilling = false;
|
|
showConfirmModal = false;
|
|
processToKill = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleModalClose() {
|
|
showInfoModal = false;
|
|
selectedProcess = null;
|
|
selectedProcessPid = null;
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
await Promise.all([getProcesses()]);
|
|
} finally {
|
|
isLoading = false;
|
|
}
|
|
|
|
themeStore.init();
|
|
});
|
|
|
|
onDestroy(() => {
|
|
if (intervalId) clearInterval(intervalId);
|
|
});
|
|
</script>
|
|
|
|
{#if isLoading}
|
|
<div class="loading-container">
|
|
<div class="loading-content">
|
|
<div class="spinner"></div>
|
|
<span class="loading-text">Loading processes...</span>
|
|
</div>
|
|
</div>
|
|
{:else}
|
|
<main>
|
|
{#if systemStats}
|
|
<StatsBar {systemStats} />
|
|
{/if}
|
|
|
|
<ToolBar
|
|
bind:searchTerm
|
|
bind:statusFilter
|
|
bind:itemsPerPage
|
|
bind:currentPage
|
|
bind:refreshRate
|
|
bind:isFrozen
|
|
{totalPages}
|
|
totalResults={filteredProcesses.length}
|
|
bind:columns
|
|
/>
|
|
|
|
{#if error}
|
|
<div class="alert">{error}</div>
|
|
{/if}
|
|
|
|
<ProcessTable
|
|
processes={paginatedProcesses}
|
|
{columns}
|
|
{systemStats}
|
|
{sortConfig}
|
|
{pinnedProcesses}
|
|
onToggleSort={toggleSort}
|
|
onTogglePin={togglePin}
|
|
onShowDetails={showProcessDetails}
|
|
onKillProcess={confirmKillProcess}
|
|
/>
|
|
</main>
|
|
{/if}
|
|
|
|
<ProcessDetailsModal
|
|
show={showInfoModal}
|
|
process={selectedProcess}
|
|
onClose={handleModalClose}
|
|
/>
|
|
|
|
<KillProcessModal
|
|
show={showConfirmModal}
|
|
process={processToKill}
|
|
{isKilling}
|
|
onClose={() => {
|
|
showConfirmModal = false;
|
|
processToKill = null;
|
|
}}
|
|
onConfirm={handleConfirmKill}
|
|
/>
|
|
|
|
<svelte:head>
|
|
<title>NeoHtop - Modern System Monitor</title>
|
|
<meta
|
|
name="description"
|
|
content="A modern, web-based system monitoring interface inspired by htop"
|
|
/>
|
|
</svelte:head>
|
|
|
|
<style>
|
|
:global(:root) {
|
|
--base: #1e1e2e;
|
|
--mantle: #181825;
|
|
--crust: #11111b;
|
|
--text: #cdd6f4;
|
|
--subtext0: #a6adc8;
|
|
--subtext1: #bac2de;
|
|
--surface0: #313244;
|
|
--surface1: #45475a;
|
|
--surface2: #585b70;
|
|
--overlay0: #6c7086;
|
|
--overlay1: #7f849c;
|
|
--blue: #89b4fa;
|
|
--lavender: #b4befe;
|
|
--sapphire: #74c7ec;
|
|
--sky: #89dceb;
|
|
--red: #f38ba8;
|
|
--maroon: #eba0ac;
|
|
--peach: #fab387;
|
|
--yellow: #f9e2af;
|
|
--green: #a6e3a1;
|
|
--teal: #94e2d5;
|
|
}
|
|
|
|
:global(body) {
|
|
margin: 0;
|
|
padding: 0;
|
|
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background-color: var(--base);
|
|
color: var(--text);
|
|
-webkit-font-smoothing: antialiased;
|
|
overflow: hidden;
|
|
user-select: none;
|
|
}
|
|
|
|
main {
|
|
height: 100vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-width: min-content;
|
|
}
|
|
|
|
.alert {
|
|
margin: 8px;
|
|
padding: 8px 12px;
|
|
background-color: var(--surface0);
|
|
border: 1px solid var(--red);
|
|
border-radius: 6px;
|
|
color: var(--red);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.loading-container {
|
|
width: 100vw;
|
|
height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: linear-gradient(135deg, var(--base) 0%, var(--mantle) 100%);
|
|
}
|
|
|
|
.loading-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.spinner {
|
|
width: 40px;
|
|
height: 40px;
|
|
border: 3px solid var(--surface0);
|
|
border-top-color: var(--blue);
|
|
border-radius: 50%;
|
|
animation: spin 1s ease-in-out infinite;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.loading-text {
|
|
color: var(--subtext0);
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
letter-spacing: 0.5px;
|
|
animation: pulse 2s ease-in-out infinite;
|
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 0.6;
|
|
}
|
|
50% {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|