@bluemanta/portdash 0.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # PortDash
2
+
3
+ A visual control panel for local dev servers. Single file, zero dependencies — just Node's built-in modules.
4
+
5
+ [中文说明](README.zh-CN.md)
6
+
7
+ ## Run it
8
+
9
+ ```bash
10
+ npx @bluemanta/portdash
11
+ ```
12
+
13
+ or clone and run directly:
14
+
15
+ ```bash
16
+ git clone https://github.com/bluemanta/portdash.git
17
+ cd portdash
18
+ node portdash.js
19
+ ```
20
+
21
+ Then open http://localhost:7777
22
+
23
+ **Platform**: built and tested on macOS. The Linux code path (`/proc/meminfo`, `lsof`, `ps`) is implemented but not yet verified on a real machine — issues and PRs welcome. Windows isn't supported.
24
+
25
+ ## What it does
26
+
27
+ - **See** every process listening on a port, grouped by project, with live memory usage per group
28
+ - **Control** start / pause / resume / restart / stop — signals go to the whole process group, so everything `npm run dev` spawns gets caught too
29
+ - **Pause means pause**: SIGSTOP freezes the process in place. Memory and ports stay held. Resuming (SIGCONT) is instant
30
+ - **Logs**: anything PortDash starts gets its output recorded under `~/.portdash/logs/`, viewable right in the UI
31
+ - **Recognizes servers you started yourself** — if you ran a dev server by hand in a terminal, PortDash matches it to the right project by working directory and lets you control it too
32
+
33
+ ## Memory protection (so one runaway service can't take the whole machine down)
34
+
35
+ A watchdog scans the full system process table every 2 seconds, through five gates:
36
+
37
+ | Gate | Trigger | Action |
38
+ |---|---|---|
39
+ | Node heap cap | `NODE_OPTIONS=--max-old-space-size` injected at start | node OOMs itself instead of exhausting system memory |
40
+ | Per-project soft limit | process group RSS > `projectRssMB` (default 4G) | SIGSTOP freeze, preserves the crash scene, alert shown in the UI |
41
+ | Per-project hard limit | > `hardRssMB` (default 10G) | SIGKILL immediately, no more mercy |
42
+ | System-wide pressure | available memory < 12%, or swap usage > 4G | freezes whichever project is using the most memory right now; if the offender wasn't started by PortDash, you just get a warning |
43
+ | Start-burst gate | memory already tight, or the same project started more than 3 times in 60s | refuses to start, tells you to check the logs first |
44
+
45
+ **Freezing is not killing.** The process is still there — after reading the logs you can "Resume" and keep going, or "Stop" and call it done.
46
+
47
+ All thresholds live under `limits` in `~/.portdash/config.json`; individual projects can also override them in the UI.
48
+ Set `limits.enabled` to `false` if you'd rather it stay out of your way entirely.
49
+
50
+ ## Configuration
51
+
52
+ Everything lives under `~/.portdash/`:
53
+
54
+ | File | Purpose |
55
+ |---|---|
56
+ | `config.json` | scan roots (`scanRoots`), UI port (`uiPort`), scan depth (`scanDepth`), memory limits (`limits`) |
57
+ | `projects.json` | the project registry. Auto-generated on first run; later rescans only add, never overwrite |
58
+ | `state.json` | bookkeeping for processes PortDash itself started |
59
+ | `logs/` | one log file per project, auto-archived to `.old` once it passes 5MB |
60
+
61
+ The start command is guessed from `package.json`'s `scripts` at scan time (`dev` > `start` > `serve`). If it guessed wrong, fix it with "Edit" in the UI — it won't be overwritten after that.
62
+
63
+ ## Notes
64
+
65
+ - Quitting PortDash doesn't stop services it already started — they keep running in the background, and PortDash re-adopts them on the next launch
66
+ - The watchdog only auto-acts on processes PortDash itself started; it won't touch anything you ran by hand
67
+ - The UI only listens on 127.0.0.1 — it's not exposed to your LAN
68
+
69
+ ## License
70
+
71
+ MIT
@@ -0,0 +1,60 @@
1
+ # PortDash
2
+
3
+ 本地开发服务的可视化控制台。零依赖单文件,只用 Node 自带模块。
4
+
5
+ [English](README.md)
6
+
7
+ ## 运行
8
+
9
+ ```bash
10
+ cd ~/Documents/CodeProjects/portdash
11
+ node portdash.js
12
+ ```
13
+
14
+ 然后打开 http://localhost:7777
15
+
16
+ 也可以在访达里双击 `启动PortDash.command`。
17
+
18
+ ## 它能做什么
19
+
20
+ - **看**:所有正在监听端口的进程,按项目归类;每个项目显示实时内存占用
21
+ - **控**:启动 / 暂停 / 恢复 / 重启 / 停止,信号发给整个进程组,`npm run dev` 派生的子进程一起收
22
+ - **暂停是真暂停**:SIGSTOP 挂起,进程冻在原地,内存和端口都保留,恢复(SIGCONT)是瞬间的
23
+ - **日志**:由 PortDash 启动的服务,输出记在 `~/.portdash/logs/`,界面上直接看
24
+ - **外部启动也认**:你在终端里手动跑的 dev server,只要工作目录对得上,也显示在对应项目下并可被停掉
25
+
26
+ ## 内存保护(防止某个服务把整台机器拖死)
27
+
28
+ 看门狗每 2 秒扫一次全系统进程表,五道闸门:
29
+
30
+ | 闸门 | 触发条件 | 动作 |
31
+ |---|---|---|
32
+ | Node 堆上限 | 启动时注入 `NODE_OPTIONS=--max-old-space-size` | 让 node 自己 OOM 退出,而不是吃光系统内存 |
33
+ | 单项目软上限 | 进程组内存 > `projectRssMB`(默认 4G) | SIGSTOP 冻结,保留现场,界面弹告警 |
34
+ | 单项目硬上限 | > `hardRssMB`(默认 10G) | 直接 SIGKILL,不再客气 |
35
+ | 系统水位 | 可用内存 < 12%,或 swap 用量 > 4G | 冻结当前最占内存的项目;如果元凶不是 PortDash 起的,只告警 |
36
+ | 启动闸门 | 内存已紧张 / 60 秒内同一项目启动超过 3 次 | 拒绝启动,提示先看日志 |
37
+
38
+ **冻结不是杀死**:进程还在,看完日志你可以「恢复」继续,或者「停止」收工。
39
+
40
+ 阈值都在 `~/.portdash/config.json` 的 `limits` 里改,单个项目还能在界面上单独设。
41
+ 嫌它管太宽就把 `limits.enabled` 设成 `false`。
42
+
43
+ ## 配置
44
+
45
+ 都在 `~/.portdash/` 下:
46
+
47
+ | 文件 | 作用 |
48
+ |---|---|
49
+ | `config.json` | 扫描目录 `scanRoots`、界面端口 `uiPort`、扫描深度 `scanDepth`、内存限额 `limits` |
50
+ | `projects.json` | 项目登记表。首次运行自动扫描生成,之后「重新扫描」只增不改 |
51
+ | `state.json` | PortDash 拉起的进程记录 |
52
+ | `logs/` | 每个项目一个日志文件,启动时超过 5M 自动归档为 .old |
53
+
54
+ 启动命令是扫描时从 `package.json` 的 scripts 猜的(dev > start > serve),猜错了在界面上点「编辑」改掉,之后不会被覆盖。
55
+
56
+ ## 注意
57
+
58
+ - 关掉 PortDash 不会影响已经启动的服务,它们继续在后台跑;重开会重新认领它们
59
+ - 看门狗只自动处置 PortDash 自己启动的进程,不会去动你手动跑的东西
60
+ - 界面只监听 127.0.0.1,不对局域网开放
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@bluemanta/portdash",
3
+ "version": "0.1.0",
4
+ "description": "A visual control panel for local dev servers — see every listening port, control it, and freeze (not kill) runaway memory before it takes the machine down.",
5
+ "keywords": ["dev-server", "localhost", "ports", "process-manager", "dashboard", "cli", "memory", "watchdog"],
6
+ "homepage": "https://github.com/bluemanta/portdash",
7
+ "bugs": "https://github.com/bluemanta/portdash/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/bluemanta/portdash.git"
11
+ },
12
+ "license": "MIT",
13
+ "author": "Chris Wang",
14
+ "main": "portdash.js",
15
+ "bin": {
16
+ "portdash": "portdash.js"
17
+ },
18
+ "files": [
19
+ "portdash.js",
20
+ "README.md",
21
+ "README.zh-CN.md",
22
+ "LICENSE"
23
+ ],
24
+ "engines": {
25
+ "node": ">=16"
26
+ },
27
+ "os": ["darwin", "linux"]
28
+ }
package/portdash.js ADDED
@@ -0,0 +1,873 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * PortDash — a visual control panel for local dev servers
5
+ *
6
+ * Zero dependencies, single file. Run: node portdash.js
7
+ * Then open: http://localhost:7777
8
+ *
9
+ * Config and data live under ~/.portdash/:
10
+ * config.json scan roots, UI port, memory limits
11
+ * projects.json the project registry (built by scanning, editable in the UI)
12
+ * state.json bookkeeping for processes PortDash itself started
13
+ * logs/ per-project run logs
14
+ */
15
+
16
+ const http = require('http');
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const os = require('os');
20
+ const crypto = require('crypto');
21
+ const { execFileSync, spawn } = require('child_process');
22
+
23
+ const HOME = os.homedir();
24
+ const ROOT = path.join(HOME, '.portdash');
25
+ const F_CFG = path.join(ROOT, 'config.json');
26
+ const F_REG = path.join(ROOT, 'projects.json');
27
+ const F_STATE = path.join(ROOT, 'state.json');
28
+ const D_LOGS = path.join(ROOT, 'logs');
29
+
30
+ const SHELL = process.env.SHELL || (process.platform === 'darwin' ? '/bin/zsh' : '/bin/bash');
31
+ const IS_MAC = process.platform === 'darwin';
32
+
33
+ const DEFAULT_CFG = {
34
+ uiPort: 7777,
35
+ scanRoots: ['~/Documents/CodeProjects'],
36
+ scanDepth: 3,
37
+ ignoreDirs: ['node_modules', '.git', 'dist', 'build', 'out', '.next', '.nuxt',
38
+ 'vendor', '.venv', 'venv', '__pycache__', 'target', '.cache', 'coverage'],
39
+
40
+ // ------- Memory protection. Raise the numbers to loosen it, or set enabled:false to turn it off -------
41
+ limits: {
42
+ enabled: true,
43
+ projectRssMB: 4096, // a single project (whole process group) over this → auto-freeze
44
+ hardRssMB: 10240, // over this → kill it outright, no more mercy
45
+ nodeHeapMB: 3072, // injected as --max-old-space-size so node OOMs itself instead of taking down the box
46
+ sysAvailFloorPct: 12, // system available memory below this % → freeze the biggest offender
47
+ sysSwapCeilMB: 4096, // swap usage above this → same as above
48
+ startBurst: 3, // max starts per project within 60s (guards against crash-restart loops)
49
+ logMaxMB: 5 // rotate the log to .old if it's already bigger than this at startup
50
+ }
51
+ };
52
+
53
+ // ---------------------------------------------------------------- basics
54
+
55
+ function ensure() {
56
+ fs.mkdirSync(ROOT, { recursive: true });
57
+ fs.mkdirSync(D_LOGS, { recursive: true });
58
+ }
59
+
60
+ function readJSON(file, fallback) {
61
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return fallback; }
62
+ }
63
+
64
+ function writeJSON(file, data) {
65
+ ensure();
66
+ fs.writeFileSync(file, JSON.stringify(data, null, 2));
67
+ }
68
+
69
+ function expand(p) {
70
+ if (!p) return p;
71
+ if (p === '~') return HOME;
72
+ if (p.startsWith('~/')) return path.join(HOME, p.slice(2));
73
+ return p;
74
+ }
75
+
76
+ const fmtMB = (mb) => (mb >= 1024 ? (mb / 1024).toFixed(1) + 'G' : Math.round(mb) + 'M');
77
+ const shorten = (p) => (p.startsWith(HOME) ? '~' + p.slice(HOME.length) : p);
78
+
79
+ function run(cmd, args) {
80
+ try {
81
+ return execFileSync(cmd, args, {
82
+ encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, timeout: 8000,
83
+ stdio: ['ignore', 'pipe', 'ignore']
84
+ });
85
+ } catch (e) {
86
+ return (e && e.stdout) ? e.stdout : '';
87
+ }
88
+ }
89
+
90
+ function idOf(cwd) {
91
+ const h = crypto.createHash('md5').update(cwd).digest('hex').slice(0, 6);
92
+ const base = path.basename(cwd).replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 24);
93
+ return `${base}-${h}`;
94
+ }
95
+
96
+ function getCfg() {
97
+ const raw = readJSON(F_CFG, {});
98
+ const cfg = Object.assign({}, DEFAULT_CFG, raw);
99
+ cfg.limits = Object.assign({}, DEFAULT_CFG.limits, raw.limits || {});
100
+ return cfg;
101
+ }
102
+ const getReg = () => readJSON(F_REG, []);
103
+ const setReg = (r) => writeJSON(F_REG, r);
104
+
105
+ let managed = readJSON(F_STATE, {});
106
+ const saveManaged = () => writeJSON(F_STATE, managed);
107
+
108
+ // ---------------------------------------------------------------- alerts
109
+
110
+ let alerts = [];
111
+ const alertSeen = {}; // don't flood with the same alert more than once per 60s
112
+ function alert_(level, text, projectId, key) {
113
+ const k = key || (level + ':' + text);
114
+ const now = Date.now();
115
+ if (alertSeen[k] && now - alertSeen[k] < 60000) return;
116
+ alertSeen[k] = now;
117
+ alerts.unshift({ id: crypto.randomBytes(4).toString('hex'), t: now, level, text, projectId });
118
+ alerts = alerts.slice(0, 20);
119
+ console.log(`[${level === 'danger' ? 'action' : 'notice'}] ${text}`);
120
+ if (projectId) {
121
+ try {
122
+ fs.appendFileSync(path.join(D_LOGS, projectId + '.log'),
123
+ `\n***** ${new Date().toLocaleString()} PortDash: ${text} *****\n`);
124
+ } catch (e) { /* ignore */ }
125
+ }
126
+ }
127
+
128
+ // ---------------------------------------------------------------- project scanning
129
+
130
+ function detectProject(dir) {
131
+ const has = (f) => fs.existsSync(path.join(dir, f));
132
+
133
+ if (has('package.json')) {
134
+ const pkg = readJSON(path.join(dir, 'package.json'), {});
135
+ const scripts = pkg.scripts || {};
136
+ const key = ['dev', 'start', 'serve', 'develop'].find((k) => scripts[k]);
137
+ const pm = has('pnpm-lock.yaml') ? 'pnpm'
138
+ : has('yarn.lock') ? 'yarn'
139
+ : (has('bun.lockb') || has('bun.lock')) ? 'bun'
140
+ : 'npm';
141
+ return { name: pkg.name || path.basename(dir), cmd: key ? `${pm} run ${key}` : '', kind: 'node' };
142
+ }
143
+ if (has('manage.py')) return { name: path.basename(dir), cmd: 'python3 manage.py runserver', kind: 'django' };
144
+ if (has('pyproject.toml') || has('requirements.txt')) return { name: path.basename(dir), cmd: '', kind: 'python' };
145
+ if (has('index.html')) {
146
+ // Weak match: a root-level index.html also counts as a previewable static site,
147
+ // but we keep walking below it looking for a "real" sub-project.
148
+ return { name: path.basename(dir), cmd: 'python3 -m http.server 8000', kind: 'static', weak: true };
149
+ }
150
+ return null;
151
+ }
152
+
153
+ function walk(dir, depth, cfg, out) {
154
+ if (depth > cfg.scanDepth) return;
155
+ let items;
156
+ try { items = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return; }
157
+
158
+ if (depth > 0) {
159
+ const p = detectProject(dir);
160
+ if (p) {
161
+ out.push(Object.assign({}, p, { cwd: dir }));
162
+ if (!p.weak) return; // stop drilling down once we get a strong match
163
+ }
164
+ }
165
+ for (const it of items) {
166
+ if (!it.isDirectory()) continue;
167
+ if (it.name.startsWith('.')) continue;
168
+ if (cfg.ignoreDirs.includes(it.name)) continue;
169
+ walk(path.join(dir, it.name), depth + 1, cfg, out);
170
+ }
171
+ }
172
+
173
+ function scanProjects() {
174
+ const cfg = getCfg();
175
+ const found = [];
176
+ for (const r of cfg.scanRoots) walk(expand(r), 0, cfg, found);
177
+
178
+ const reg = getReg();
179
+ const known = new Set(reg.map((p) => p.cwd));
180
+ let added = 0;
181
+ for (const f of found) {
182
+ if (known.has(f.cwd)) continue; // never overwrite an already-registered project (keeps your edits)
183
+ reg.push({ id: idOf(f.cwd), name: f.name, cwd: f.cwd, cmd: f.cmd, kind: f.kind,
184
+ port: null, memMB: null, heapMB: null });
185
+ added++;
186
+ }
187
+ setReg(reg);
188
+ return { total: reg.length, added };
189
+ }
190
+
191
+ // ---------------------------------------------------------------- system inspection
192
+
193
+ function listeners() {
194
+ const out = run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN']);
195
+ const rows = [], seen = new Set();
196
+ for (const line of out.split('\n').slice(1)) {
197
+ if (!line.trim()) continue;
198
+ const t = line.trim().split(/\s+/);
199
+ const pid = parseInt(t[1], 10);
200
+ if (!pid) continue;
201
+ let port = null;
202
+ for (let i = t.length - 1; i >= 0; i--) {
203
+ const m = /^(.*):(\d+)$/.exec(t[i]);
204
+ if (m) { port = parseInt(m[2], 10); break; }
205
+ }
206
+ if (!port) continue;
207
+ const key = pid + ':' + port;
208
+ if (seen.has(key)) continue;
209
+ seen.add(key);
210
+ rows.push({ pid, port, command: t[0] });
211
+ }
212
+ return rows;
213
+ }
214
+
215
+ /** One pass over the whole process table: pid → info, and pgid → total RSS (MB) */
216
+ function processTable() {
217
+ const out = run('ps', ['-Ao', 'pid=,pgid=,rss=,stat=,etime=,command=']);
218
+ const byPid = {}, rssByPgid = {};
219
+ for (const line of out.split('\n')) {
220
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S+)\s+(.*)$/.exec(line);
221
+ if (!m) continue;
222
+ const pid = +m[1], pgid = +m[2], rssMB = +m[3] / 1024;
223
+ byPid[pid] = { pid, pgid, rssMB, stat: m[4], etime: m[5], command: m[6] };
224
+ rssByPgid[pgid] = (rssByPgid[pgid] || 0) + rssMB;
225
+ }
226
+ return { byPid, rssByPgid };
227
+ }
228
+
229
+ function cwdInfo(pids) {
230
+ const map = {};
231
+ if (!pids.length) return map;
232
+ const out = run('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')]);
233
+ let cur = null;
234
+ for (const line of out.split('\n')) {
235
+ if (line[0] === 'p') cur = line.slice(1).trim();
236
+ else if (line[0] === 'n' && cur && !map[cur]) map[cur] = line.slice(1).trim();
237
+ }
238
+ return map;
239
+ }
240
+
241
+ /** System memory pressure. Any parse failure returns null — the watchdog would rather
242
+ do nothing than act on a bad reading. */
243
+ function sysMem() {
244
+ try {
245
+ if (IS_MAC) {
246
+ const vm = run('vm_stat', []);
247
+ if (!vm) return null;
248
+ const psz = +((/page size of (\d+) bytes/.exec(vm) || [, 4096])[1]);
249
+ const g = (label) => {
250
+ const m = new RegExp(label + ':\\s+(\\d+)').exec(vm);
251
+ return m ? +m[1] : 0;
252
+ };
253
+ const free = g('Pages free'), active = g('Pages active'), inactive = g('Pages inactive'),
254
+ spec = g('Pages speculative'), wired = g('Pages wired down'),
255
+ compressed = g('Pages occupied by compressor'), purgeable = g('Pages purgeable');
256
+ const total = free + active + inactive + spec + wired + compressed;
257
+ if (!total) return null;
258
+ const avail = free + inactive + spec + purgeable;
259
+ const sw = /used = ([\d.]+)([MGK])/.exec(run('sysctl', ['-n', 'vm.swapusage']) || '');
260
+ const swapUsedMB = sw ? +sw[1] * (sw[2] === 'G' ? 1024 : sw[2] === 'K' ? 1 / 1024 : 1) : 0;
261
+ return {
262
+ availPct: Math.round((avail / total) * 100),
263
+ swapUsedMB: Math.round(swapUsedMB),
264
+ totalMB: Math.round((total * psz) / 1048576)
265
+ };
266
+ }
267
+ const mi = fs.readFileSync('/proc/meminfo', 'utf8');
268
+ const kb = (k) => { const m = new RegExp('^' + k + ':\\s+(\\d+)', 'm').exec(mi); return m ? +m[1] : 0; };
269
+ const total = kb('MemTotal'), avail = kb('MemAvailable');
270
+ if (!total) return null;
271
+ return {
272
+ availPct: Math.round((avail / total) * 100),
273
+ swapUsedMB: Math.round((kb('SwapTotal') - kb('SwapFree')) / 1024),
274
+ totalMB: Math.round(total / 1024)
275
+ };
276
+ } catch (e) { return null; }
277
+ }
278
+
279
+ const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (e) { return false; } };
280
+
281
+ // ---------------------------------------------------------------- state aggregation
282
+
283
+ function buildState() {
284
+ const cfg = getCfg();
285
+ const reg = getReg();
286
+ const L = listeners();
287
+ const { byPid, rssByPgid } = processTable();
288
+
289
+ let dirty = false;
290
+ for (const [id, m] of Object.entries(managed)) {
291
+ if (!byPid[m.pid]) { delete managed[id]; dirty = true; }
292
+ }
293
+ if (dirty) saveManaged();
294
+
295
+ const C = cwdInfo([...new Set(L.map((r) => r.pid))]);
296
+ const claimed = new Set();
297
+
298
+ const projects = reg.map((p) => {
299
+ let pid = null, source = null;
300
+ const m = managed[p.id];
301
+ if (m && byPid[m.pid]) { pid = m.pid; source = 'managed'; }
302
+ if (!pid) {
303
+ const hit = L.find((r) => C[r.pid] === p.cwd);
304
+ if (hit) { pid = hit.pid; source = 'external'; }
305
+ }
306
+
307
+ let status = 'stopped', ports = [], etime = null, pgid = null, rssMB = null;
308
+ if (pid) {
309
+ const info = byPid[pid] || {};
310
+ pgid = info.pgid || pid;
311
+ etime = info.etime || null;
312
+ status = (info.stat && info.stat.startsWith('T')) ? 'paused' : 'running';
313
+ rssMB = Math.round(rssByPgid[pgid] || info.rssMB || 0);
314
+ ports = [...new Set(L.filter((r) => byPid[r.pid] && byPid[r.pid].pgid === pgid).map((r) => r.port))]
315
+ .sort((a, b) => a - b);
316
+ L.forEach((r) => { if (byPid[r.pid] && byPid[r.pid].pgid === pgid) claimed.add(r.pid); });
317
+ }
318
+
319
+ return Object.assign({}, p, {
320
+ cwdShort: shorten(p.cwd), status, pid, pgid, ports, etime, source, rssMB,
321
+ memLimit: p.memMB || cfg.limits.projectRssMB,
322
+ openPort: ports[0] || p.port || null
323
+ });
324
+ });
325
+
326
+ const others = L.filter((r) => !claimed.has(r.pid) && r.pid !== process.pid).map((r) => {
327
+ const info = byPid[r.pid] || {};
328
+ const exe = (info.command || '').trim().split(/\s+/)[0];
329
+ return {
330
+ pid: r.pid, pgid: info.pgid || r.pid, port: r.port,
331
+ command: exe ? path.basename(exe) : r.command,
332
+ cmdline: info.command || '', etime: info.etime || '',
333
+ rssMB: Math.round(rssByPgid[info.pgid] || info.rssMB || 0),
334
+ cwd: C[r.pid] || '', cwdShort: C[r.pid] ? shorten(C[r.pid]) : '',
335
+ paused: !!(info.stat && info.stat.startsWith('T'))
336
+ };
337
+ }).sort((a, b) => a.port - b.port);
338
+
339
+ return { projects, others, alerts, sys: sysMem(), limits: cfg.limits, now: Date.now() };
340
+ }
341
+
342
+ // ---------------------------------------------------------------- process control
343
+
344
+ function signalGroup(pgid, sig) {
345
+ try { process.kill(-pgid, sig); return true; } catch (e) { /* fall through */ }
346
+ try { process.kill(pgid, sig); return true; } catch (e) { return false; }
347
+ }
348
+
349
+ const starting = new Set(); // projects currently starting up, guards against double-clicks
350
+ const startLog = {}; // id → recent start timestamps, guards against crash-restart loops
351
+
352
+ function rotateLog(file, maxMB) {
353
+ try {
354
+ if (fs.existsSync(file) && fs.statSync(file).size > maxMB * 1048576) {
355
+ fs.renameSync(file, file + '.old');
356
+ }
357
+ } catch (e) { /* ignore */ }
358
+ }
359
+
360
+ function startProject(id) {
361
+ const lim = getCfg().limits;
362
+ const p = getReg().find((x) => x.id === id);
363
+ if (!p) throw new Error('Project not found');
364
+ if (!p.cmd) throw new Error('No start command configured for this project — click "Edit" and set one (e.g. npm run dev)');
365
+ if (!fs.existsSync(p.cwd)) throw new Error('Directory does not exist: ' + p.cwd);
366
+
367
+ // --- Guard against double-clicks: the UI only refreshes every 2.5s, so two quick clicks
368
+ // would otherwise really start two instances ---
369
+ if (starting.has(id)) throw new Error('Already starting, hang on');
370
+
371
+ // --- Re-check real state right before starting instead of trusting the cache ---
372
+ const cur = buildState().projects.find((x) => x.id === id);
373
+ if (cur && cur.pid) throw new Error(`Already running (pid ${cur.pid}) — use "Restart" instead`);
374
+
375
+ // --- Guard against crash-restart loops: a project that keeps failing to start and gets
376
+ // retried is the classic path to a memory avalanche ---
377
+ const now = Date.now();
378
+ startLog[id] = (startLog[id] || []).filter((t) => now - t < 60000);
379
+ if (lim.enabled && startLog[id].length >= lim.startBurst) {
380
+ throw new Error(`Already started ${lim.startBurst} times in the last minute. Check "Logs" to see why it won't come up instead of forcing it again`);
381
+ }
382
+
383
+ // --- Refuse to start anything new while the system is already tight on memory ---
384
+ const sm = sysMem();
385
+ if (lim.enabled && sm && sm.availPct < lim.sysAvailFloorPct) {
386
+ throw new Error(`Only ${sm.availPct}% memory available — stop something first`);
387
+ }
388
+
389
+ ensure();
390
+ const logFile = path.join(D_LOGS, id + '.log');
391
+ rotateLog(logFile, lim.logMaxMB);
392
+ const fd = fs.openSync(logFile, 'a');
393
+ fs.writeSync(fd, `\n===== ${new Date().toLocaleString()} start: ${p.cmd} =====\n`);
394
+
395
+ // --- Cap node's heap so it OOMs itself instead of taking the whole system down ---
396
+ const env = Object.assign({}, process.env, { FORCE_COLOR: '0' });
397
+ const heap = p.heapMB || lim.nodeHeapMB;
398
+ if (lim.enabled && heap && !/max-old-space-size/.test(env.NODE_OPTIONS || '')) {
399
+ env.NODE_OPTIONS = ((env.NODE_OPTIONS || '') + ` --max-old-space-size=${heap}`).trim();
400
+ }
401
+
402
+ const child = spawn(SHELL, ['-lc', p.cmd], {
403
+ cwd: p.cwd,
404
+ detached: true, // its own process group, so signals reach the whole child tree
405
+ stdio: ['ignore', fd, fd],
406
+ env
407
+ });
408
+ child.unref();
409
+
410
+ starting.add(id);
411
+ setTimeout(() => starting.delete(id), 3000);
412
+ startLog[id].push(now);
413
+ managed[id] = { pid: child.pid, pgid: child.pid, startedAt: now, cmd: p.cmd };
414
+ saveManaged();
415
+ return { pid: child.pid, heapLimitMB: lim.enabled ? heap : null };
416
+ }
417
+
418
+ function resolveTarget(body) {
419
+ if (body.pid) {
420
+ const t = processTable().byPid[body.pid];
421
+ return { pid: +body.pid, pgid: t ? t.pgid : +body.pid };
422
+ }
423
+ const st = buildState().projects.find((p) => p.id === body.id);
424
+ if (!st || !st.pid) throw new Error('This project is not currently running');
425
+ return { pid: st.pid, pgid: st.pgid };
426
+ }
427
+
428
+ function stopTarget(body) {
429
+ const { pid, pgid } = resolveTarget(body);
430
+ signalGroup(pgid, 'SIGCONT'); // thaw first, or a frozen process never sees the TERM
431
+ signalGroup(pgid, 'SIGTERM');
432
+ setTimeout(() => { if (alive(pid)) signalGroup(pgid, 'SIGKILL'); }, 3000);
433
+ if (body.id) { delete managed[body.id]; saveManaged(); }
434
+ return { ok: true };
435
+ }
436
+
437
+ const waitGone = (pid, ms) => new Promise((resolve) => {
438
+ const t0 = Date.now();
439
+ const tick = () => (!alive(pid) || Date.now() - t0 > ms) ? resolve() : setTimeout(tick, 200);
440
+ tick();
441
+ });
442
+
443
+ async function restartProject(id) {
444
+ const st = buildState().projects.find((p) => p.id === id);
445
+ if (st && st.pid) {
446
+ signalGroup(st.pgid, 'SIGCONT');
447
+ signalGroup(st.pgid, 'SIGTERM');
448
+ await waitGone(st.pid, 5000);
449
+ if (alive(st.pid)) { signalGroup(st.pgid, 'SIGKILL'); await waitGone(st.pid, 2000); }
450
+ delete managed[id]; saveManaged();
451
+ }
452
+ await new Promise((r) => setTimeout(r, 500)); // give the port a moment to actually free up
453
+ return startProject(id);
454
+ }
455
+
456
+ // ---------------------------------------------------------------- memory watchdog
457
+
458
+ function watchdog() {
459
+ const lim = getCfg().limits;
460
+ if (!lim.enabled) return;
461
+
462
+ const reg = getReg();
463
+ const { byPid, rssByPgid } = processTable();
464
+ const running = [];
465
+
466
+ for (const [id, m] of Object.entries(managed)) {
467
+ const info = byPid[m.pid];
468
+ if (!info) continue;
469
+ const p = reg.find((x) => x.id === id);
470
+ if (!p) continue;
471
+ running.push({
472
+ id, name: p.name, pgid: info.pgid,
473
+ rss: Math.round(rssByPgid[info.pgid] || info.rssMB || 0),
474
+ paused: !!(info.stat && info.stat.startsWith('T')),
475
+ limit: p.memMB || lim.projectRssMB
476
+ });
477
+ }
478
+
479
+ // 1) hard per-project limit → kill outright
480
+ for (const r of running) {
481
+ if (r.rss > lim.hardRssMB) {
482
+ signalGroup(r.pgid, 'SIGKILL');
483
+ delete managed[r.id]; saveManaged();
484
+ alert_('danger', `"${r.name}" hit ${fmtMB(r.rss)}, over the hard limit of ${fmtMB(lim.hardRssMB)} — force-stopped.`, r.id, 'hard:' + r.id);
485
+ }
486
+ }
487
+
488
+ // 2) soft per-project limit → freeze (preserves the crash scene so you can inspect logs before deciding)
489
+ for (const r of running) {
490
+ if (!r.paused && r.rss > r.limit && r.rss <= lim.hardRssMB) {
491
+ signalGroup(r.pgid, 'SIGSTOP');
492
+ alert_('danger', `"${r.name}" reached ${fmtMB(r.rss)}, over its limit of ${fmtMB(r.limit)} — auto-frozen. The process is still there; check the logs, then "Resume" or "Stop".`, r.id, 'soft:' + r.id);
493
+ }
494
+ }
495
+
496
+ // 3) system-wide pressure → freeze whoever's using the most (only touches processes PortDash
497
+ // itself started; anything else just gets a warning)
498
+ const sm = sysMem();
499
+ if (!sm) return;
500
+ const low = sm.availPct < lim.sysAvailFloorPct;
501
+ const swapping = sm.swapUsedMB > lim.sysSwapCeilMB;
502
+ if (!low && !swapping) return;
503
+
504
+ const victim = running.filter((r) => !r.paused).sort((a, b) => b.rss - a.rss)[0];
505
+ const why = low ? `only ${sm.availPct}% memory available` : `swap usage at ${fmtMB(sm.swapUsedMB)}`;
506
+ if (victim) {
507
+ signalGroup(victim.pgid, 'SIGSTOP');
508
+ alert_('danger', `${why} — froze "${victim.name}" (${fmtMB(victim.rss)}), the biggest consumer, to protect the system.`, victim.id, 'sys:' + victim.id);
509
+ } else {
510
+ alert_('warn', `${why}, but the top consumer wasn't started by PortDash — you'll need to handle it yourself.`, null, 'sys:none');
511
+ }
512
+ }
513
+
514
+ // ---------------------------------------------------------------- HTTP
515
+
516
+ function json(res, code, data) {
517
+ const b = Buffer.from(JSON.stringify(data));
518
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': b.length });
519
+ res.end(b);
520
+ }
521
+
522
+ const readBody = (req) => new Promise((resolve) => {
523
+ let s = '';
524
+ req.on('data', (c) => { s += c; });
525
+ req.on('end', () => { try { resolve(JSON.parse(s || '{}')); } catch (e) { resolve({}); } });
526
+ });
527
+
528
+ // Defends against a browser tab on any other page reaching this server:
529
+ // - Host check blocks DNS-rebinding (attacker domain resolved to 127.0.0.1)
530
+ // - Origin check blocks plain cross-site form/fetch requests
531
+ // - the custom header can only be set by same-origin fetch, since a cross-origin
532
+ // request carrying it would need a CORS preflight this server never approves
533
+ const LOCAL_HOST_RE = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
534
+ function isTrustedRequest(req) {
535
+ if (!LOCAL_HOST_RE.test(req.headers.host || '')) return false;
536
+ const origin = req.headers.origin;
537
+ if (origin && !LOCAL_HOST_RE.test(origin.replace(/^https?:\/\//, ''))) return false;
538
+ if (req.headers['x-portdash'] !== '1') return false;
539
+ return true;
540
+ }
541
+
542
+ const server = http.createServer(async (req, res) => {
543
+ const u = new URL(req.url, 'http://localhost');
544
+ if (!LOCAL_HOST_RE.test(req.headers.host || '')) { res.writeHead(400); return res.end('bad host'); }
545
+ try {
546
+ if (u.pathname === '/') {
547
+ const b = Buffer.from(HTML);
548
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': b.length });
549
+ return res.end(b);
550
+ }
551
+ if (u.pathname === '/favicon.ico') { res.writeHead(204); return res.end(); }
552
+ if (u.pathname === '/api/state') return json(res, 200, buildState());
553
+
554
+ if (u.pathname === '/api/logs') {
555
+ const f = path.join(D_LOGS, path.basename(String(u.searchParams.get('id'))) + '.log');
556
+ let text = '(No logs yet. Logs are only recorded for services started through PortDash.)';
557
+ if (fs.existsSync(f)) {
558
+ const size = fs.statSync(f).size, cap = 200 * 1024;
559
+ const len = Math.min(size, cap);
560
+ const fd = fs.openSync(f, 'r');
561
+ const buf = Buffer.alloc(len);
562
+ fs.readSync(fd, buf, 0, len, Math.max(0, size - cap));
563
+ fs.closeSync(fd);
564
+ text = buf.toString('utf8');
565
+ }
566
+ const b = Buffer.from(text);
567
+ res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': b.length });
568
+ return res.end(b);
569
+ }
570
+
571
+ if (req.method === 'POST') {
572
+ if (!isTrustedRequest(req)) { res.writeHead(403); return res.end('forbidden'); }
573
+ const body = await readBody(req);
574
+ if (u.pathname === '/api/scan') return json(res, 200, scanProjects());
575
+ if (u.pathname === '/api/start') return json(res, 200, startProject(body.id));
576
+ if (u.pathname === '/api/stop') return json(res, 200, stopTarget(body));
577
+ if (u.pathname === '/api/restart') return json(res, 200, await restartProject(body.id));
578
+ if (u.pathname === '/api/pause') return json(res, 200, { ok: signalGroup(resolveTarget(body).pgid, 'SIGSTOP') });
579
+ if (u.pathname === '/api/resume') return json(res, 200, { ok: signalGroup(resolveTarget(body).pgid, 'SIGCONT') });
580
+ if (u.pathname === '/api/dismiss') { alerts = alerts.filter((a) => a.id !== body.alertId); return json(res, 200, { ok: true }); }
581
+
582
+ if (u.pathname === '/api/save') {
583
+ const reg = getReg();
584
+ const p = reg.find((x) => x.id === body.id);
585
+ if (!p) throw new Error('Project not found');
586
+ if (typeof body.name === 'string' && body.name.trim()) p.name = body.name.trim();
587
+ if (typeof body.cmd === 'string') p.cmd = body.cmd.trim();
588
+ p.port = body.port ? parseInt(body.port, 10) : null;
589
+ p.memMB = body.memMB ? parseInt(body.memMB, 10) : null;
590
+ p.heapMB = body.heapMB ? parseInt(body.heapMB, 10) : null;
591
+ setReg(reg);
592
+ return json(res, 200, { ok: true });
593
+ }
594
+ if (u.pathname === '/api/register') {
595
+ if (!body.cwd) throw new Error("Couldn't determine this process's working directory, can't register it");
596
+ const reg = getReg();
597
+ if (reg.some((x) => x.cwd === body.cwd)) throw new Error('This directory is already registered');
598
+ const d = detectProject(body.cwd) || { name: path.basename(body.cwd), cmd: '', kind: 'unknown' };
599
+ reg.push({ id: idOf(body.cwd), name: d.name, cwd: body.cwd, cmd: d.cmd, kind: d.kind,
600
+ port: body.port || null, memMB: null, heapMB: null });
601
+ setReg(reg);
602
+ return json(res, 200, { ok: true });
603
+ }
604
+ if (u.pathname === '/api/remove') {
605
+ setReg(getReg().filter((x) => x.id !== body.id));
606
+ delete managed[body.id]; saveManaged();
607
+ return json(res, 200, { ok: true });
608
+ }
609
+ }
610
+ res.writeHead(404); res.end('not found');
611
+ } catch (e) {
612
+ json(res, 400, { error: e.message || String(e) });
613
+ }
614
+ });
615
+
616
+ // ---------------------------------------------------------------- frontend
617
+
618
+ const HTML = `<!doctype html>
619
+ <html lang="en"><head><meta charset="utf-8">
620
+ <meta name="viewport" content="width=device-width,initial-scale=1">
621
+ <title>PortDash</title>
622
+ <style>
623
+ :root{--bg:#f6f7f9;--card:#fff;--line:#e4e7ec;--tx:#1a1d21;--dim:#6b7280;
624
+ --run:#10b981;--pause:#f59e0b;--stop:#9ca3af;--accent:#2563eb;--danger:#dc2626}
625
+ @media (prefers-color-scheme:dark){:root{--bg:#15171a;--card:#1d2024;--line:#2c3036;--tx:#e8eaed;--dim:#9aa1ab}}
626
+ *{box-sizing:border-box}
627
+ body{margin:0;background:var(--bg);color:var(--tx);
628
+ font:14px/1.5 -apple-system,BlinkMacSystemFont,"Helvetica Neue",sans-serif}
629
+ .wrap{max-width:1060px;margin:0 auto;padding:26px 20px 60px}
630
+ header{display:flex;align-items:baseline;gap:13px;margin-bottom:18px;flex-wrap:wrap}
631
+ h1{font-size:20px;margin:0;letter-spacing:-.2px}
632
+ .sub{color:var(--dim);font-size:13px}
633
+ .spacer{flex:1}
634
+ button{font:inherit;cursor:pointer;border:1px solid var(--line);background:var(--card);
635
+ color:var(--tx);border-radius:7px;padding:5px 11px;transition:.12s}
636
+ button:hover{border-color:var(--accent);color:var(--accent)}
637
+ button.p{background:var(--accent);border-color:var(--accent);color:#fff}
638
+ button.p:hover{opacity:.88;color:#fff}
639
+ button.d:hover{border-color:var(--danger);color:var(--danger)}
640
+ h2{font-size:13px;color:var(--dim);font-weight:600;margin:26px 0 10px;letter-spacing:.3px}
641
+ .row{background:var(--card);border:1px solid var(--line);border-radius:10px;
642
+ padding:13px 15px;margin-bottom:8px;display:flex;align-items:center;gap:13px}
643
+ .row.hot{border-color:color-mix(in srgb,var(--pause) 55%,var(--line))}
644
+ .dot{width:8px;height:8px;border-radius:50%;flex:none}
645
+ .dot.running{background:var(--run);box-shadow:0 0 0 3px color-mix(in srgb,var(--run) 22%,transparent)}
646
+ .dot.paused{background:var(--pause);box-shadow:0 0 0 3px color-mix(in srgb,var(--pause) 22%,transparent)}
647
+ .dot.stopped{background:var(--stop)}
648
+ .main{flex:1;min-width:0}
649
+ .nm{font-weight:600;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
650
+ .meta{color:var(--dim);font-size:12px;margin-top:3px;overflow:hidden;text-overflow:ellipsis;
651
+ white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
652
+ .tag{font-size:11px;padding:1px 7px;border-radius:20px;border:1px solid var(--line);color:var(--dim);font-weight:400}
653
+ .tag.port{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 35%,var(--line));font-family:ui-monospace,Menlo,monospace}
654
+ .tag.mem{font-family:ui-monospace,Menlo,monospace}
655
+ .tag.mem.warn{color:var(--pause);border-color:color-mix(in srgb,var(--pause) 45%,var(--line))}
656
+ .tag.mem.bad{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 45%,var(--line))}
657
+ .acts{display:flex;gap:6px;flex:none;flex-wrap:wrap;justify-content:flex-end}
658
+ .empty{color:var(--dim);padding:22px;text-align:center;border:1px dashed var(--line);border-radius:10px}
659
+ .alert{border-radius:10px;padding:11px 14px;margin-bottom:8px;display:flex;gap:10px;
660
+ align-items:flex-start;border:1px solid;font-size:13px}
661
+ .alert.danger{background:color-mix(in srgb,var(--danger) 10%,var(--card));border-color:color-mix(in srgb,var(--danger) 40%,var(--line))}
662
+ .alert.warn{background:color-mix(in srgb,var(--pause) 10%,var(--card));border-color:color-mix(in srgb,var(--pause) 40%,var(--line))}
663
+ .alert .x{margin-left:auto;cursor:pointer;color:var(--dim);border:0;background:none;padding:0 4px}
664
+ .bar{height:4px;border-radius:3px;background:var(--line);overflow:hidden;width:44px;display:inline-block;vertical-align:middle}
665
+ .bar i{display:block;height:100%;background:var(--run)}
666
+ .bar i.warn{background:var(--pause)} .bar i.bad{background:var(--danger)}
667
+ dialog{border:1px solid var(--line);border-radius:12px;background:var(--card);color:var(--tx);
668
+ padding:20px;max-width:min(760px,92vw);width:100%}
669
+ dialog::backdrop{background:rgba(0,0,0,.45)}
670
+ label{display:block;font-size:12px;color:var(--dim);margin:12px 0 4px}
671
+ input{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--tx);font:inherit}
672
+ .two{display:flex;gap:12px}.two>div{flex:1}
673
+ pre{background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:12px;max-height:56vh;
674
+ overflow:auto;font:12px/1.6 ui-monospace,Menlo,monospace;white-space:pre-wrap;word-break:break-all}
675
+ .err{background:var(--danger);color:#fff;padding:9px 14px;border-radius:8px;position:fixed;bottom:20px;
676
+ left:50%;transform:translateX(-50%);z-index:9;box-shadow:0 4px 16px rgba(0,0,0,.2);max-width:80vw}
677
+ </style></head><body>
678
+ <div class="wrap">
679
+ <header>
680
+ <h1>PortDash</h1>
681
+ <span class="sub" id="stat">Loading…</span>
682
+ <span class="spacer"></span>
683
+ <span class="sub" id="sys"></span>
684
+ <button onclick="scan()">Rescan</button>
685
+ </header>
686
+ <div id="alerts"></div>
687
+ <h2>My projects</h2>
688
+ <div id="projects"></div>
689
+ <h2>Other processes on listening ports</h2>
690
+ <div id="others"></div>
691
+ </div>
692
+
693
+ <dialog id="edit">
694
+ <div style="font-weight:600;margin-bottom:4px">Edit project</div>
695
+ <div class="sub" id="e_cwd" style="font-size:12px"></div>
696
+ <label>Name</label><input id="e_name">
697
+ <label>Start command (runs in the project directory)</label><input id="e_cmd" placeholder="npm run dev">
698
+ <div class="two">
699
+ <div><label>Default port</label><input id="e_port" placeholder="optional"></div>
700
+ <div><label>Memory limit MB (auto-freeze past this)</label><input id="e_mem" placeholder="leave blank for default"></div>
701
+ <div><label>Node heap limit MB</label><input id="e_heap" placeholder="leave blank for default"></div>
702
+ </div>
703
+ <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:18px">
704
+ <button onclick="edit.close()">Cancel</button>
705
+ <button class="p" onclick="saveEdit()">Save</button>
706
+ </div>
707
+ </dialog>
708
+
709
+ <dialog id="logs">
710
+ <div style="display:flex;align-items:center;margin-bottom:10px">
711
+ <div style="font-weight:600" id="l_title">Logs</div><span class="spacer"></span>
712
+ <button onclick="logs.close()">Close</button>
713
+ </div>
714
+ <pre id="l_body"></pre>
715
+ </dialog>
716
+
717
+ <script>
718
+ const STATE={byId:{}};
719
+ let editingId=null, logId=null;
720
+
721
+ function toast(m){
722
+ document.querySelectorAll('.err').forEach(e=>e.remove());
723
+ const d=document.createElement('div'); d.className='err'; d.textContent=m;
724
+ document.body.appendChild(d); setTimeout(()=>d.remove(),5000);
725
+ }
726
+ async function api(p,b){
727
+ const r=await fetch(p,{method:'POST',headers:{'Content-Type':'application/json','X-Portdash':'1'},body:JSON.stringify(b||{})});
728
+ const j=await r.json().catch(()=>({}));
729
+ if(!r.ok){ toast(j.error||'Action failed'); throw new Error(j.error); }
730
+ return j;
731
+ }
732
+ const esc=s=>String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
733
+ const gb=mb=>mb>=1024?(mb/1024).toFixed(1)+'G':mb+'M';
734
+
735
+ async function act(p,b){ await api(p,b); setTimeout(load,350); }
736
+ async function scan(){ const r=await api('/api/scan'); toast('Scan complete, '+r.added+' new project(s)'); load(); }
737
+ function open_(port){ window.open('http://localhost:'+port,'_blank'); }
738
+
739
+ function openEdit(p){
740
+ editingId=p.id; e_cwd.textContent=p.cwdShort; e_name.value=p.name;
741
+ e_cmd.value=p.cmd||''; e_port.value=p.port||''; e_mem.value=p.memMB||''; e_heap.value=p.heapMB||'';
742
+ edit.showModal();
743
+ }
744
+ async function saveEdit(){
745
+ await api('/api/save',{id:editingId,name:e_name.value,cmd:e_cmd.value,port:e_port.value,
746
+ memMB:e_mem.value,heapMB:e_heap.value});
747
+ edit.close(); load();
748
+ }
749
+ async function showLogs(p){
750
+ logId=p.id; l_title.textContent='Logs · '+p.name; l_body.textContent='Loading…'; logs.showModal();
751
+ l_body.textContent=await (await fetch('/api/logs?id='+encodeURIComponent(p.id))).text();
752
+ l_body.scrollTop=l_body.scrollHeight;
753
+ }
754
+ async function remove(p){
755
+ if(!confirm('Remove "'+p.name+'" from the registry? (this won\\'t touch your project files)'))return;
756
+ await act('/api/remove',{id:p.id});
757
+ }
758
+
759
+ function btn(a,id,label,cls){
760
+ return '<button class="'+(cls||'')+'" data-act="'+a+'" data-id="'+id+'">'+label+'</button>';
761
+ }
762
+ function memTag(rss,limit){
763
+ if(!rss) return '';
764
+ const pct=limit?rss/limit:0;
765
+ const cls=pct>=.85?'bad':pct>=.6?'warn':'';
766
+ const w=Math.min(100,Math.round(pct*100));
767
+ return '<span class="tag mem '+cls+'">'+gb(rss)
768
+ +' <span class="bar"><i class="'+cls+'" style="width:'+w+'%"></i></span></span>';
769
+ }
770
+
771
+ function projectRow(p){
772
+ const label={running:'running',paused:'frozen',stopped:'stopped'}[p.status];
773
+ const ports=p.ports.map(x=>'<span class="tag port">:'+x+'</span>').join('');
774
+ let acts='';
775
+ if(p.status==='stopped') acts=btn('start',p.id,'Start','p');
776
+ else if(p.status==='running')
777
+ acts=(p.openPort?'<button data-act="open" data-port="'+p.openPort+'">Open</button>':'')
778
+ +btn('pause',p.id,'Pause')+btn('restart',p.id,'Restart')+btn('stop',p.id,'Stop','d');
779
+ else acts=btn('resume',p.id,'Resume','p')+btn('stop',p.id,'Stop','d');
780
+ acts+=btn('logs',p.id,'Logs')+btn('edit',p.id,'Edit')+btn('remove',p.id,'×','d');
781
+ const badge=p.source==='external'?'<span class="tag">external</span>':'';
782
+ const cmdTxt=p.cmd?esc(p.cmd):'<span style="color:var(--pause)">no start command configured</span>';
783
+ const hot=(p.rssMB&&p.memLimit&&p.rssMB/p.memLimit>=.6)?' hot':'';
784
+ return '<div class="row'+hot+'"><span class="dot '+p.status+'"></span><div class="main">'
785
+ +'<div class="nm">'+esc(p.name)+ports+memTag(p.rssMB,p.memLimit)+badge+'</div>'
786
+ +'<div class="meta">'+esc(p.cwdShort)+' · '+cmdTxt
787
+ +(p.etime?' · '+label+' '+esc(p.etime):'')+'</div></div>'
788
+ +'<div class="acts">'+acts+'</div></div>';
789
+ }
790
+
791
+ function otherRow(o){
792
+ let acts='<button data-act="open" data-port="'+o.port+'">Open</button>'
793
+ +'<button data-act="'+(o.paused?'resume':'pause')+'" data-pid="'+o.pid+'">'+(o.paused?'Resume':'Pause')+'</button>'
794
+ +'<button class="d" data-act="stop" data-pid="'+o.pid+'">Stop</button>';
795
+ if(o.cwd) acts+='<button data-act="register" data-cwd="'+esc(o.cwd)+'" data-port="'+o.port+'">Register</button>';
796
+ return '<div class="row"><span class="dot '+(o.paused?'paused':'running')+'"></span><div class="main">'
797
+ +'<div class="nm">'+esc(o.command)+'<span class="tag port">:'+o.port+'</span>'
798
+ +'<span class="tag">pid '+o.pid+'</span>'+(o.rssMB?'<span class="tag mem">'+gb(o.rssMB)+'</span>':'')+'</div>'
799
+ +'<div class="meta">'+esc(o.cwdShort||o.cmdline||'')+'</div></div>'
800
+ +'<div class="acts">'+acts+'</div></div>';
801
+ }
802
+
803
+ document.addEventListener('click', async (e)=>{
804
+ const b=e.target.closest('button[data-act]'); if(!b) return;
805
+ const a=b.dataset.act, id=b.dataset.id||null, pid=b.dataset.pid?+b.dataset.pid:null;
806
+ if(a==='open') return open_(b.dataset.port);
807
+ if(a==='edit') return openEdit(STATE.byId[id]);
808
+ if(a==='logs') return showLogs(STATE.byId[id]);
809
+ if(a==='remove') return remove(STATE.byId[id]);
810
+ if(a==='dismiss') return act('/api/dismiss',{alertId:b.dataset.alert});
811
+ if(a==='register') return act('/api/register',{cwd:b.dataset.cwd,port:+b.dataset.port});
812
+ const M={start:'/api/start',stop:'/api/stop',pause:'/api/pause',resume:'/api/resume',restart:'/api/restart'};
813
+ if(M[a]) return act(M[a], id?{id:id}:{pid:pid});
814
+ });
815
+
816
+ async function load(){
817
+ let s; try{ s=await (await fetch('/api/state')).json(); }catch(e){ return; }
818
+ STATE.byId={}; s.projects.forEach(p=>STATE.byId[p.id]=p);
819
+
820
+ const run=s.projects.filter(p=>p.status==='running').length;
821
+ const pau=s.projects.filter(p=>p.status==='paused').length;
822
+ stat.textContent=s.projects.length+' project(s) · '+run+' running'
823
+ +(pau?' · '+pau+' frozen':'')+' · '+s.others.length+' other port(s) in use';
824
+
825
+ if(s.sys){
826
+ const c=s.sys.availPct<20?'var(--danger)':s.sys.availPct<35?'var(--pause)':'var(--dim)';
827
+ sys.innerHTML='<span style="color:'+c+'">'+s.sys.availPct+'% memory available</span>'
828
+ +(s.sys.swapUsedMB>512?' · swap '+gb(s.sys.swapUsedMB):'');
829
+ }
830
+
831
+ alerts.innerHTML=(s.alerts||[]).map(a=>'<div class="alert '+a.level+'"><div>'+esc(a.text)+'</div>'
832
+ +'<button class="x" data-act="dismiss" data-alert="'+a.id+'">×</button></div>').join('');
833
+
834
+ const ord={running:0,paused:1,stopped:2};
835
+ projects.innerHTML = s.projects.length
836
+ ? s.projects.slice().sort((a,b)=>ord[a.status]-ord[b.status]||a.name.localeCompare(b.name))
837
+ .map(projectRow).join('')
838
+ : '<div class="empty">No projects registered yet. Click "Rescan" above, or edit scanRoots in ~/.portdash/config.json.</div>';
839
+ others.innerHTML = s.others.length ? s.others.map(otherRow).join('')
840
+ : '<div class="empty">No other processes are listening on a port.</div>';
841
+ if(logs.open&&logId) l_body.textContent=await (await fetch('/api/logs?id='+encodeURIComponent(logId))).text();
842
+ }
843
+ load(); setInterval(load,2500);
844
+ </script></body></html>`;
845
+
846
+ // ---------------------------------------------------------------- boot
847
+
848
+ ensure();
849
+ if (!fs.existsSync(F_CFG)) writeJSON(F_CFG, DEFAULT_CFG);
850
+ if (!fs.existsSync(F_REG)) console.log(`First run: found ${scanProjects().added} project(s)`);
851
+
852
+ const cfg0 = getCfg();
853
+ setInterval(watchdog, 2000); // every 2s — any slower and a runaway process could eat several more GB in between checks
854
+
855
+ server.listen(cfg0.uiPort, '127.0.0.1', () => {
856
+ const sm = sysMem();
857
+ console.log(`\n PortDash → http://localhost:${cfg0.uiPort}\n`);
858
+ console.log(` Memory protection: ${cfg0.limits.enabled ? 'on' : 'off'}` +
859
+ (cfg0.limits.enabled
860
+ ? ` (freeze at ${cfg0.limits.projectRssMB}M / kill at ${cfg0.limits.hardRssMB}M per project, node heap ${cfg0.limits.nodeHeapMB}M)`
861
+ : ''));
862
+ if (sm) console.log(` System: ${sm.availPct}% available of ${(sm.totalMB / 1024).toFixed(0)}G, swap used ${sm.swapUsedMB}M`);
863
+ console.log(` Config: ${F_CFG}`);
864
+ console.log(` Ctrl+C to quit (won't affect services already started)\n`);
865
+ });
866
+
867
+ server.on('error', (e) => {
868
+ if (e.code === 'EADDRINUSE') {
869
+ console.error(`Port ${cfg0.uiPort} is already in use. Change uiPort in ${F_CFG}.`);
870
+ process.exit(1);
871
+ }
872
+ throw e;
873
+ });