@hienlh/ppm 0.18.9 → 0.18.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/.spike/group-protected-probe.ts +21 -0
  2. package/.spike/tunnel.err +22 -42
  3. package/.spike/w.err +26 -94
  4. package/CHANGELOG.md +12 -0
  5. package/assets/skills/ppm/SKILL.md +1 -1
  6. package/assets/skills/ppm/references/http-api.md +1 -1
  7. package/dist/web/assets/{ai-resource-editor-DFCuNe-P.js → ai-resource-editor-mdDKG-rw.js} +1 -1
  8. package/dist/web/assets/{chat-tab-flMPLoKb.js → chat-tab-C060A94y.js} +3 -3
  9. package/dist/web/assets/{code-editor-C2VQbXzw.js → code-editor-WELegzdp.js} +2 -2
  10. package/dist/web/assets/{conflict-editor-CdH_h0vD.js → conflict-editor-fRTU3f1o.js} +1 -1
  11. package/dist/web/assets/{csv-preview-CY_454vd.js → csv-preview-D1d0cxOJ.js} +1 -1
  12. package/dist/web/assets/{database-viewer-BCZOTrPT.js → database-viewer-DevnWTV4.js} +1 -1
  13. package/dist/web/assets/{diff-viewer-BwfMN-nW.js → diff-viewer-DGxVg1pD.js} +1 -1
  14. package/dist/web/assets/{docx-preview-D3Sdh_aT.js → docx-preview-Cx-zLwAw.js} +1 -1
  15. package/dist/web/assets/{explorer-body-BKOMM566.js → explorer-body-j_4hVc34.js} +1 -1
  16. package/dist/web/assets/{explorer-window-content-Bbzijk8t.js → explorer-window-content-DdrGwk3w.js} +1 -1
  17. package/dist/web/assets/{extension-webview-CuOQrHIS.js → extension-webview-BH_mWYMe.js} +1 -1
  18. package/dist/web/assets/format-bytes-BE_W3UBg.js +1 -0
  19. package/dist/web/assets/{git-log-panel-DcV7PhqW.js → git-log-panel-CfpXRJcP.js} +1 -1
  20. package/dist/web/assets/{glide-data-grid-Cehfgrzc.js → glide-data-grid-n4ox_uev.js} +1 -1
  21. package/dist/web/assets/{group-chat-tab-BVPYxjz-.js → group-chat-tab-dYuuknLN.js} +1 -1
  22. package/dist/web/assets/{image-preview-D-Ok10eZ.js → image-preview-Ss38ex8g.js} +1 -1
  23. package/dist/web/assets/index-BiTiXgBZ.css +2 -0
  24. package/dist/web/assets/{index-CerSxGdf.js → index-Ch8l9de-.js} +5 -5
  25. package/dist/web/assets/{keybindings-store-aIY40k2o.js → keybindings-store-CDfTjwZX.js} +1 -1
  26. package/dist/web/assets/{markdown-renderer-pVzBdkpl.js → markdown-renderer-B317vEys.js} +1 -1
  27. package/dist/web/assets/{markdown-renderer-I89kyQhe.js → markdown-renderer-CpRlpLHw.js} +1 -1
  28. package/dist/web/assets/{mobile-explorer-sheet-5Uj9ZMM9.js → mobile-explorer-sheet-D1Bv9UCT.js} +1 -1
  29. package/dist/web/assets/{notification-store-DJ-OtAG3.js → notification-store-KwTAZT8b.js} +1 -1
  30. package/dist/web/assets/overview-panel-BxDohIEa.js +1 -0
  31. package/dist/web/assets/{pdf-preview-Di5xHFyT.js → pdf-preview-dwRe54ht.js} +1 -1
  32. package/dist/web/assets/{postgres-viewer-CR6VpTrX.js → postgres-viewer-BWZLW7kI.js} +1 -1
  33. package/dist/web/assets/process-table-CI-fnVsy.js +1 -0
  34. package/dist/web/assets/resolve-theme-B9ABAH_3.js +1 -0
  35. package/dist/web/assets/{settings-tab-jbiQwozX.js → settings-tab-mPxrKkfm.js} +1 -1
  36. package/dist/web/assets/{sql-query-editor-BPYsJDsn.js → sql-query-editor-BwPjEaP9.js} +1 -1
  37. package/dist/web/assets/{sqlite-viewer-Bq9ZJA2y.js → sqlite-viewer-Cs-_D942.js} +1 -1
  38. package/dist/web/assets/{system-monitor-body-CKEYWyN9.js → system-monitor-body-CV7-0hZG.js} +2 -2
  39. package/dist/web/assets/{system-monitor-tab-WjhFFHyM.js → system-monitor-tab-Ce-8Axhh.js} +1 -1
  40. package/dist/web/assets/{system-monitor-window-content-CMiISeoC.js → system-monitor-window-content-CetwYOQy.js} +1 -1
  41. package/dist/web/assets/{team-member-sheet-oY5EkH53.js → team-member-sheet-CjgaQjsZ.js} +2 -2
  42. package/dist/web/assets/{team-member-window-content-yCBeI_5X.js → team-member-window-content-DNJiYWS7.js} +1 -1
  43. package/dist/web/assets/{terminal-tab-BxHtMR7h.js → terminal-tab-DF7521sT.js} +1 -1
  44. package/dist/web/assets/{tool-cards-BcGrk1zk.js → tool-cards-DCAXGDJI.js} +2 -2
  45. package/dist/web/assets/{use-monaco-theme-CGucu3fb.js → use-monaco-theme-DSCdlrjO.js} +1 -1
  46. package/dist/web/assets/{use-open-team-member-DdrRXj-M.js → use-open-team-member-H4k74HnV.js} +1 -1
  47. package/dist/web/assets/{use-websocket-C-Rxv8JN.js → use-websocket-iJfc7EbK.js} +1 -1
  48. package/dist/web/assets/{video-preview-DYAbbmd0.js → video-preview-B0ZWpX0F.js} +1 -1
  49. package/dist/web/index.html +3 -3
  50. package/dist/web/sw.js +1 -1
  51. package/package.json +1 -1
  52. package/src/web/components/system/build-kill-request.ts +28 -1
  53. package/src/web/components/system/chart-scale.ts +3 -3
  54. package/src/web/components/system/kill-confirm-dialog.tsx +44 -28
  55. package/src/web/components/system/metric-chart-canvas.tsx +1 -2
  56. package/src/web/components/system/process-columns-grid.ts +39 -29
  57. package/src/web/components/system/process-group-row.tsx +31 -8
  58. package/src/web/components/system/process-row.tsx +6 -7
  59. package/src/web/components/system/process-table-model.ts +4 -22
  60. package/src/web/components/system/process-table-toolbar.tsx +12 -43
  61. package/src/web/components/system/process-table.tsx +22 -63
  62. package/src/web/components/system/sortable-header.tsx +33 -8
  63. package/src/web/components/system/system-monitor-body.tsx +1 -1
  64. package/src/web/components/system/use-column-widths.ts +91 -0
  65. package/src/web/components/system/use-process-kill.ts +100 -0
  66. package/src/web/styles/globals.css +5 -3
  67. package/src/web/theme/builtin/aurora.ts +8 -5
  68. package/dist/web/assets/format-bytes-Dk7B73B7.js +0 -1
  69. package/dist/web/assets/index-DRA_OpWY.css +0 -2
  70. package/dist/web/assets/overview-panel-Cgi3UPY1.js +0 -1
  71. package/dist/web/assets/process-table-DL6shbzt.js +0 -1
  72. package/dist/web/assets/resolve-theme-IdhSR-gx.js +0 -1
  73. package/src/web/components/system/sparkline-canvas.tsx +0 -63
