import { procUser } from './processData'; import { useCallback, useMemo, useState } from 'react'; import { processStateVariant } from '@/components/ds/status'; import { X, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { DataTable, DataTableFooter, SearchField } from '@/components/ds'; import { useTableChrome, GridViewPicker, GridChips, GridFilterButton, GridToolsMenu, GridFilterDrawer, } from '@/components/ds/grid'; import { useTableSort } from '@/hooks/useTableSort'; import { useColumnVisibility } from '@/hooks/useColumnVisibility'; import { useTranslation } from 'react-i18next'; import { formatDateTime } from '@/utils/intl'; // Built-in saved views. These are the five chips that used to sit in a second // toolbar row on Terminal › Processes (All / Running / Sleeping / Stopped / // Zombie) — every one of them was a rule over the `status` column wearing a // button's clothes. Written as rules they combine with the search and with any // column filter, they carry live counts in the picker, and a user can save // their own slice next to them. // // Every rule matches the `status` column's `value`, which is psutil's own // lowercase status word — the one the Badge renders. const NO_RULES = { match: 'all', rules: [] }; const STATUS_IS = (id, ...values) => ({ match: 'all', rules: [{ id, field: 'status', op: 'any', value: values }], }); // Busiest-first is the only ordering that makes a top-N slice meaningful, so // every view carries it rather than inheriting whatever was clicked last. const BY_CPU = [{ key: 'cpu', direction: 'desc' }]; const PROCESS_VIEWS = [ { // The worklist. A stopped or zombie process is the only row here that // is unambiguously wrong — a zombie is holding a PID its parent never // reaped, a stopped one is parked on a signal — so it leads. name: 'Stopped & zombie', state: { sorts: BY_CPU, hiddenKeys: [], columnFilters: STATUS_IS('pv1', 'stopped', 'zombie'), page: { search: '' }, }, }, { name: 'Running', state: { sorts: BY_CPU, hiddenKeys: [], columnFilters: STATUS_IS('pv2', 'running'), page: { search: '' }, }, }, { // Almost everything on a healthy box. Worth its own view because it is // the half you subtract to see what is actually doing work. name: 'Sleeping', state: { sorts: BY_CPU, hiddenKeys: [], columnFilters: STATUS_IS('pv3', 'sleeping', 'idle', 'disk-sleep'), page: { search: '' }, }, }, { name: 'All processes', state: { sorts: BY_CPU, hiddenKeys: [], columnFilters: NO_RULES, page: { search: '' }, }, }, ]; // psutil reports the owner as `username`; the per-PID detail endpoint and the // style-guide fixtures call it `user`. Read both so the column is never blank // against a real payload. export function UsageCell({ percent = 0, variant = 'cpu', label }) { const clamped = Math.min(Number(percent) || 0, 100); return (
{label ?? `${clamped.toFixed(1)}%`}
); } export function ProcessTable({ processes = [], selectedPid = null, onSelect, onKill, onForceKill, onRefresh, formatMemory = defaultFormatMemory, getStatusVariant = defaultGetStatusVariant, // Saved views + the one row of chrome are opt-in: the style guide renders // the bare table, a real page passes the key its views are stored under. viewPageKey, // Extra nodes for that SAME chrome row — a host's own data-source controls // (Terminal's top-N and live toggle), never a second toolbar underneath. actions, storageKey = 'serverkit-table-processes', // Controlled sort, same contract as DataTable's. Terminal drives it because // the endpoint returns a top-N slice and the fetch has to be ordered by // whatever the table is ordered by. sorts: controlledSorts, onSortsChange, }) { const { t } = useTranslation(); const [search, setSearch] = useState(''); const internalSort = useTableSort({ storageKey: `${storageKey}-sort`, defaultSorts: BY_CPU }); const sorts = controlledSorts ?? internalSort.sorts; const setSorts = onSortsChange ?? internalSort.setSorts; const { hiddenKeys, setHiddenKeys } = useColumnVisibility({ storageKey: `${storageKey}-cols` }); const searched = useMemo(() => { const needle = search.trim().toLowerCase(); if (!needle) return processes; return processes.filter((p) => ( p.name?.toLowerCase().includes(needle) || procUser(p).toLowerCase().includes(needle) || p.command?.toLowerCase().includes(needle) || String(p.pid).includes(needle) )); }, [processes, search]); const viewPageState = useMemo(() => ({ search }), [search]); const applyViewPageState = useCallback((saved) => { if (saved.search !== undefined) setSearch(saved.search); }, []); // Every column declares BOTH a `value` (what the rules and the exporter // read) and a `render` — none of these cells is a bare row property, and a // derived column without a render draws nothing at all. const columns = [ { key: 'pid', header: 'PID', sortable: true, type: 'num', value: (p) => p.pid, sortValue: (p) => p.pid, cellClassName: 'sk-cell-mono', render: (p) => p.pid, }, { key: 'name', headerKey: 'common.labels.name', header: 'Name', sortable: true, hideable: false, // Hundreds of distinct executables on a real host — a fragment you // type, never a list you pick from. type: 'text', value: (p) => p.name || '', sortValue: (p) => p.name || '', cellClassName: 'sk-cell-name', render: (p) =>
{p.name}
, }, { key: 'user', headerKey: 'common.labels.user', header: 'User', sortable: true, value: procUser, sortValue: procUser, render: procUser, }, { key: 'cpu', headerKey: 'app.processTable.cpu', header: 'CPU %', sortable: true, type: 'num', value: (p) => Number(p.cpu_percent) || 0, sortValue: (p) => Number(p.cpu_percent) || 0, render: (p) => , }, { key: 'memory', headerKey: 'common.labels.memory', header: 'Memory', sortable: true, type: 'num', value: (p) => Number(p.memory_percent) || 0, sortValue: (p) => Number(p.memory_percent) || 0, // ONE memory column, not the two this table used to declare: the // process LIST endpoint carries `memory_percent` only — `memory_info` // comes back from the per-PID detail call — so the bytes column // rendered a dash on every row of real data. The bar is always the // percentage; the label upgrades to bytes when a row has them. render: (p) => ( ), }, { key: 'status', headerKey: 'common.labels.status', header: 'Status', sortable: true, // Declared, not inferred: a box where everything sleeps offers ONE // distinct value, the column would fall back to text, and every view // above would become a no-op typed-fragment match. type: 'enum', enumOrder: ['running', 'sleeping', 'idle', 'disk-sleep', 'stopped', 'zombie'], value: (p) => p.status || '', sortValue: (p) => p.status || '', render: (p) => ( {p.status} ), }, { key: '__actions', headerKey: 'common.labels.actions', header: 'Actions', sortable: false, hideable: false, render: (p) => (
{onKill && ( )} {onForceKill && ( )}
), }, ]; // `rows` is the SEARCHED list — the column rules are applied inside // DataTable, and the count below comes back through `chrome.shownCount`. const chrome = useTableChrome({ columns, rows: searched, viewPageKey, builtinViews: PROCESS_VIEWS, noun: 'processes', sorts, setSorts, hiddenKeys, setHiddenKeys, pageState: viewPageState, applyPage: applyViewPageState, }); const table = ( onSelect?.(p)} rowClassName={(p) => (selectedPid === p.pid ? 'is-selected' : '')} className="processes-table" emptyTitle="No processes match this view." emptyMessage="" footer={( )} /> ); if (!viewPageKey) return table; return ( <> {/* One row of chrome: the view name is the heading, and search, the host's own controls, the filter button and the "⋮" ride it. */} {actions} chrome.setDrawerOpen(true)} /> )} /> {table} ); } export function ProcessDetailsPanel({ process, onClose, formatMemory = defaultFormatMemory }) { const { t } = useTranslation(); if (!process) return null; return (

{t('app.processTable.processDetails', 'Process details')}

{process.command && (
{t('common.labels.command', 'Command')} {process.command}
)}
); } export function DetailItem({ label, value, mono = false, children }) { const valueClass = ['detail-value', mono && 'mono'].filter(Boolean).join(' '); return (
{label} {children ?? {value}}
); } function defaultFormatMemory(bytes) { if (!bytes) return '-'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; while (bytes >= 1024 && i < units.length - 1) { bytes /= 1024; i++; } return `${bytes.toFixed(1)} ${units[i]}`; } function defaultGetStatusVariant(status) { return processStateVariant(status); } export default ProcessTable;