portio
Version:
A beautiful terminal UI for managing processes on network ports (Windows only)
300 lines (299 loc) • 15 kB
JavaScript
import React, { useState, useEffect, useRef } from 'react';
import { Box, Text, useApp, useInput } from 'ink';
import TextInput from 'ink-text-input';
import chalk from 'chalk';
import { getProcessesOnPorts, killProcess, killProcessElevated, checkProcessExists } from '../utils/portDetector.js';
import { ProcessTable } from './ProcessTable.js';
// Static header that only renders once
const StaticHeader = () => {
const asciiTitle = [
'██████╗ ██████╗ ██████╗ ████████╗██╗ ██╗',
'██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝╚██╗ ██╔╝',
'██████╔╝██║ ██║██████╔╝ ██║ ╚████╔╝ ',
'██╔═══╝ ██║ ██║██╔══██╗ ██║ ╚██╔╝ ',
'██║ ╚██████╔╝██║ ██║ ██║ ██║ ',
'╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ',
];
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
React.createElement(Box, { flexDirection: "column", alignItems: "center" },
asciiTitle.map((line, index) => (React.createElement(Text, { key: index, color: "cyan", bold: true }, line))),
React.createElement(Box, { marginTop: 1 },
React.createElement(Text, { color: "gray", italic: true }, "The port pal you've been waiting for"))),
React.createElement(Box, { marginTop: 1 },
React.createElement(Text, { bold: true }, chalk.hex('#4ECDC4')('⚡ PORTIO')),
React.createElement(Text, { color: "gray" }, " - Port Process Manager "),
React.createElement(Text, { color: "magenta" }, "v1.0"))));
};
export const StableInteractiveUI = ({ initialShowAll = true }) => {
const { exit } = useApp();
const [processes, setProcesses] = useState([]);
const [filteredProcesses, setFilteredProcesses] = useState([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [filterQuery, setFilterQuery] = useState('');
const [isFiltering, setIsFiltering] = useState(false);
const [showAll, setShowAll] = useState(initialShowAll);
const [verboseMode, setVerboseMode] = useState(false);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [confirmKill, setConfirmKill] = useState(null);
const [failedKillPid, setFailedKillPid] = useState(null);
const [checkingAdminKill, setCheckingAdminKill] = useState(false);
const lastRenderTime = useRef(Date.now());
// Debounce renders to prevent spam
const shouldRender = () => {
const now = Date.now();
if (now - lastRenderTime.current < 50) { // Minimum 50ms between renders
return false;
}
lastRenderTime.current = now;
return true;
};
const loadProcesses = async () => {
setLoading(true);
const procs = await getProcessesOnPorts(showAll);
setProcesses(procs);
setFilteredProcesses(procs);
setLoading(false);
setMessage('');
};
useEffect(() => {
loadProcesses();
}, [showAll]);
useEffect(() => {
if (filterQuery) {
const filtered = processes.filter(p => p.port.toString().includes(filterQuery) ||
p.pid.toString().includes(filterQuery) ||
p.processName.toLowerCase().includes(filterQuery.toLowerCase()) ||
p.command.toLowerCase().includes(filterQuery.toLowerCase()));
setFilteredProcesses(filtered);
setSelectedIndex(0);
}
else {
setFilteredProcesses(processes);
}
}, [filterQuery, processes]);
useInput((input, key) => {
// Ignore input if we're rendering too frequently
if (!shouldRender())
return;
// Handle admin kill attempt
if (failedKillPid !== null) {
if (input === 'A' || input === 'a') {
const process = filteredProcesses.find(p => p.pid === failedKillPid);
if (process) {
setMessage(`🚀 Launching elevated terminal... Approve the UAC prompt to kill ${process.processName}`);
setCheckingAdminKill(true);
killProcessElevated(process.pid, process.processName);
// Wait a moment for the UAC prompt and execution
setTimeout(() => {
setMessage(`⏳ Checking if ${process.processName} was terminated...`);
}, 1500);
// Check after delay to see if it worked
setTimeout(async () => {
const stillExists = await checkProcessExists(process.pid);
setCheckingAdminKill(false);
if (!stillExists) {
setMessage(`✅ Admin kill successful! ${process.processName} (PID: ${process.pid}) has been terminated.`);
loadProcesses();
}
else {
setMessage(`❌ Admin kill failed. ${process.processName} may be protected by the system or you cancelled the UAC prompt.`);
}
}, 3000);
setFailedKillPid(null);
}
return;
}
else if (key.escape) {
setFailedKillPid(null);
setMessage('');
return;
}
}
if (confirmKill !== null) {
if (key.return) {
handleKillProcess(confirmKill);
setConfirmKill(null);
}
else if (key.escape) {
setConfirmKill(null);
setMessage('Kill cancelled');
}
return;
}
if (isFiltering) {
if (key.escape) {
setIsFiltering(false);
setFilterQuery('');
}
else if (key.return) {
setIsFiltering(false);
}
return;
}
if (input === 'q' || (key.ctrl && input === 'c')) {
exit();
}
if (input === '/') {
setIsFiltering(true);
return;
}
if (input === 'c') {
setFilterQuery('');
setMessage('Filter cleared');
return;
}
if (input === 'r') {
setMessage('Refreshing...');
loadProcesses();
return;
}
if (input === 'd') {
setShowAll(!showAll);
setMessage(showAll ? 'Showing dev ports only' : 'Showing all ports');
return;
}
if (input === 'v') {
setVerboseMode(!verboseMode);
setMessage(verboseMode ? 'Verbose mode off' : 'Verbose mode on');
return;
}
if (key.upArrow) {
setSelectedIndex(Math.max(0, selectedIndex - 1));
}
if (key.downArrow) {
setSelectedIndex(Math.min(filteredProcesses.length - 1, selectedIndex + 1));
}
if (key.return && filteredProcesses.length > 0) {
const process = filteredProcesses[selectedIndex];
if (process) {
setConfirmKill(process.pid);
setMessage(`⚠️ Kill ${chalk.yellow(process.processName)} (PID: ${process.pid}) on port ${chalk.cyan(process.port)}? Press ${chalk.green('Enter')} to confirm or ${chalk.red('ESC')} to cancel`);
}
}
});
const handleKillProcess = async (pid) => {
const success = await killProcess(pid);
const process = filteredProcesses.find(p => p.pid === pid);
if (success) {
setMessage(`✅ Successfully killed ${process?.processName || `process ${pid}`}`);
setFailedKillPid(null);
setTimeout(() => loadProcesses(), 500);
}
else {
setFailedKillPid(pid);
setMessage(`❌ Failed to kill ${process?.processName || `process ${pid}`}. Press ${chalk.yellow.bold('A')} to try with admin privileges or run portio as admin.`);
}
};
const tableData = filteredProcesses.map((proc, index) => {
const isSelected = index === selectedIndex;
// Enhanced color scheme with gradients
const isDevPort = proc.port >= 3000 && proc.port < 10000;
const isSystemPort = proc.port < 1024;
const isHighPort = proc.port >= 49152;
if (isSelected) {
// Vibrant selection with background
return {
'#': chalk.bgCyan.black(` ${(index + 1).toString().padEnd(3)} `),
'PID': chalk.bgCyan.black(` ${proc.pid.toString().padEnd(6)} `),
'Port': chalk.bgCyan.black.bold(` ${proc.port.toString().padEnd(5)} `),
'Process': chalk.bgCyan.black(` ${(proc.processName || 'Unknown').padEnd(15)} `),
'Command': chalk.bgCyan.black(` ${verboseMode ? (proc.fullCommand || proc.command) : proc.command} `)
};
}
// Color-coded by port type with better visibility
let portColor;
if (isSystemPort) {
portColor = chalk.hex('#FF6B6B'); // Coral red for system ports
}
else if (isDevPort) {
portColor = chalk.hex('#4ECDC4'); // Teal for dev ports
}
else if (isHighPort) {
portColor = chalk.hex('#95E77E'); // Light green for ephemeral
}
else {
portColor = chalk.hex('#FFE66D'); // Yellow for registered ports
}
// Process name coloring based on type
let processColor = chalk.white;
const processLower = proc.processName?.toLowerCase() || '';
if (processLower.includes('node') || processLower.includes('npm')) {
processColor = chalk.hex('#68D391'); // Node green
}
else if (processLower.includes('python')) {
processColor = chalk.hex('#4B8BBE'); // Python blue
}
else if (processLower.includes('java')) {
processColor = chalk.hex('#F89820'); // Java orange
}
else if (processLower.includes('docker')) {
processColor = chalk.hex('#2496ED'); // Docker blue
}
const processName = proc.processName || 'Unknown';
return {
'#': chalk.gray((index + 1).toString()),
'PID': chalk.hex('#A78BFA')(proc.pid.toString()), // Purple for PIDs
'Port': portColor.bold(proc.port.toString()),
'Process': processColor(processName),
'Command': chalk.hex('#94A3B8')(verboseMode ? (proc.fullCommand || proc.command) : proc.command) // Slate gray
};
});
return (React.createElement(Box, { flexDirection: "column" },
React.createElement(StaticHeader, null),
loading ? (React.createElement(Box, null,
React.createElement(Text, { color: "cyan" }, "\u27F3 Scanning ports..."))) : (React.createElement(React.Fragment, null,
isFiltering ? (React.createElement(Box, { marginBottom: 1 },
React.createElement(Text, { color: "yellow" }, "\uD83D\uDD0D "),
React.createElement(TextInput, { value: filterQuery, onChange: setFilterQuery, placeholder: "Search..." }))) : (React.createElement(Box, { marginBottom: 1 },
React.createElement(Text, { color: "gray" }, "Mode: "),
React.createElement(Text, { color: showAll ? 'magenta' : 'cyan', bold: true }, showAll ? '● All Ports' : '● Dev Only'),
React.createElement(Text, { color: "gray" }, " \u2502 Found: "),
React.createElement(Text, { color: "yellow", bold: true }, filteredProcesses.length),
React.createElement(Text, { color: "gray" }, " processes"))),
filteredProcesses.length > 0 ? (React.createElement(ProcessTable, { data: tableData, selectedIndex: selectedIndex })) : (React.createElement(Box, { padding: 1, borderStyle: "round", borderColor: "gray" },
React.createElement(Text, { color: "gray" }, filterQuery
? `❌ No matches for "${chalk.yellow(filterQuery)}"`
: '📭 No processes found on listening ports'))),
message && (React.createElement(Box, { marginTop: 1, paddingX: 1 },
React.createElement(Text, null, message.includes('✅') || message.includes('❌') || message.includes('⚠️') ||
message.includes('🚀') || message.includes('⏳') ? message : // Already formatted with emoji
message.includes('Success') ? chalk.green('✅ ' + message) :
message.includes('Failed') ? chalk.red('❌ ' + message) :
message.includes('cancelled') ? chalk.yellow('↩️ ' + message) :
chalk.cyan('ℹ️ ' + message)))),
checkingAdminKill && (React.createElement(Box, { marginTop: 1, paddingX: 1 },
React.createElement(Text, { color: "yellow" },
chalk.yellow('⏳'),
" Waiting for admin action..."))),
!isFiltering && !confirmKill && !failedKillPid && (React.createElement(Box, { marginTop: 1, paddingX: 1 },
React.createElement(Text, { color: "gray" },
chalk.cyan.bold('↑↓'),
" nav",
chalk.red.bold(' ⏎'),
" kill",
chalk.yellow.bold(' /'),
" search",
chalk.green.bold(' r'),
" refresh",
chalk.magenta.bold(' d'),
" dev/all",
chalk.blue.bold(' v'),
" verbose",
chalk.gray.bold(' q'),
" quit"))),
failedKillPid && !confirmKill && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red" },
React.createElement(Text, null,
chalk.red.bold('⚠️ Admin Required: '),
chalk.yellow.bold('A'),
" launch admin terminal",
chalk.gray.bold(' ESC'),
" cancel"))),
confirmKill && (React.createElement(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "yellow" },
React.createElement(Text, null,
chalk.yellow.bold('⚠️ Confirm Kill: '),
chalk.green.bold('Enter'),
" to confirm",
chalk.red.bold(' ESC'),
" to cancel")))))));
};