@@ -5,44 +5,60 @@ import { Button } from "@/components/ui/button";
5
5
  import { Switch } from "@/components/ui/switch";
6
6
  import { useIsMobile } from "@/hooks/use-is-mobile";
7
7
  import { formatRam } from "@/lib/format-bytes";
8
- import type { ProcessInfo } from "../../../types/system-metrics";
8
+ import type { KillTarget } from "./build-kill-request";
9
9
 
10
10
  export interface KillConfirmDialogProps {
11
- process: ProcessInfo | null;
12
- onConfirm: (proc: ProcessInfo, tree: boolean) => void;
11
+ target: KillTarget | null;
12
+ /** `tree` is only meaningful for a single process; a group is always ended whole. */
13
+ onConfirm: (target: KillTarget, tree: boolean) => void;
13
14
  onCancel: () => void;
14
15
  }
15
16
 
16
- /** Confirm dialog for ending a process. Bottom sheet below `md`, centered dialog at
17
- * `md`+. The "end child processes" toggle defaults off and the destructive action is
18
- * never the default focus — Escape/backdrop cancels either way. */
19
- export function KillConfirmDialog({ process, onConfirm, onCancel }: KillConfirmDialogProps) {
17
+ /** Confirm dialog for ending a process or a whole app group. Bottom sheet below
18
+ * `md`, centered dialog at `md`+. The "end child processes" toggle defaults off and
19
+ * the destructive action is never the default focus — Escape/backdrop cancels either
20
+ * way. */
21
+ export function KillConfirmDialog({ target, onConfirm, onCancel }: KillConfirmDialogProps) {
20
22
  const isMobile = useIsMobile();
21
23
  const [tree, setTree] = useState(false);
22
24
 
23
25
  useEffect(() => {
24
- if (!process) setTree(false);
25
- }, [process]);
26
+ if (!target) setTree(false);
27
+ }, [target]);
26
28
 
27
- if (!process) return null;
29
+ if (!target) return null;
30
+
31
+ const isGroup = target.kind === "group";
32
+ const title = isGroup ? "End app" : "End process";
33
+ const cpu = isGroup ? target.group.cpu : target.proc.cpu;
34
+ const ramMB = isGroup ? target.group.ramMB : target.proc.ramMB;
28
35
 
