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 (