portmap-dev 1.1.0__py3-none-any.whl

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.
@@ -0,0 +1,806 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Portmap</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
9
+ <style>
10
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
11
+
12
+ :root {
13
+ --bg: #080c10;
14
+ --surface: #0e1419;
15
+ --surface2: #141b22;
16
+ --border: #1e2a35;
17
+ --border2: #253040;
18
+ --text: #d4dde6;
19
+ --muted: #4d6070;
20
+ --accent: #00d4ff;
21
+ --accent2: #0099bb;
22
+ --green: #00e676;
23
+ --green-dim: #00c85310;
24
+ --red: #ff4d4d;
25
+ --red-dim: #ff4d4d12;
26
+ --yellow: #ffc400;
27
+ --font-mono: 'IBM Plex Mono', monospace;
28
+ --font-ui: 'Inter', sans-serif;
29
+ }
30
+
31
+ body {
32
+ background: var(--bg);
33
+ color: var(--text);
34
+ font-family: var(--font-ui);
35
+ min-height: 100vh;
36
+ padding: 0;
37
+ }
38
+
39
+ /* Subtle grid background */
40
+ body::before {
41
+ content: '';
42
+ position: fixed;
43
+ inset: 0;
44
+ background-image:
45
+ linear-gradient(var(--border) 1px, transparent 1px),
46
+ linear-gradient(90deg, var(--border) 1px, transparent 1px);
47
+ background-size: 40px 40px;
48
+ opacity: 0.3;
49
+ pointer-events: none;
50
+ z-index: 0;
51
+ }
52
+
53
+ .layout {
54
+ position: relative;
55
+ z-index: 1;
56
+ max-width: 960px;
57
+ margin: 0 auto;
58
+ padding: 32px 24px 64px;
59
+ }
60
+
61
+ /* ── Header ── */
62
+ header {
63
+ display: flex;
64
+ justify-content: space-between;
65
+ align-items: flex-start;
66
+ margin-bottom: 40px;
67
+ padding-bottom: 28px;
68
+ border-bottom: 1px solid var(--border);
69
+ }
70
+
71
+ .logo-group { display: flex; flex-direction: column; gap: 6px; }
72
+
73
+ .logo {
74
+ display: flex;
75
+ align-items: center;
76
+ gap: 10px;
77
+ }
78
+
79
+ .logo-icon {
80
+ width: 28px; height: 28px;
81
+ border: 1.5px solid var(--accent);
82
+ border-radius: 6px;
83
+ display: grid;
84
+ place-items: center;
85
+ }
86
+
87
+ .logo-icon svg { width: 14px; height: 14px; }
88
+
89
+ h1 {
90
+ font-family: var(--font-mono);
91
+ font-size: 18px;
92
+ font-weight: 600;
93
+ letter-spacing: 0.02em;
94
+ color: #fff;
95
+ }
96
+
97
+ .tagline {
98
+ font-size: 12px;
99
+ color: var(--muted);
100
+ letter-spacing: 0.01em;
101
+ }
102
+
103
+ .header-right {
104
+ display: flex;
105
+ flex-direction: column;
106
+ align-items: flex-end;
107
+ gap: 8px;
108
+ }
109
+
110
+ .live-badge {
111
+ display: flex;
112
+ align-items: center;
113
+ gap: 7px;
114
+ padding: 5px 12px;
115
+ background: var(--green-dim);
116
+ border: 1px solid #00e67620;
117
+ border-radius: 20px;
118
+ font-size: 11px;
119
+ font-family: var(--font-mono);
120
+ color: var(--green);
121
+ letter-spacing: 0.04em;
122
+ }
123
+
124
+ .dot {
125
+ width: 6px; height: 6px;
126
+ border-radius: 50%;
127
+ background: var(--green);
128
+ position: relative;
129
+ }
130
+
131
+ .dot::after {
132
+ content: '';
133
+ position: absolute;
134
+ inset: -3px;
135
+ border-radius: 50%;
136
+ background: var(--green);
137
+ opacity: 0.3;
138
+ animation: pulse 2s ease-in-out infinite;
139
+ }
140
+
141
+ @keyframes pulse {
142
+ 0%, 100% { transform: scale(1); opacity: 0.3; }
143
+ 50% { transform: scale(1.8); opacity: 0; }
144
+ }
145
+
146
+ .stats-row {
147
+ display: flex;
148
+ gap: 16px;
149
+ align-items: center;
150
+ }
151
+
152
+ .stat {
153
+ text-align: right;
154
+ }
155
+
156
+ .stat-value {
157
+ font-family: var(--font-mono);
158
+ font-size: 20px;
159
+ font-weight: 600;
160
+ color: #fff;
161
+ line-height: 1;
162
+ }
163
+
164
+ .stat-label {
165
+ font-size: 10px;
166
+ color: var(--muted);
167
+ text-transform: uppercase;
168
+ letter-spacing: 0.08em;
169
+ margin-top: 3px;
170
+ }
171
+
172
+ .stat-divider {
173
+ width: 1px;
174
+ height: 28px;
175
+ background: var(--border2);
176
+ }
177
+
178
+ /* ── Filter Bar ── */
179
+ .filter-bar {
180
+ display: flex;
181
+ align-items: center;
182
+ gap: 8px;
183
+ margin-bottom: 20px;
184
+ }
185
+
186
+ .filter-btn {
187
+ padding: 5px 14px;
188
+ border-radius: 20px;
189
+ border: 1px solid var(--border2);
190
+ background: transparent;
191
+ color: var(--muted);
192
+ font-size: 12px;
193
+ font-family: var(--font-ui);
194
+ cursor: pointer;
195
+ transition: all 0.15s;
196
+ }
197
+
198
+ .filter-btn:hover { border-color: var(--border2); color: var(--text); }
199
+
200
+ .filter-btn.active {
201
+ background: var(--surface2);
202
+ border-color: var(--accent);
203
+ color: var(--accent);
204
+ }
205
+
206
+ .filter-spacer { flex: 1; }
207
+
208
+ .search-wrap {
209
+ position: relative;
210
+ display: flex;
211
+ align-items: center;
212
+ }
213
+
214
+ .search-wrap svg {
215
+ position: absolute;
216
+ left: 10px;
217
+ color: var(--muted);
218
+ width: 13px; height: 13px;
219
+ pointer-events: none;
220
+ }
221
+
222
+ #search {
223
+ background: var(--surface);
224
+ border: 1px solid var(--border2);
225
+ border-radius: 8px;
226
+ color: var(--text);
227
+ font-family: var(--font-mono);
228
+ font-size: 12px;
229
+ padding: 6px 12px 6px 30px;
230
+ width: 200px;
231
+ outline: none;
232
+ transition: border-color 0.15s;
233
+ }
234
+
235
+ #search:focus { border-color: var(--accent2); }
236
+ #search::placeholder { color: var(--muted); }
237
+
238
+ /* ── Grid ── */
239
+ #grid {
240
+ display: grid;
241
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
242
+ gap: 12px;
243
+ }
244
+
245
+ /* ── Service Card ── */
246
+ .card {
247
+ background: var(--surface);
248
+ border: 1px solid var(--border);
249
+ border-radius: 12px;
250
+ padding: 18px 20px;
251
+ display: flex;
252
+ flex-direction: column;
253
+ gap: 14px;
254
+ transition: border-color 0.2s, transform 0.2s;
255
+ animation: cardIn 0.25s ease forwards;
256
+ cursor: default;
257
+ }
258
+
259
+ @keyframes cardIn {
260
+ from { opacity: 0; transform: translateY(6px); }
261
+ to { opacity: 1; transform: translateY(0); }
262
+ }
263
+
264
+ .card:hover {
265
+ border-color: var(--border2);
266
+ transform: translateY(-1px);
267
+ }
268
+
269
+ .card.dying {
270
+ opacity: 0.4;
271
+ filter: grayscale(1);
272
+ pointer-events: none;
273
+ transition: opacity 0.4s, filter 0.4s;
274
+ }
275
+
276
+ /* Card top row */
277
+ .card-top {
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: space-between;
281
+ }
282
+
283
+ .service-id {
284
+ display: flex;
285
+ align-items: center;
286
+ gap: 10px;
287
+ }
288
+
289
+ .service-icon {
290
+ width: 34px; height: 34px;
291
+ border-radius: 8px;
292
+ display: grid;
293
+ place-items: center;
294
+ font-size: 16px;
295
+ flex-shrink: 0;
296
+ border: 1px solid transparent;
297
+ }
298
+
299
+ .service-name {
300
+ font-size: 14px;
301
+ font-weight: 600;
302
+ color: #fff;
303
+ line-height: 1.2;
304
+ }
305
+
306
+ .service-proc {
307
+ font-family: var(--font-mono);
308
+ font-size: 10px;
309
+ color: var(--muted);
310
+ margin-top: 1px;
311
+ }
312
+
313
+ /* Status badge */
314
+ .status-badge {
315
+ display: flex;
316
+ align-items: center;
317
+ gap: 5px;
318
+ padding: 3px 8px;
319
+ border-radius: 20px;
320
+ font-size: 10px;
321
+ font-family: var(--font-mono);
322
+ font-weight: 500;
323
+ }
324
+
325
+ .status-badge.up {
326
+ background: var(--green-dim);
327
+ border: 1px solid #00e67618;
328
+ color: var(--green);
329
+ }
330
+
331
+ .status-badge.protected {
332
+ background: #ffffff08;
333
+ border: 1px solid var(--border2);
334
+ color: var(--muted);
335
+ }
336
+
337
+ .status-dot {
338
+ width: 5px; height: 5px;
339
+ border-radius: 50%;
340
+ background: currentColor;
341
+ }
342
+
343
+ /* Card meta row */
344
+ .card-meta {
345
+ display: flex;
346
+ gap: 20px;
347
+ }
348
+
349
+ .meta-item { display: flex; flex-direction: column; gap: 2px; }
350
+
351
+ .meta-label {
352
+ font-size: 9px;
353
+ text-transform: uppercase;
354
+ letter-spacing: 0.08em;
355
+ color: var(--muted);
356
+ }
357
+
358
+ .meta-value {
359
+ font-family: var(--font-mono);
360
+ font-size: 13px;
361
+ font-weight: 500;
362
+ color: var(--accent);
363
+ }
364
+
365
+ .meta-value.muted { color: var(--muted); }
366
+
367
+ /* Card bottom row */
368
+ .card-bottom {
369
+ display: flex;
370
+ align-items: center;
371
+ justify-content: space-between;
372
+ padding-top: 10px;
373
+ border-top: 1px solid var(--border);
374
+ }
375
+
376
+ .open-link {
377
+ display: flex;
378
+ align-items: center;
379
+ gap: 5px;
380
+ font-family: var(--font-mono);
381
+ font-size: 11px;
382
+ color: var(--muted);
383
+ text-decoration: none;
384
+ transition: color 0.15s;
385
+ padding: 4px 0;
386
+ }
387
+
388
+ .open-link svg { width: 11px; height: 11px; }
389
+
390
+ .open-link:hover { color: var(--accent); }
391
+
392
+ .card-actions { display: flex; gap: 6px; }
393
+
394
+ .btn-kill {
395
+ padding: 5px 14px;
396
+ border-radius: 7px;
397
+ border: 1px solid #ff4d4d30;
398
+ background: var(--red-dim);
399
+ color: var(--red);
400
+ font-size: 11px;
401
+ font-family: var(--font-mono);
402
+ font-weight: 500;
403
+ cursor: pointer;
404
+ transition: all 0.15s;
405
+ }
406
+
407
+ .btn-kill:hover:not(:disabled) {
408
+ background: #ff4d4d22;
409
+ border-color: #ff4d4d60;
410
+ }
411
+
412
+ .btn-kill:disabled {
413
+ opacity: 0.5;
414
+ cursor: not-allowed;
415
+ }
416
+
417
+ /* Empty state */
418
+ #empty {
419
+ display: none;
420
+ grid-column: 1 / -1;
421
+ text-align: center;
422
+ padding: 64px 0;
423
+ color: var(--muted);
424
+ }
425
+
426
+ #empty svg { width: 40px; height: 40px; margin-bottom: 12px; opacity: 0.3; }
427
+ #empty p { font-size: 13px; }
428
+
429
+ /* Protected card style */
430
+ .card.protected-card {
431
+ border-style: dashed;
432
+ opacity: 0.6;
433
+ }
434
+
435
+ .card.protected-card:hover { opacity: 0.7; transform: none; }
436
+
437
+ /* Separator between user/system */
438
+ .section-label {
439
+ grid-column: 1 / -1;
440
+ font-size: 10px;
441
+ text-transform: uppercase;
442
+ letter-spacing: 0.1em;
443
+ color: var(--muted);
444
+ padding: 4px 0;
445
+ border-bottom: 1px solid var(--border);
446
+ margin-bottom: 4px;
447
+ }
448
+
449
+ /* Toast */
450
+ #toast {
451
+ position: fixed;
452
+ bottom: 24px;
453
+ right: 24px;
454
+ padding: 10px 18px;
455
+ background: var(--surface2);
456
+ border: 1px solid var(--border2);
457
+ border-radius: 10px;
458
+ font-size: 12px;
459
+ font-family: var(--font-mono);
460
+ color: var(--text);
461
+ z-index: 100;
462
+ transform: translateY(20px);
463
+ opacity: 0;
464
+ transition: all 0.2s;
465
+ pointer-events: none;
466
+ }
467
+
468
+ .status-badge.unknown {
469
+ background: #ffffff06;
470
+ border: 1px solid var(--border2);
471
+ color: var(--muted);
472
+ }
473
+
474
+ #toast.show { transform: translateY(0); opacity: 1; }
475
+ #toast.error { border-color: #ff4d4d50; color: var(--red); }
476
+ #toast.success { border-color: #00e67640; color: var(--green); }
477
+ </style>
478
+ </head>
479
+ <body>
480
+
481
+ <div class="layout">
482
+ <header>
483
+ <div class="logo-group">
484
+ <div class="logo">
485
+ <div class="logo-icon">
486
+ <svg viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
487
+ <circle cx="7" cy="7" r="2.5" stroke="#00d4ff" stroke-width="1.2"/>
488
+ <path d="M7 1v2M7 11v2M1 7h2M11 7h2" stroke="#00d4ff" stroke-width="1.2" stroke-linecap="round"/>
489
+ </svg>
490
+ </div>
491
+ <h1>portmap</h1>
492
+ </div>
493
+ <span class="tagline">Local service monitor Β· Real-time</span>
494
+ </div>
495
+
496
+ <div class="header-right">
497
+ <div class="live-badge">
498
+ <span class="dot"></span>
499
+ LIVE
500
+ </div>
501
+ <div class="stats-row">
502
+ <div class="stat">
503
+ <div class="stat-value" id="count-active">β€”</div>
504
+ <div class="stat-label">Active</div>
505
+ </div>
506
+ <div class="stat-divider"></div>
507
+ <div class="stat">
508
+ <div class="stat-value" id="count-protected">β€”</div>
509
+ <div class="stat-label">Protected</div>
510
+ </div>
511
+ <div class="stat-divider"></div>
512
+ <div class="stat">
513
+ <div class="stat-value" id="count-total">β€”</div>
514
+ <div class="stat-label">Total</div>
515
+ </div>
516
+ </div>
517
+ </div>
518
+ </header>
519
+
520
+ <div class="filter-bar">
521
+ <button class="filter-btn active" data-filter="all">All</button>
522
+ <button class="filter-btn" data-filter="user">User</button>
523
+ <button class="filter-btn" data-filter="system">System</button>
524
+ <div class="filter-spacer"></div>
525
+ <div class="search-wrap">
526
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
527
+ <circle cx="6.5" cy="6.5" r="4.5"/><path d="M11 11l3 3" stroke-linecap="round"/>
528
+ </svg>
529
+ <input id="search" type="text" placeholder="filter ports, names..." />
530
+ </div>
531
+ </div>
532
+
533
+ <div id="grid">
534
+ <div id="empty">
535
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
536
+ <path d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" stroke-linecap="round"/>
537
+ </svg>
538
+ <p>No services match your filter.</p>
539
+ </div>
540
+ </div>
541
+ </div>
542
+
543
+ <div id="toast"></div>
544
+
545
+ <script>
546
+ // Escape text for safe embedding in HTML attributes
547
+ function escapeAttr(s) {
548
+ return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
549
+ .replace(/</g, '&lt;').replace(/>/g, '&gt;');
550
+ }
551
+
552
+ // ── Service metadata (icons + colors) ──
553
+ const FRAMEWORK_META = {
554
+ 'Django': { emoji: '🟒', bg: '#0c4b3310', border: '#0c4b3340', accent: '#44b78b' },
555
+ 'Python': { emoji: '🐍', bg: '#3572a510', border: '#3572a530', accent: '#3572a5' },
556
+ 'FastAPI': { emoji: '⚑', bg: '#00b09610', border: '#00b09630', accent: '#00b096' },
557
+ 'Node.js': { emoji: 'πŸ“¦', bg: '#41692110', border: '#41692130', accent: '#8bc34a' },
558
+ 'Go': { emoji: '🐹', bg: '#00add810', border: '#00add830', accent: '#00add8' },
559
+ 'Rust': { emoji: 'πŸ¦€', bg: '#de3a1710', border: '#de3a1730', accent: '#de3a17' },
560
+ 'Ruby': { emoji: 'πŸ’Ž', bg: '#cc342d10', border: '#cc342d30', accent: '#cc342d' },
561
+ 'Java': { emoji: 'β˜•', bg: '#b0721310', border: '#b0721330', accent: '#b07213' },
562
+ 'Redis': { emoji: 'πŸ”΄', bg: '#dc143c10', border: '#dc143c30', accent: '#dc143c' },
563
+ 'PostgreSQL': { emoji: '🐘', bg: '#33669910', border: '#33669930', accent: '#336699' },
564
+ 'MySQL': { emoji: '🐬', bg: '#0076a310', border: '#0076a330', accent: '#0076a3' },
565
+ 'MongoDB': { emoji: 'πŸƒ', bg: '#00ed6410', border: '#00ed6430', accent: '#00ed64' },
566
+ 'Nginx': { emoji: '🌐', bg: '#26962510', border: '#26962530', accent: '#269625' },
567
+ 'Docker': { emoji: '🐳', bg: '#0db7ed10', border: '#0db7ed30', accent: '#0db7ed' },
568
+ 'PHP': { emoji: '🐘', bg: '#77719910', border: '#77719930', accent: '#777199' },
569
+ };
570
+
571
+ const UNKNOWN_META = { emoji: '●', bg: '#ffffff06', border: '#ffffff12', accent: '#4d6070' };
572
+
573
+ function getMeta(framework) {
574
+ if (!framework) return UNKNOWN_META;
575
+ return FRAMEWORK_META[framework] || UNKNOWN_META;
576
+ }
577
+
578
+ // ── State ──
579
+ let allConnections = [];
580
+ let activeFilter = 'all';
581
+ let searchQuery = '';
582
+
583
+ // ── WebSocket ──
584
+ let ws;
585
+ function connect() {
586
+ ws = new WebSocket(`ws://${window.location.host}/ws/scan`);
587
+ ws.onmessage = (e) => {
588
+ allConnections = JSON.parse(e.data);
589
+ render();
590
+ };
591
+ ws.onclose = () => setTimeout(connect, 2000);
592
+ }
593
+ connect();
594
+
595
+ // ── Filters ──
596
+ document.querySelectorAll('.filter-btn').forEach(btn => {
597
+ btn.addEventListener('click', () => {
598
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
599
+ btn.classList.add('active');
600
+ activeFilter = btn.dataset.filter;
601
+ render();
602
+ });
603
+ });
604
+
605
+ document.getElementById('search').addEventListener('input', (e) => {
606
+ searchQuery = e.target.value.toLowerCase();
607
+ render();
608
+ });
609
+
610
+ // ── Render ──
611
+ // ── Render ──
612
+ function render() {
613
+ const grid = document.getElementById('grid');
614
+
615
+ let filtered = allConnections.filter(conn => {
616
+ if (activeFilter === 'user' && conn.kind === 'system') return false;
617
+ if (activeFilter === 'system' && conn.kind !== 'system') return false;
618
+ if (searchQuery) {
619
+ const q = searchQuery;
620
+ return String(conn.port).includes(q) ||
621
+ conn.name.toLowerCase().includes(q);
622
+ }
623
+ return true;
624
+ });
625
+
626
+ // Stats
627
+ const projectCount = allConnections.filter(c => c.kind === 'project' || c.kind === 'infrastructure').length;
628
+ const sysCount = allConnections.filter(c => c.kind === 'system').length;
629
+
630
+ document.getElementById('count-active').textContent = projectCount;
631
+ document.getElementById('count-protected').textContent = sysCount;
632
+ document.getElementById('count-total').textContent = allConnections.length;
633
+
634
+ // Empty state
635
+ const empty = document.getElementById('empty');
636
+ empty.style.display = filtered.length === 0 ? 'block' : 'none';
637
+
638
+ // 2 Sections Layout: User Services (Projects + Unconfirmed) & System
639
+ const userConns = filtered.filter(c => c.kind !== 'system');
640
+ const sysConns = filtered.filter(c => c.kind === 'system');
641
+
642
+ let html = '';
643
+
644
+ if (userConns.length > 0) {
645
+ html += `<div class="section-label">User Services Β· ${userConns.length}</div>`;
646
+ userConns.forEach(conn => { html += buildUserCard(conn); });
647
+ }
648
+
649
+ if (sysConns.length > 0) {
650
+ html += `<div class="section-label">System Β· ${sysConns.length}</div>`;
651
+ sysConns.forEach(conn => { html += buildSystemCard(conn); });
652
+ }
653
+
654
+ const cards = grid.querySelectorAll('.card, .section-label');
655
+ cards.forEach(el => el.remove());
656
+ grid.insertAdjacentHTML('beforeend', html);
657
+ }
658
+
659
+ function buildUserCard(conn) {
660
+ const meta = getMeta(conn.framework);
661
+ const localUrl = `http://localhost:${conn.port}`;
662
+ const isProject = conn.kind === 'project';
663
+
664
+ const displayName = conn.name || conn.framework;
665
+
666
+ const subtitle = isProject
667
+ ? (conn.framework || `pid ${conn.pid}`)
668
+ : `pid ${conn.pid}`;
669
+
670
+ const cardBorder = conn.framework
671
+ ? `border-color: ${meta.border}`
672
+ : 'border-color: var(--border)';
673
+
674
+ return `
675
+ <div class="card" id="card-${conn.pid}"
676
+ data-pid="${conn.pid}"
677
+ data-port="${conn.port}"
678
+ style="${cardBorder}">
679
+
680
+ <div class="card-top">
681
+ <div class="service-id">
682
+ <div class="service-icon"
683
+ style="background:${meta.bg}; border-color:${meta.border}; font-size:15px">
684
+ ${meta.emoji}
685
+ </div>
686
+ <div>
687
+ <div class="service-name">${displayName}</div>
688
+ <div class="service-proc">${subtitle}</div>
689
+ </div>
690
+ </div>
691
+
692
+ <div class="status-badge ${conn.framework ? 'up' : 'unknown'}">
693
+ <span class="status-dot"></span>
694
+ ${conn.framework ? conn.kind.toUpperCase() : 'UNCONFIRMED'}
695
+ </div>
696
+ </div>
697
+
698
+ <div class="card-meta">
699
+ <div class="meta-item">
700
+ <span class="meta-label">Port</span>
701
+ <span class="meta-value">:${conn.port}</span>
702
+ </div>
703
+ ${conn.cwd ? `
704
+ <div class="meta-item">
705
+ <span class="meta-label">Project</span>
706
+ <span class="meta-value" style="color:var(--muted);font-size:11px;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
707
+ title="${conn.cwd}">${conn.cwd}</span>
708
+ </div>` : ''}
709
+ </div>
710
+
711
+ <div class="card-bottom">
712
+ <a class="open-link" href="${localUrl}" target="_blank" rel="noopener">
713
+ <svg viewBox="0 0 12 12" fill="none" stroke="currentColor"
714
+ stroke-width="1.5" stroke-linecap="round">
715
+ <path d="M2 10L10 2M5 2h5v5"/>
716
+ </svg>
717
+ localhost:${conn.port}
718
+ </a>
719
+ <div class="card-actions">
720
+ <button class="btn-kill"
721
+ id="kill-${conn.pid}"
722
+ data-name="${escapeAttr(displayName)}"
723
+ onclick="killProcess(${conn.pid}, ${conn.port})">
724
+ Kill
725
+ </button>
726
+ </div>
727
+ </div>
728
+ </div>`;
729
+ }
730
+
731
+ function buildSystemCard(conn) {
732
+ return `
733
+ <div class="card protected-card" id="card-sys-${conn.port}">
734
+ <div class="card-top">
735
+ <div class="service-id">
736
+ <div class="service-icon" style="background:#ffffff06;border-color:#ffffff10">πŸ”’</div>
737
+ <div>
738
+ <div class="service-name" style="color:#4d6070">System Process</div>
739
+ <div class="service-proc">pid protected</div>
740
+ </div>
741
+ </div>
742
+ <div class="status-badge protected">
743
+ <span class="status-dot"></span>
744
+ SYS
745
+ </div>
746
+ </div>
747
+ <div class="card-meta">
748
+ <div class="meta-item">
749
+ <span class="meta-label">Port</span>
750
+ <span class="meta-value muted">:${conn.port}</span>
751
+ </div>
752
+ </div>
753
+ <div class="card-bottom">
754
+ <span class="open-link" style="cursor:default">
755
+ System protected
756
+ </span>
757
+ </div>
758
+ </div>
759
+ `;
760
+ }
761
+
762
+ // ── Kill ──
763
+ async function killProcess(pid, port) {
764
+ const btn = document.getElementById(`kill-${pid}`);
765
+ const card = document.getElementById(`card-${pid}`);
766
+
767
+ // Require explicit confirmation before sending any kill request.
768
+ const name = (btn && btn.dataset.name) || 'this process';
769
+ if (!confirm(`Kill ${name} (PID ${pid})?`)) {
770
+ return; // Cancelled β€” no request is sent.
771
+ }
772
+
773
+ if (btn) { btn.textContent = 'Killing…'; btn.disabled = true; }
774
+ if (card) { card.classList.add('dying'); }
775
+
776
+ try {
777
+ const res = await fetch(`/api/kill/${pid}`, { method: 'POST' });
778
+ const data = await res.json();
779
+ if (res.ok) {
780
+ // Display method used for termination
781
+ const method = data.method === 'SIGKILL' ? ' (force killed)' : '';
782
+ showToast(`Port :${port} process terminated${method}`, 'success');
783
+ } else {
784
+ showToast(data.detail || 'Kill failed', 'error');
785
+ if (btn) { btn.textContent = 'Kill'; btn.disabled = false; }
786
+ if (card) { card.classList.remove('dying'); }
787
+ }
788
+ } catch {
789
+ showToast('Network error', 'error');
790
+ if (btn) { btn.textContent = 'Kill'; btn.disabled = false; }
791
+ if (card) { card.classList.remove('dying'); }
792
+ }
793
+ }
794
+
795
+ // ── Toast ──
796
+ let toastTimer;
797
+ function showToast(msg, type = '') {
798
+ const t = document.getElementById('toast');
799
+ t.textContent = msg;
800
+ t.className = `show ${type}`;
801
+ clearTimeout(toastTimer);
802
+ toastTimer = setTimeout(() => { t.className = ''; }, 3000);
803
+ }
804
+ </script>
805
+ </body>
806
+ </html>