29
36
  const body = (
30
- <div className="space-y-4" data-testid="sysmon-kill-confirm">
31
- <p className="text-sm">
32
- End <span className="font-medium">{process.name}</span> (pid {process.pid})?
33
- </p>
37
+ <div className="space-y-4" data-testid="sysmon-kill-confirm" data-kill-kind={target.kind}>
38
+ {isGroup ? (
39
+ <p className="text-sm">
40
+ End <span className="font-medium">{target.group.label}</span> and all its{" "}
41
+ <span className="font-medium">{target.members.length}</span> processes?
42
+ </p>
43
+ ) : (
44
+ <p className="text-sm">
45
+ End <span className="font-medium">{target.proc.name}</span> (pid {target.proc.pid})?
46
+ </p>
47
+ )}
34
48
  <p className="text-xs text-text-subtle">
35
- CPU {process.cpu.toFixed(1)}% · RAM {formatRam(process.ramMB)}
49
+ CPU {cpu.toFixed(1)}% · RAM {formatRam(ramMB)}
36
50
  </p>
37
- <label className="flex items-center justify-between gap-3 min-h-11 py-1">
38
- <span className="text-sm">Also end child processes</span>
39
- <Switch
40
- checked={tree}
41
- onCheckedChange={setTree}
42
- data-testid="sysmon-kill-tree"
43
- aria-label="Also end child processes"
44
- />
45
- </label>
51
+ {!isGroup && (
52
+ <label className="flex items-center justify-between gap-3 min-h-11 py-1">
53
+ <span className="text-sm">Also end child processes</span>
54
+ <Switch
55
+ checked={tree}
56
+ onCheckedChange={setTree}
57
+ data-testid="sysmon-kill-tree"
58
+ aria-label="Also end child processes"
59
+ />
60
+ </label>
61
+ )}
46
62
  <div className="flex flex-col-reverse md:flex-row gap-2 md:justify-end pt-2">
47
63
  <Button
48
64
  variant="outline"
@@ -55,11 +71,11 @@ export function KillConfirmDialog({ process, onConfirm, onCancel }: KillConfirmD
55
71
  <Button
56
72
  variant="destructive"
57
73
  autoFocus={false}
58
- onClick={() => onConfirm(process, tree)}
74
+ onClick={() => onConfirm(target, tree)}
59
75
  data-testid="sysmon-kill-confirm-ok"
60
76
  className="min-h-11"
61
77
  >
62
- End process
78
+ {isGroup ? `End ${target.members.length} processes` : "End process"}
63
79
  </Button>
64
80
  </div>
65
81
  </div>
@@ -69,7 +85,7 @@ export function KillConfirmDialog({ process, onConfirm, onCancel }: KillConfirmD
69
85
  return (
70
86
  <BottomSheet open onClose={onCancel}>
71
87
  <div className="px-4 pb-4">
72
- <h2 className="text-base font-semibold mb-3">End process</h2>
88
+ <h2 className="text-base font-semibold mb-3">{title}</h2>
73
89
  {body}
74
90
  </div>
75
91
  </BottomSheet>
@@ -80,7 +96,7 @@ export function KillConfirmDialog({ process, onConfirm, onCancel }: KillConfirmD
80
96
  <Dialog open onOpenChange={(open) => !open && onCancel()}>
81
97
  <DialogContent>
82
98
  <DialogHeader>
83
- <DialogTitle>End process</DialogTitle>
99
+ <DialogTitle>{title}</DialogTitle>
84
100
  </DialogHeader>
85
101
  {body}
86
102
  </DialogContent>
@@ -20,8 +20,7 @@ interface MetricChartCanvasProps {
20
20
  * width of its parent — the cards live in a resizable floating window, so a fixed
21
21
  * pixel width overflows the card as soon as the window is narrower than designed.
22
22
  * No redraw throttle — one draw per prop change, which at a 2s tick cadence is
23
- * nowhere near a frame budget. `SparklineCanvas` stays the small inline variant
24
- * used by process rows. */
23
+ * nowhere near a frame budget. */
25
24
  export const MetricChartCanvas = memo(function MetricChartCanvas({
26
25
  series,
27
26
  height,
@@ -1,45 +1,50 @@
1
1
  /** Pure grid-template builder for the process table. No React, so the visibility
2
- * rules (which optional columns exist, which one survives below `@lg`) are
3
- * unit-testable without mounting a component.
2
+ * rules (which optional columns exist, which one survives below `@lg`) and the
3
+ * user-resized widths are unit-testable without mounting a component.
4
4
  *
5
5
  * Two responsive templates are computed up front and switched via CSS custom
6
6
  * properties + a `@lg:` Tailwind class (see `PROCESS_ROW_GRID_CLASS` below) —
7
7
  * Tailwind's arbitrary-value scan only needs the class NAME to be a static
8
- * string; the var() value itself can vary at runtime. This mirrors the fixed
9
- * two-template approach the process table already used for the trend/age
10
- * track, just parameterised by which optional columns the host can measure. */
8
+ * string; the var() value itself can vary at runtime. */
11
9
  import type { CSSProperties } from "react";
12
10
  import type { ProcessColumnAvailability, SortKey } from "../../../types/system-metrics";
13
11
 
14
12
  export type OptionalColumnKey = "disk" | "gpu" | "net";
13
+ /** Every column whose width the user can drag. The name column takes the rest. */
14
+ export type ResizableColumnKey = "cpu" | "ram" | OptionalColumnKey;
15
15
 
16
16
  /** Alias kept local so call sites don't need to know the exact server-contract
17
17
  * type name — this module only cares about the three boolean flags. */
18
18
  export type ProcessColumnsFlags = ProcessColumnAvailability;
19
19
 
20
+ /** px overrides from a drag; missing keys fall back to `DEFAULT_COLUMN_WIDTH`. */
21
+ export type ColumnWidths = Partial<Record<ResizableColumnKey, number>>;
22
+
20
23
  const OPTIONAL_COLUMN_ORDER: OptionalColumnKey[] = ["disk", "gpu", "net"];
21
24
 
22
25
  /** Disk/Net cells show two values ("↓ read ↑ write"); GPU shows one composite
23
26
  * ("12% · 1.1 GB") and fits narrower. */
24
- const OPTIONAL_COLUMN_WIDTH: Record<OptionalColumnKey, string> = {
25
- disk: "120px",
26
- gpu: "90px",
27
- net: "120px",
27
+ export const DEFAULT_COLUMN_WIDTH: Record<ResizableColumnKey, number> = {
28
+ cpu: 64,
29
+ ram: 80,
30
+ disk: 120,
31
+ gpu: 90,
32
+ net: 120,
28
33
  };
34
+ /** Drag bounds: below 48px a "1.2 MB/s" no longer fits; above 400px the name
35
+ * column starves in a default-size window. */
36
+ export const MIN_COLUMN_WIDTH = 48;
37
+ export const MAX_COLUMN_WIDTH = 400;
29
38
 
30
- /** Fixed tracks that exist regardless of optional columns: Process name (flexible),
31
- * CPU, RAM at the start; the trend/age track (wide only) and the kill button at
32
- * the end. */
33
- const NAME_CPU_RAM = ["minmax(0,1fr)", "64px", "80px"];
34
- const TREND_TRACK = "130px";
39
+ const NAME_TRACK = "minmax(0,1fr)";
35
40
  const KILL_TRACK = "44px";
36
41
 
37
42
  export interface ProcessGridResult {
38
43
  /** Grid template for `@lg` and up: Name/CPU/RAM + every enabled optional column
39
- * in a fixed order + the trend/age track + kill button. */
44
+ * in a fixed order + kill button. */
40
45
  wideTemplate: string;
41
46
  /** Grid template below `@lg`: Name/CPU/RAM + AT MOST the one optional column
42
- * currently sorted by (if enabled) + kill button. No trend/age track. */
47
+ * currently sorted by (if enabled) + kill button. */
43
48
  narrowTemplate: string;
44
49
  /** Which optional columns are enabled at all (host can measure them). */
45
50
  columns: ProcessColumnsFlags;
@@ -52,22 +57,27 @@ function isOptionalColumnKey(key: SortKey): key is OptionalColumnKey {
52
57
  return key === "disk" || key === "gpu" || key === "net";
53
58
  }
54
59
 
55
- export function buildProcessGrid(columns: ProcessColumnsFlags, sortKey: SortKey): ProcessGridResult {
60
+ export function clampColumnWidth(px: number): number {
61
+ return Math.round(Math.min(MAX_COLUMN_WIDTH, Math.max(MIN_COLUMN_WIDTH, px)));
62
+ }
63
+
64
+ export function columnWidthPx(key: ResizableColumnKey, widths: ColumnWidths): number {
65
+ const w = widths[key];
66
+ return typeof w === "number" && Number.isFinite(w) ? clampColumnWidth(w) : DEFAULT_COLUMN_WIDTH[key];
67
+ }
68
+
69
+ export function buildProcessGrid(
70
+ columns: ProcessColumnsFlags,
71
+ sortKey: SortKey,
72
+ widths: ColumnWidths = {},
73
+ ): ProcessGridResult {
56
74
  const enabled = OPTIONAL_COLUMN_ORDER.filter((k) => columns[k]);
57
75
  const narrowExtra = isOptionalColumnKey(sortKey) && columns[sortKey] ? sortKey : null;
76
+ const track = (k: ResizableColumnKey) => `${columnWidthPx(k, widths)}px`;
77
+ const base = [NAME_TRACK, track("cpu"), track("ram")];
58
78
 
59
- const wideTemplate = [
60
- ...NAME_CPU_RAM,
61
- ...enabled.map((k) => OPTIONAL_COLUMN_WIDTH[k]),
62
- TREND_TRACK,
63
- KILL_TRACK,
64
- ].join(" ");
65
-
66
- const narrowTemplate = [
67
- ...NAME_CPU_RAM,
68
- ...(narrowExtra ? [OPTIONAL_COLUMN_WIDTH[narrowExtra]] : []),
69
- KILL_TRACK,
70
- ].join(" ");
79
+ const wideTemplate = [...base, ...enabled.map(track), KILL_TRACK].join(" ");
80
+ const narrowTemplate = [...base, ...(narrowExtra ? [track(narrowExtra)] : []), KILL_TRACK].join(" ");
71
81
 
72
82
  return { wideTemplate, narrowTemplate, columns, narrowExtra };
73
83
  }
@@ -1,25 +1,28 @@
1
- import { ChevronRight, ChevronDown } from "lucide-react";
1
+ import { ChevronRight, ChevronDown, X } from "lucide-react";
2
2
  import { cn } from "@/lib/utils";
3
3
  import { formatRam } from "@/lib/format-bytes";
4
4
  import { cpuColor, formatDiskCell, formatGpuCell, formatNetCell, sumOptionalBps } from "./process-row-format";
5
5
  import { optionalCellClassName, type ProcessGridResult } from "./process-columns-grid";
6
- import { SparklineCanvas } from "./sparkline-canvas";
7
6
  import type { ProcessGroup } from "../../../types/system-metrics";
8
7
 
8
+ const PROTECTED_GROUP_TOOLTIP = "Protected: this app contains a PPM or OS-critical process — cannot be ended as a whole.";
9
+
9
10
  export interface ProcessGroupRowProps {
10
11
  group: ProcessGroup;
11
12
  expanded: boolean;
12
- spark: number[];
13
13
  grid: ProcessGridResult;
14
+ /** True when any member is refused by the kill guard — the whole-app button is disabled. */
15
+ killProtected: boolean;
14
16
  onToggle: (key: string) => void;
17
+ onKillClick: (group: ProcessGroup) => void;
15
18
  }
16
19
 
17
- export function ProcessGroupRow({ group, expanded, spark, grid, onToggle }: ProcessGroupRowProps) {
20
+ export function ProcessGroupRow({ group, expanded, grid, killProtected, onToggle, onKillClick }: ProcessGroupRowProps) {
18
21
  const Chevron = expanded ? ChevronDown : ChevronRight;
19
22
 
20
23
  return (
21
24
  <div
22
- className="grid items-center gap-1 min-h-11 md:min-h-7 px-3 text-xs cursor-pointer hover:bg-surface-hover transition-colors grid-cols-[var(--sysmon-grid-n)] @lg:grid-cols-[var(--sysmon-grid-w)]"
25
+ className="grid items-center gap-1 min-h-11 md:min-h-7 px-3 text-xs cursor-pointer hover:bg-surface-hover transition-colors group/proc grid-cols-[var(--sysmon-grid-n)] @lg:grid-cols-[var(--sysmon-grid-w)]"
23
26
  onClick={() => onToggle(group.key)}
24
27
  data-testid="sysmon-group-row"
25
28
  data-group-key={group.key}
@@ -62,10 +65,30 @@ export function ProcessGroupRow({ group, expanded, spark, grid, onToggle }: Proc
62
65
  {formatNetCell(group.netInBps, group.netOutBps)}
63
66
  </span>
64
67
  )}
65
- <div className="hidden @lg:flex items-center justify-end">
66
- {spark.length > 1 && <SparklineCanvas data={spark} width={120} height={20} maxValue={100} />}
68
+ {/* Same 44px track as the leaf rows' kill button, so ending a whole app (Notion
69
+ and its 7 helpers) is one click instead of seven. */}
70
+ <div className="flex justify-end">
71
+ <button
72
+ type="button"
73
+ onClick={(e) => {
74
+ e.stopPropagation();
75
+ onKillClick(group);
76
+ }}
77
+ disabled={killProtected}
78
+ aria-label={`End ${group.label} and its ${group.count} processes`}
79
+ title={killProtected ? PROTECTED_GROUP_TOOLTIP : `End ${group.label} (${group.count} processes)`}
80
+ data-testid="sysmon-group-kill-btn"
81
+ className={cn(
82
+ "flex items-center justify-center size-11 md:size-7 rounded transition-all",
83
+ "can-hover:opacity-0 can-hover:group-hover/proc:opacity-100",
84
+ killProtected
85
+ ? "opacity-40 cursor-not-allowed"
86
+ : "hover:bg-error/20 hover:text-error active:bg-error/30",
87
+ )}
88
+ >
89
+ <X className="size-3.5" />
90
+ </button>
67
91
  </div>
68
- <div className="hidden @lg:block" />
69
92
  </div>
70
93
  );
71
94
  }
@@ -26,9 +26,14 @@ export function ProcessRow({ proc, indent, grid, onKillClick }: ProcessRowProps)
26
26
  data-gpu-pct={proc.gpuPct}
27
27
  data-net-bps={sumOptionalBps(proc.netInBps, proc.netOutBps)}
28
28
  >
29
+ {/* Uptime lives in the tooltip: a dedicated column cost 130px for a figure
30
+ that matters only when you are already looking at one row. */}
29
31
  <div className={cn("flex items-start gap-1.5 min-w-0", indent && "pl-6")}>
30
32
  <span className="text-text-subtle shrink-0">{proc.pid}</span>
31
- <span className="truncate text-text-secondary" title={proc.command}>
33
+ <span
34
+ className="truncate text-text-secondary"
35
+ title={proc.startedAt ? `${proc.command}\nRunning ${formatAge(proc.startedAt)}` : proc.command}
36
+ >
32
37
  {proc.command}
33
38
  </span>
34
39
  </div>
@@ -49,12 +54,6 @@ export function ProcessRow({ proc, indent, grid, onKillClick }: ProcessRowProps)
49
54
  {formatNetCell(proc.netInBps, proc.netOutBps)}
50
55
  </span>
51
56
  )}
52
- <span
53
- className="hidden @lg:block text-text-subtle text-right"
54
- title={proc.startedAt ? new Date(proc.startedAt).toLocaleString() : ""}
55
- >
56
- {formatAge(proc.startedAt || undefined)}
57
- </span>
58
57
  <div className="flex justify-end">
59
58
  <button
60
59
  type="button"
@@ -2,26 +2,19 @@
2
2
  * filter/sort/expand/rollup rules are unit-testable without mounting a component.
3
3
  * Sort comparator lives in `process-table-sort.ts`, totals accumulator in
4
4
  * `process-table-totals.ts` — both split out to stay under the file-size guideline. */
5
- import type {
6
- MetricsHistoryPoint,
7
- ProcessGroup,
8
- ProcessInfo,
9
- SortDir,
10
- SortKey,
11
- } from "../../../types/system-metrics";
5
+ import type { ProcessGroup, ProcessInfo, SortDir, SortKey } from "../../../types/system-metrics";
12
6
  import { sortByKey, sortFields } from "./process-table-sort";
13
7
  import { accumulateTotals, EMPTY_TOTALS, type Totals } from "./process-table-totals";
14
8
 
15
9
  export type { Totals } from "./process-table-totals";
16
10
 
17
11
  export type TableRow =
18
- | { kind: "group"; group: ProcessGroup; expanded: boolean; spark: number[] }
12
+ | { kind: "group"; group: ProcessGroup; expanded: boolean }
19
13
  | { kind: "process"; proc: ProcessInfo; indent: boolean };
20
14
 
21
15
  export interface BuildRowsInput {
22
16
  processes: ProcessInfo[];
23
17
  groups: ProcessGroup[];
24
- history: MetricsHistoryPoint[];
25
18
  mode: "grouped" | "flat";
26
19
  ppmOnly: boolean;
27
20
  query: string;
@@ -37,8 +30,6 @@ export interface BuildRowsResult {
37
30
  autoExpanded: Set<string>;
38
31
  }
39
32
 
40
- const SPARK_POINTS = 60;
41
-
42
33
  function matches(text: string, query: string): boolean {
43
34
  return text.toLowerCase().includes(query);
44
35
  }
@@ -47,13 +38,9 @@ function processMatches(proc: ProcessInfo, query: string): boolean {
47
38
  return matches(proc.name, query) || matches(proc.command, query);
48
39
  }
49
40
 
50
- function sparkFor(history: MetricsHistoryPoint[], groupKey: string): number[] {
51
- return history.slice(-SPARK_POINTS).map((h) => h.groups[groupKey]?.cpu ?? 0);
52
- }
53
-
54
41
  /** Order fixed: filter (ppmOnly) -> filter (query) -> sort -> flatten (expand). */
55
42
  export function buildRows(input: BuildRowsInput): BuildRowsResult {
56
- const { processes, groups, history, mode, ppmOnly, query, sortKey, sortDir, expanded } = input;
43
+ const { processes, groups, mode, ppmOnly, query, sortKey, sortDir, expanded } = input;
57
44
  const q = query.trim().toLowerCase();
58
45
  const byPid = new Map(processes.map((p) => [p.pid, p]));
59
46
 
@@ -93,12 +80,7 @@ export function buildRows(input: BuildRowsInput): BuildRowsResult {
93
80
  const rows: TableRow[] = [];
94
81
  for (const group of filteredGroups) {
95
82
  const isExpanded = q ? autoExpanded.has(group.key) || expanded.has(group.key) : expanded.has(group.key);
96
- rows.push({
97
- kind: "group",
98
- group,
99
- expanded: isExpanded,
100
- spark: sparkFor(history, group.key),
101
- });
83
+ rows.push({ kind: "group", group, expanded: isExpanded });
102
84
  if (!isExpanded) continue;
103
85
 
104
86
  // Skip pids the current snapshot no longer has — a process can exit between the
@@ -5,7 +5,7 @@ import { formatRam } from "@/lib/format-bytes";
5
5
  import { cpuColor, formatDiskCell, formatGpuCell, formatNetCell } from "./process-row-format";
6
6
  import { optionalCellClassName, PROCESS_ROW_GRID_CLASS, type ProcessGridResult } from "./process-columns-grid";
7
7
  import type { Totals } from "./process-table-totals";
8
- import { SortableHeader } from "./sortable-header";
8
+ import { SortableHeader, type ColumnResizeHandlers } from "./sortable-header";
9
9
  import type { SortDir, SortKey } from "../../../types/system-metrics";
10
10
 
11
11
  const SEARCH_DEBOUNCE_MS = 150;
@@ -83,12 +83,15 @@ export interface ProcessTableHeaderProps {
83
83
  sortDir: SortDir;
84
84
  grid: ProcessGridResult;
85
85
  onSort: (field: Exclude<SortKey, null>) => void;
86
+ resize: ColumnResizeHandlers;
86
87
  }
87
88
 
88
89
  /** Sticky column header row, grid-aligned with the rows below it. Disk/GPU/Net
89
90
  * headers only render when the host reported that column as measurable
90
- * (`grid.columns.<x>`); below `@lg` only the one currently sorted survives. */
91
- export function ProcessTableHeader({ sortKey, sortDir, grid, onSort }: ProcessTableHeaderProps) {
91
+ * (`grid.columns.<x>`); below `@lg` only the one currently sorted survives. Every
92
+ * fixed-width column carries a drag handle on its right edge. */
93
+ export function ProcessTableHeader({ sortKey, sortDir, grid, onSort, resize }: ProcessTableHeaderProps) {
94
+ const common = { activeKey: sortKey, activeDir: sortDir, onClick: onSort, resize };
92
95
  return (
93
96
  <div
94
97
  role="row"
@@ -97,51 +100,18 @@ export function ProcessTableHeader({ sortKey, sortDir, grid, onSort }: ProcessTa
97
100
  PROCESS_ROW_GRID_CLASS,
98
101
  )}
99
102
  >
100
- <SortableHeader
101
- label="Process"
102
- field="name"
103
- activeKey={sortKey}
104
- activeDir={sortDir}
105
- onClick={onSort}
106
- align="left"
107
- testId="sysmon-sort-name"
108
- />
109
- <SortableHeader label="CPU" field="cpu" activeKey={sortKey} activeDir={sortDir} onClick={onSort} testId="sysmon-sort-cpu" />
110
- <SortableHeader label="RAM" field="ram" activeKey={sortKey} activeDir={sortDir} onClick={onSort} testId="sysmon-sort-ram" />
103
+ <SortableHeader label="Process" field="name" {...common} resize={undefined} align="left" testId="sysmon-sort-name" />
104
+ <SortableHeader label="CPU" field="cpu" {...common} resizeKey="cpu" testId="sysmon-sort-cpu" />
105
+ <SortableHeader label="RAM" field="ram" {...common} resizeKey="ram" testId="sysmon-sort-ram" />
111
106
  {grid.columns.disk && (
112
- <SortableHeader
113
- label="Disk"
114
- field="disk"
115
- activeKey={sortKey}
116
- activeDir={sortDir}
117
- onClick={onSort}
118
- testId="sysmon-col-disk"
119
- className={optionalCellClassName(grid, "disk")}
120
- />
107
+ <SortableHeader label="Disk" field="disk" {...common} resizeKey="disk" testId="sysmon-col-disk" className={optionalCellClassName(grid, "disk")} />
121
108
  )}
122
109
  {grid.columns.gpu && (
123
- <SortableHeader
124
- label="GPU"
125
- field="gpu"
126
- activeKey={sortKey}
127
- activeDir={sortDir}
128
- onClick={onSort}
129
- testId="sysmon-col-gpu"
130
- className={optionalCellClassName(grid, "gpu")}
131
- />
110
+ <SortableHeader label="GPU" field="gpu" {...common} resizeKey="gpu" testId="sysmon-col-gpu" className={optionalCellClassName(grid, "gpu")} />
132
111
  )}
133
112
  {grid.columns.net && (
134
- <SortableHeader
135
- label="Net"
136
- field="net"
137
- activeKey={sortKey}
138
- activeDir={sortDir}
139
- onClick={onSort}
140
- testId="sysmon-col-net"
141
- className={optionalCellClassName(grid, "net")}
142
- />
113
+ <SortableHeader label="Net" field="net" {...common} resizeKey="net" testId="sysmon-col-net" className={optionalCellClassName(grid, "net")} />
143
114
  )}
144
- <span className="text-right hidden @lg:block">Trend</span>
145
115
  <span />
146
116
  </div>
147
117
  );
@@ -183,7 +153,6 @@ export function ProcessTableFooter({ totals, grid, gpuUtilPercent }: ProcessTabl
183
153
  {formatNetCell(totals.netInBps, totals.netOutBps)}
184
154
  </span>
185
155
  )}
186
- <span className="hidden @lg:block" />
187
156
  <span />
188
157
  </div>
189
158
  );
@@ -1,24 +1,15 @@
1
1
  import { useCallback, useMemo, useRef, useState } from "react";
2
2
  import { useVirtualizer } from "@tanstack/react-virtual";
3
- import { toast } from "sonner";
4
- import { getAuthToken } from "@/lib/api-client";
5
3
  import { useIsMobile } from "@/hooks/use-is-mobile";
6
4
  import { buildRows, toggleSort } from "./process-table-model";
7
- import { buildKillRequest } from "./build-kill-request";
8
5
  import { buildProcessGrid, gridCssVars } from "./process-columns-grid";
9
6
  import { ProcessTableToolbar, ProcessTableHeader, ProcessTableFooter } from "./process-table-toolbar";
10
7
  import { ProcessGroupRow } from "./process-group-row";
11
8
  import { ProcessRow } from "./process-row";
12
9
  import { KillConfirmDialog } from "./kill-confirm-dialog";
13
- import type {
14
- KillProcessRequest,
15
- KillProcessResult,
16
- MetricsHistoryPoint,
17
- MetricsSnapshot,
18
- ProcessInfo,
19
- SortDir,
20
- SortKey,
21
- } from "../../../types/system-metrics";
10
+ import { useProcessKill } from "./use-process-kill";
11
+ import { useColumnWidths } from "./use-column-widths";
12
+ import type { MetricsSnapshot, SortDir, SortKey } from "../../../types/system-metrics";
22
13
 
23
14
  /** Older cached bundles/snapshots (mid-rollout) predate `processColumns` — default
24
15
  * every optional column off rather than let a missing field throw. */
@@ -26,39 +17,9 @@ const NO_OPTIONAL_COLUMNS = { disk: false, gpu: false, net: false };
26
17
 
27
18
  export interface ProcessTableProps {
28
19
  snapshot: MetricsSnapshot;
29
- history: MetricsHistoryPoint[];
30
20
  }
31
21
 
32
- /** `api.post` (src/web/lib/api-client.ts) has no way to add a bespoke header, and the
33
- * kill route requires `X-PPM-Request: 1` — CSRF hardening, since a cross-origin HTML
34
- * form cannot set a custom header, so a preflight becomes mandatory. A raw fetch here
35
- * mirrors `handleResponse`'s single-Error-on-any-failure contract exactly, so the
36
- * caller still has exactly one catch path. */
37
- async function killProcess(request: KillProcessRequest): Promise<KillProcessResult> {
38
- const token = getAuthToken();
39
- const headers: Record<string, string> = {
40
- "Content-Type": "application/json",
41
- "X-PPM-Request": "1",
42
- };
43
- if (token) headers.Authorization = `Bearer ${token}`;
44
-
45
- const res = await fetch("/api/system/resources/kill", {
46
- method: "POST",
47
- headers,
48
- body: JSON.stringify(request),
49
- });
50
-
51
- let json: { ok: boolean; data?: KillProcessResult; error?: string };
52
- try {
53
- json = await res.json();
54
- } catch {
55
- throw new Error(res.ok ? "Empty response from server" : `Server error (HTTP ${res.status})`);
56
- }
57
- if (json.ok === false) throw new Error(json.error ?? `HTTP ${res.status}`);
58
- return json.data as KillProcessResult;
59
- }
60
-
61
- export function ProcessTable({ snapshot, history }: ProcessTableProps) {
22
+ export function ProcessTable({ snapshot }: ProcessTableProps) {
62
23
  const isMobile = useIsMobile();
63
24
  const parentRef = useRef<HTMLDivElement>(null);
64
25
 
@@ -68,14 +29,15 @@ export function ProcessTable({ snapshot, history }: ProcessTableProps) {
68
29
  const [sortKey, setSortKey] = useState<SortKey>(null);
69
30
  const [sortDir, setSortDir] = useState<SortDir>("desc");
70
31
  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
71
- const [pendingKill, setPendingKill] = useState<ProcessInfo | null>(null);
32
+ const { pendingKill, groupProtected, requestKillProcess, requestKillGroup, confirmKill, cancelKill } =
33
+ useProcessKill(snapshot);
34
+ const { widths, begin: onResizeStart, reset: onResizeReset } = useColumnWidths();
72
35
 
73
36
  const { rows, totals } = useMemo(
74
37
  () =>
75
38
  buildRows({
76
39
  processes: snapshot.processes,
77
40
  groups: snapshot.groups,
78
- history,
79
41
  mode,
80
42
  ppmOnly,
81
43
  query,
@@ -83,11 +45,11 @@ export function ProcessTable({ snapshot, history }: ProcessTableProps) {
83
45
  sortDir,
84
46
  expanded,
85
47
  }),
86
- [snapshot.processes, snapshot.groups, history, mode, ppmOnly, query, sortKey, sortDir, expanded],
48
+ [snapshot.processes, snapshot.groups, mode, ppmOnly, query, sortKey, sortDir, expanded],
87
49
  );
88
50
 
89
51
  const columns = snapshot.processColumns ?? NO_OPTIONAL_COLUMNS;
90
- const grid = useMemo(() => buildProcessGrid(columns, sortKey), [columns, sortKey]);
52
+ const grid = useMemo(() => buildProcessGrid(columns, sortKey, widths), [columns, sortKey, widths]);
91
53
  const gpuUtilPercent = snapshot.system.gpus[0]?.utilPercent;
92
54
 
93
55
  const virtualizer = useVirtualizer({
@@ -115,16 +77,6 @@ export function ProcessTable({ snapshot, history }: ProcessTableProps) {
115
77
  });
116
78
  }, []);
117
79
 
118
- const handleConfirmKill = useCallback(async (proc: ProcessInfo, tree: boolean) => {
119
- setPendingKill(null);
120
- try {
121
- await killProcess(buildKillRequest(proc, tree));
122
- toast.success(`Ended ${proc.name} (${proc.pid})`);
123
- } catch (e) {
124
- toast.error(e instanceof Error ? e.message : `Failed to end ${proc.name}`);
125
- }
126
- }, []);
127
-
128
80
  // `@container`: the trend/age track is toggled on the table's own width, not the
129
81
  // viewport — a floating window is routinely narrower than the screen it sits on.
130
82
  // `gridCssVars` sets `--sysmon-grid-n`/`--sysmon-grid-w` once here; CSS custom
@@ -143,7 +95,13 @@ export function ProcessTable({ snapshot, history }: ProcessTableProps) {
143
95
  onPpmOnlyChange={setPpmOnly}
144
96
  onQueryChange={setQuery}
145
97
  />
146
- <ProcessTableHeader sortKey={sortKey} sortDir={sortDir} grid={grid} onSort={handleSort} />
98
+ <ProcessTableHeader
99
+ sortKey={sortKey}
100
+ sortDir={sortDir}
101
+ grid={grid}
102
+ onSort={handleSort}
103
+ resize={{ onResizeStart, onResizeReset }}
104
+ />
147
105
  <div ref={parentRef} className="flex-1 min-h-0 overflow-y-auto">
148
106
  <div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
149
107
  {virtualizer.getVirtualItems().map((item) => {
@@ -164,12 +122,13 @@ export function ProcessTable({ snapshot, history }: ProcessTableProps) {
164
122
  <ProcessGroupRow
165
123
  group={row.group}
166
124
  expanded={row.expanded}
167
- spark={row.spark}
168
125
  grid={grid}
126
+ killProtected={groupProtected.get(row.group.key) ?? true}
169
127
  onToggle={handleToggleGroup}
128
+ onKillClick={requestKillGroup}
170
129
  />
171
130
  ) : (
172
- <ProcessRow proc={row.proc} indent={row.indent} grid={grid} onKillClick={setPendingKill} />
131
+ <ProcessRow proc={row.proc} indent={row.indent} grid={grid} onKillClick={requestKillProcess} />
173
132
  )}
174
133
  </div>
175
134
  );
@@ -178,9 +137,9 @@ export function ProcessTable({ snapshot, history }: ProcessTableProps) {
178
137
  </div>
179
138
  <ProcessTableFooter totals={totals} grid={grid} gpuUtilPercent={gpuUtilPercent} />
180
139
  <KillConfirmDialog
181
- process={pendingKill}
182
- onConfirm={handleConfirmKill}
183
- onCancel={() => setPendingKill(null)}
140
+ target={pendingKill}
141
+ onConfirm={confirmKill}
142
+ onCancel={cancelKill}
184
143
  />
185
144
  </div>
186
145
  );