2024-11-09 17:37:48 +01:00

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>