netshow 0.1.1__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.
netshow/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """NetShow - A real-time network connection monitor with friendly service names."""
2
+
3
+ __version__ = "0.1.0"
netshow/app.py ADDED
@@ -0,0 +1,390 @@
1
+ import os
2
+ from datetime import datetime
3
+ from typing import TypedDict
4
+
5
+ import psutil
6
+ from textual import events
7
+ from textual.app import App, ComposeResult
8
+ from textual.containers import (
9
+ Container,
10
+ Horizontal,
11
+ ScrollableContainer,
12
+ Vertical,
13
+ )
14
+ from textual.reactive import reactive
15
+ from textual.screen import Screen
16
+ from textual.timer import Timer
17
+ from textual.widgets import (
18
+ Button,
19
+ DataTable,
20
+ Footer,
21
+ Header,
22
+ Static,
23
+ )
24
+
25
+ from .helpers import get_lsof_conns, get_psutil_conns
26
+ from .styles import CSS
27
+
28
+ # Constants
29
+ REFRESH_INTERVAL = 3.0 # seconds
30
+ CONNECTION_COLUMNS = ["pid", "friendly", "proc", "laddr", "raddr", "status"]
31
+
32
+
33
+ class ConnectionData(TypedDict):
34
+ """Type definition for connection data."""
35
+
36
+ pid: str
37
+ friendly: str
38
+ proc: str
39
+ laddr: str
40
+ raddr: str
41
+ status: str
42
+
43
+
44
+ class ConnectionDetailScreen(Screen):
45
+ """Screen for displaying detailed information about a selected connection."""
46
+
47
+ BINDINGS = [("escape", "app.pop_screen", "Back to connections")]
48
+
49
+ def __init__(self, connection_data: ConnectionData):
50
+ super().__init__()
51
+ self.connection_data = connection_data
52
+ self.process_info = self._get_process_info(connection_data["pid"])
53
+
54
+ def _get_status_icon(self, status: str) -> str:
55
+ """Get an appropriate icon for connection status."""
56
+ status_icons = {
57
+ "ESTABLISHED": "βœ…",
58
+ "LISTEN": "πŸ‘‚",
59
+ "TIME_WAIT": "⏳",
60
+ "CLOSE_WAIT": "⏸️",
61
+ "SYN_SENT": "πŸ“€",
62
+ "SYN_RECV": "πŸ“₯",
63
+ "FIN_WAIT1": "πŸ”„",
64
+ "FIN_WAIT2": "πŸ”",
65
+ "CLOSING": "πŸ”š",
66
+ "LAST_ACK": "🏁",
67
+ }
68
+ return status_icons.get(status, "❓")
69
+
70
+ def _get_process_info(self, pid_str: str) -> dict:
71
+ """Get additional process information if PID is available."""
72
+ if pid_str == "-":
73
+ return {}
74
+
75
+ try:
76
+ pid = int(pid_str)
77
+ proc = psutil.Process(pid)
78
+ return {
79
+ "name": proc.name(),
80
+ "exe": proc.exe(),
81
+ "cmd": " ".join(proc.cmdline()),
82
+ "create_time": proc.create_time(),
83
+ "status": proc.status(),
84
+ "username": proc.username(),
85
+ "cwd": proc.cwd(),
86
+ "num_threads": proc.num_threads(),
87
+ "cpu_percent": proc.cpu_percent(interval=0.1),
88
+ "memory_percent": proc.memory_percent(),
89
+ "open_files": proc.open_files(),
90
+ "connections": proc.connections(),
91
+ }
92
+ except (psutil.NoSuchProcess, psutil.AccessDenied, ValueError):
93
+ return {}
94
+
95
+ def compose(self) -> ComposeResult:
96
+ """Compose the detail screen layout."""
97
+ yield Header(show_clock=True)
98
+
99
+ with ScrollableContainer():
100
+ yield Static(
101
+ f"πŸ”— Connection Details: {self.connection_data['friendly']}",
102
+ id="detail_title",
103
+ )
104
+
105
+ with Horizontal(id="main_content"):
106
+ with Container(id="connection_details"):
107
+ yield Static("🌐 Connection Info", classes="detail_title")
108
+ yield Static(
109
+ f"πŸ†” PID: {self.connection_data['pid']}", classes="detail_item"
110
+ )
111
+ yield Static(
112
+ f"βš™οΈ Process: {self.connection_data['proc']}",
113
+ classes="detail_item",
114
+ )
115
+ yield Static(
116
+ f"🏷️ Friendly Name: {self.connection_data['friendly']}",
117
+ classes="detail_item",
118
+ )
119
+ yield Static(
120
+ f"🏠 Local Address: {self.connection_data['laddr']}",
121
+ classes="detail_item",
122
+ markup=False,
123
+ )
124
+ yield Static(
125
+ f"🌐 Remote Address: {self.connection_data['raddr']}",
126
+ classes="detail_item",
127
+ markup=False,
128
+ )
129
+
130
+ status = self.connection_data["status"]
131
+ status_icon = self._get_status_icon(status)
132
+ yield Static(
133
+ f"{status_icon} Status: {status}",
134
+ classes=f"detail_item status-{status}",
135
+ )
136
+
137
+ # Show additional process info if available
138
+ if self.process_info:
139
+ with Container(id="process_info"):
140
+ yield Static("πŸ”§ Process Details", classes="detail_title")
141
+ yield Static(
142
+ f"πŸ“ Executable: {self.process_info.get('exe', 'N/A')}",
143
+ classes="detail_item",
144
+ )
145
+ yield Static(
146
+ f"πŸ’» Command Line: {self.process_info.get('cmd', 'N/A')}",
147
+ classes="detail_item",
148
+ )
149
+ yield Static(
150
+ f"πŸ“Š Status: {self.process_info.get('status', 'N/A')}",
151
+ classes="detail_item",
152
+ )
153
+ yield Static(
154
+ f"πŸ‘€ User: {self.process_info.get('username', 'N/A')}",
155
+ classes="detail_item",
156
+ )
157
+ yield Static(
158
+ f"πŸ“‚ Working Directory: {self.process_info.get('cwd', 'N/A')}",
159
+ classes="detail_item",
160
+ )
161
+ yield Static(
162
+ f"🧡 Threads: {self.process_info.get('num_threads', 'N/A')}",
163
+ classes="detail_item",
164
+ )
165
+
166
+ cpu_percent = self.process_info.get("cpu_percent", 0.0)
167
+ cpu_icon = (
168
+ "πŸ”₯"
169
+ if cpu_percent > 50
170
+ else "⚑" if cpu_percent > 10 else "πŸ’€"
171
+ )
172
+ yield Static(
173
+ f"{cpu_icon} CPU Usage: {cpu_percent:.1f}%",
174
+ classes="detail_item",
175
+ )
176
+
177
+ memory_percent = self.process_info.get("memory_percent", 0.0)
178
+ memory_display = (
179
+ f"{memory_percent:.2f}%"
180
+ if isinstance(memory_percent, (int, float))
181
+ else "N/A"
182
+ )
183
+ memory_icon = (
184
+ "🚨"
185
+ if memory_percent > 80
186
+ else "⚠️" if memory_percent > 50 else "πŸ’Ύ"
187
+ )
188
+ yield Static(
189
+ f"{memory_icon} Memory Usage: {memory_display}",
190
+ classes="detail_item",
191
+ )
192
+
193
+ # Network connections from this process
194
+ connections = self.process_info.get("connections", [])
195
+ if connections:
196
+ conn_count = (
197
+ len(connections) if isinstance(connections, list) else 0
198
+ )
199
+ yield Static(
200
+ f"🌐 Active Connections: {conn_count}",
201
+ classes="detail_item",
202
+ )
203
+
204
+ with Container(id="button_container"):
205
+ yield Button("πŸ”™ Back to Connections", id="back_button")
206
+
207
+ def on_button_pressed(self, event: Button.Pressed) -> None:
208
+ """Handle button press events."""
209
+ if event.button.id == "back_button":
210
+ self.app.pop_screen()
211
+
212
+
213
+ class NetshowApp(App):
214
+ """A modern real‑time network connection monitor with enhanced visuals.
215
+
216
+ Features:
217
+ β€’ **Beautiful gradient UI** with glass morphism effects
218
+ β€’ **Animated status indicators** and visual feedback
219
+ β€’ **Enhanced typography** with semantic icons
220
+ β€’ **Preserves scroll position** when the table refreshes
221
+ β€’ **Process-aware monitoring** with detailed drill-down views
222
+ β€’ **Responsive design** that adapts to terminal size
223
+ """
224
+
225
+ CSS = CSS
226
+
227
+ total_connections = reactive(0)
228
+ active_connections = reactive(0)
229
+ listening_connections = reactive(0)
230
+
231
+ def compose(self) -> ComposeResult:
232
+ yield Header(show_clock=True)
233
+ with Vertical():
234
+ with Container(id="stats_container"):
235
+ yield Static("πŸ”„ Initializing NetShow…", id="status_bar")
236
+ yield DataTable(id="connections_table")
237
+ yield Footer()
238
+
239
+ def on_mount(self) -> None:
240
+ table = self.query_one("#connections_table", DataTable)
241
+ table.add_columns(
242
+ "πŸ†” PID",
243
+ "πŸ”– Service",
244
+ "βš™οΈ Process",
245
+ "🏠 Local Address",
246
+ "🌐 Remote Address",
247
+ "πŸ“Š Status",
248
+ )
249
+
250
+ # Enable cursor to allow row selection
251
+ table.cursor_type = "row"
252
+ table.can_focus = True
253
+
254
+ # Refresh at regular intervals
255
+ self.timer: Timer = self.set_interval(
256
+ REFRESH_INTERVAL, self.refresh_connections
257
+ )
258
+ self.refresh_connections()
259
+
260
+ def refresh_connections(self) -> None:
261
+ table = self.query_one("#connections_table", DataTable)
262
+
263
+ # Capture current scroll offset & cursor row
264
+ row_offset, col_offset = getattr(table, "scroll_offset", (0, 0))
265
+ cursor_row = getattr(table, "cursor_row", 0)
266
+
267
+ table.clear()
268
+
269
+ status_bar = self.query_one("#status_bar", Static)
270
+ using_root = os.geteuid() == 0
271
+
272
+ try:
273
+ conns = get_psutil_conns() if using_root else get_lsof_conns()
274
+ except (psutil.AccessDenied, PermissionError):
275
+ conns = get_lsof_conns()
276
+ using_root = False
277
+
278
+ # Count connection types for stats
279
+ established = listening = 0
280
+ for c in conns:
281
+ status = c["status"]
282
+ if status == "ESTABLISHED":
283
+ established += 1
284
+ elif status == "LISTEN":
285
+ listening += 1
286
+
287
+ # Add status icon to status column
288
+ status_icon = self._get_status_icon(status)
289
+ table.add_row(
290
+ c["pid"],
291
+ c["friendly"],
292
+ c["proc"],
293
+ c["laddr"],
294
+ c["raddr"],
295
+ f"{status_icon} {status}",
296
+ )
297
+
298
+ # Update reactive stats
299
+ self.total_connections = len(conns)
300
+ self.active_connections = established
301
+ self.listening_connections = listening
302
+
303
+ # Restore scroll & cursor
304
+ if hasattr(table, "scroll_to"):
305
+ table.scroll_to(row_offset, col_offset)
306
+ if cursor_row < table.row_count and hasattr(table, "cursor_coordinate"):
307
+ table.cursor_coordinate = (cursor_row, 0) # type: ignore
308
+
309
+ source = "πŸ”‘ psutil (root)" if using_root else "πŸ”§ lsof"
310
+ timestamp = datetime.now().strftime("%H:%M:%S")
311
+ status_bar.update(
312
+ f"πŸ“Š Total: {len(conns)} | βœ… Active: {established} | πŸ‘‚ Listening: {listening} | "
313
+ f"{source} | πŸ•’ {timestamp}"
314
+ )
315
+
316
+ def _get_status_icon(self, status: str) -> str:
317
+ """Get an appropriate icon for connection status."""
318
+ status_icons = {
319
+ "ESTABLISHED": "βœ…",
320
+ "LISTEN": "πŸ‘‚",
321
+ "TIME_WAIT": "⏳",
322
+ "CLOSE_WAIT": "⏸️",
323
+ "SYN_SENT": "πŸ“€",
324
+ "SYN_RECV": "πŸ“₯",
325
+ "FIN_WAIT1": "πŸ”„",
326
+ "FIN_WAIT2": "πŸ”",
327
+ "CLOSING": "πŸ”š",
328
+ "LAST_ACK": "🏁",
329
+ }
330
+ return status_icons.get(status, "❓")
331
+
332
+ def _get_selected_connection_data(self, row_data: tuple) -> ConnectionData:
333
+ """Convert row data tuple to ConnectionData dict."""
334
+ # Extract status without icon (remove first 2 characters: icon + space)
335
+ status_with_icon = row_data[5]
336
+ clean_status = (
337
+ status_with_icon[2:] if len(status_with_icon) > 2 else status_with_icon
338
+ )
339
+
340
+ return ConnectionData(
341
+ pid=row_data[0],
342
+ friendly=row_data[1],
343
+ proc=row_data[2],
344
+ laddr=row_data[3],
345
+ raddr=row_data[4],
346
+ status=clean_status,
347
+ )
348
+
349
+ async def on_key(self, event: events.Key) -> None:
350
+ if event.key == "q":
351
+ await self.action_quit()
352
+ elif event.key == "enter":
353
+ # When Enter is pressed on a highlighted row
354
+ table = self.query_one("#connections_table", DataTable)
355
+ if table.cursor_row is not None and table.cursor_row < table.row_count:
356
+ # Pause refreshing while viewing details
357
+ self.timer.pause()
358
+
359
+ # Get the row data at cursor position and use it directly
360
+ row_data = table.get_row_at(table.cursor_row)
361
+ selected_data = self._get_selected_connection_data(tuple(row_data))
362
+ await self.push_screen(ConnectionDetailScreen(selected_data))
363
+
364
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
365
+ """Handle row highlighting in the DataTable."""
366
+ # This event fires when cursor moves over rows
367
+ pass
368
+
369
+ async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
370
+ """Handle row selection in the DataTable."""
371
+ # Pause refreshing while viewing details
372
+ self.timer.pause()
373
+
374
+ # Get the selected row's data
375
+ table = self.query_one("#connections_table", DataTable)
376
+ row_data = table.get_row(event.row_key)
377
+ selected_data = self._get_selected_connection_data(tuple(row_data))
378
+
379
+ # Push the detail screen
380
+ await self.push_screen(ConnectionDetailScreen(selected_data))
381
+
382
+ async def on_screen_resume(self) -> None:
383
+ """Called when this screen is resumed (after popping another screen)."""
384
+ # Resume refreshing when returning from detail view
385
+ self.timer.resume()
386
+ self.refresh_connections()
387
+
388
+
389
+ if __name__ == "__main__":
390
+ NetshowApp().run()
netshow/cli.py ADDED
@@ -0,0 +1,17 @@
1
+ """CLI entry point for NetShow."""
2
+
3
+ import sys
4
+
5
+ from .app import NetshowApp
6
+
7
+
8
+ def main() -> None:
9
+ """Main CLI entry point."""
10
+ try:
11
+ NetshowApp().run()
12
+ except KeyboardInterrupt:
13
+ sys.exit(0)
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
netshow/helpers.py ADDED
@@ -0,0 +1,127 @@
1
+ """Helper functions for gathering network connections."""
2
+
3
+ import re
4
+ import subprocess
5
+ from functools import lru_cache
6
+ from typing import Optional
7
+
8
+ import psutil
9
+
10
+ # Friendly-name helpers
11
+ STATIC_MAP = {
12
+ "rapportd": "Handoff Sync Process",
13
+ "IPNExtension": "Tailscale",
14
+ "Code\x20H": "VSCode",
15
+ "Adobe\x20H": "Adobe",
16
+ }
17
+
18
+ DOCKER_RE = re.compile(r"com\.docker", re.I)
19
+ PLEX_RE = re.compile(r"plex", re.I)
20
+
21
+
22
+ @lru_cache(maxsize=1)
23
+ def _docker_container_lookup() -> dict[str, str]:
24
+ """Look up Docker container IDs and names."""
25
+ try:
26
+ out = subprocess.check_output(
27
+ ["docker", "ps", "--format", "{{.ID}} {{.Names}}"],
28
+ text=True,
29
+ stderr=subprocess.DEVNULL,
30
+ )
31
+ except Exception:
32
+ return {}
33
+ return dict(line.split(maxsplit=1) for line in out.splitlines())
34
+
35
+
36
+ def get_friendly_name(proc_name: str, pid: int, cmdline: Optional[str]) -> str:
37
+ """Get a friendly name for a process."""
38
+ if proc_name in STATIC_MAP:
39
+ return STATIC_MAP[proc_name]
40
+ if PLEX_RE.match(proc_name):
41
+ return "Plex Media Server"
42
+ if DOCKER_RE.match(proc_name):
43
+ cnt = len(_docker_container_lookup())
44
+ return f"Docker Desktop ({cnt} containers)" if cnt else "Docker Desktop"
45
+ if cmdline:
46
+ for cid, cname in _docker_container_lookup().items():
47
+ if cid in cmdline:
48
+ return f"Docker: {cname}"
49
+ return proc_name
50
+
51
+
52
+ # Connection gathering helpers
53
+ def get_psutil_conns() -> list[dict[str, str]]:
54
+ """Get network connections using psutil."""
55
+ conns = []
56
+ for conn in psutil.net_connections(kind="tcp"):
57
+ pid = conn.pid if conn.pid else None
58
+ try:
59
+ proc = psutil.Process(pid) if pid else None
60
+ proc_name = proc.name() if proc else "-"
61
+ cmdline = " ".join(proc.cmdline()) if proc else ""
62
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
63
+ proc_name, cmdline = "-", ""
64
+ conns.append(
65
+ {
66
+ "pid": str(pid) if pid else "-",
67
+ "proc": proc_name,
68
+ "friendly": get_friendly_name(proc_name, pid or 0, cmdline),
69
+ "laddr": (
70
+ f"[{conn.laddr.ip}]:{conn.laddr.port}"
71
+ if conn.laddr and ":" in conn.laddr.ip
72
+ else f"{conn.laddr.ip}:{conn.laddr.port}" if conn.laddr else ""
73
+ ),
74
+ "raddr": (
75
+ f"[{conn.raddr.ip}]:{conn.raddr.port}"
76
+ if conn.raddr and ":" in conn.raddr.ip
77
+ else f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else ""
78
+ ),
79
+ "status": conn.status,
80
+ }
81
+ )
82
+ return conns
83
+
84
+
85
+ def get_lsof_conns() -> list[dict[str, str]]:
86
+ """Get network connections using lsof."""
87
+ try:
88
+ output = subprocess.check_output(
89
+ ["lsof", "-nP", "-iTCP", "-sTCP:ESTABLISHED,LISTEN"],
90
+ text=True,
91
+ stderr=subprocess.DEVNULL,
92
+ )
93
+ except FileNotFoundError:
94
+ return []
95
+
96
+ conns = []
97
+ pattern = re.compile(
98
+ r"^(?P<proc>\S+)\s+(?P<pid>\d+)\s+\S+\s+\S+\s+\S+\s+\S+\s+\S+\s+TCP\s+(?P<addr>.+)$"
99
+ )
100
+ for line in output.splitlines()[1:]:
101
+ m = pattern.match(line)
102
+ if not m:
103
+ continue
104
+ proc_name = m["proc"]
105
+ pid = int(m["pid"])
106
+ addr_field = m["addr"]
107
+ status = ""
108
+ if "(" in addr_field:
109
+ addr_field, status = addr_field.rsplit("(", 1)
110
+ status = status.rstrip(")")
111
+ laddr, raddr = (addr_field.split("->", 1) + [""])[:2]
112
+ laddr, raddr = laddr.strip(), raddr.strip()
113
+ try:
114
+ cmdline = " ".join(psutil.Process(pid).cmdline())
115
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
116
+ cmdline = ""
117
+ conns.append(
118
+ {
119
+ "pid": str(pid),
120
+ "proc": proc_name,
121
+ "friendly": get_friendly_name(proc_name, pid, cmdline),
122
+ "laddr": laddr,
123
+ "raddr": raddr,
124
+ "status": status,
125
+ }
126
+ )
127
+ return conns
netshow/styles.py ADDED
@@ -0,0 +1,234 @@
1
+ """Styles for the NetshowApp - Enhanced Selenized Dark Theme."""
2
+
3
+ CSS = """
4
+ /* === SELENIZED DARK COLOR PALETTE === */
5
+ $bg_0: #103c48;
6
+ $bg_1: #184956;
7
+ $bg_2: #2d5b69;
8
+ $dim_0: #72898f;
9
+ $fg_0: #adbcbc;
10
+ $fg_1: #cad8d9;
11
+
12
+ $red: #fa5750;
13
+ $green: #75b938;
14
+ $yellow: #dbb32d;
15
+ $blue: #4695f7;
16
+ $magenta: #f275be;
17
+ $cyan: #41c7b9;
18
+ $orange: #ed8649;
19
+ $violet: #af88eb;
20
+
21
+ $br_red: #ff665c;
22
+ $br_green: #84c747;
23
+ $br_yellow: #ebc13d;
24
+ $br_blue: #58a3ff;
25
+ $br_magenta: #ff84cd;
26
+ $br_cyan: #53d6c7;
27
+ $br_orange: #fd9456;
28
+ $br_violet: #bd96fa;
29
+
30
+ $accent_primary: $blue;
31
+ $accent_secondary: $magenta;
32
+ $accent_tertiary: $cyan;
33
+ $accent_success: $green;
34
+ $accent_warning: $yellow;
35
+ $accent_error: $red;
36
+
37
+ /* === GLOBAL STYLES === */
38
+ Screen {
39
+ background: $bg_0;
40
+ color: $fg_0;
41
+ }
42
+
43
+ /* === HEADER & FOOTER === */
44
+ Header {
45
+ background: $bg_1;
46
+ color: $fg_1;
47
+ border-bottom: solid $blue;
48
+ text-style: bold;
49
+ }
50
+
51
+ Footer {
52
+ background: $bg_1;
53
+ color: $fg_1;
54
+ border-top: solid $blue;
55
+ }
56
+
57
+ /* === STATUS BAR === */
58
+ #stats_container {
59
+ height: auto;
60
+ margin: 1 0;
61
+ }
62
+
63
+ #status_bar {
64
+ background: $bg_2;
65
+ color: $fg_1;
66
+ height: 3;
67
+ padding: 0 2;
68
+ border: solid $dim_0;
69
+ margin: 0 1;
70
+ text-style: bold;
71
+ }
72
+
73
+ /* === LAYOUT CONTAINERS === */
74
+ Vertical {
75
+ width: 100%;
76
+ height: 1fr;
77
+ padding: 0 1;
78
+ }
79
+
80
+ /* === DATA TABLE STYLING === */
81
+ DataTable {
82
+ background: $bg_0;
83
+ color: $fg_0;
84
+ width: 100%;
85
+ height: 1fr;
86
+ border: solid $dim_0;
87
+ margin: 1 0;
88
+ }
89
+
90
+ DataTable .header {
91
+ background: $bg_1;
92
+ color: $fg_1;
93
+ text-style: bold;
94
+ height: 3;
95
+ border-bottom: solid $blue;
96
+ }
97
+
98
+ DataTable .datatable--cursor {
99
+ background: $blue;
100
+ color: $bg_0;
101
+ text-style: bold;
102
+ }
103
+
104
+ DataTable .datatable--hover {
105
+ background: $bg_2;
106
+ color: $fg_1;
107
+ }
108
+
109
+ DataTable:focus .datatable--cursor {
110
+ background: $br_blue;
111
+ text-style: bold;
112
+ }
113
+
114
+ /* === DETAIL SCREEN STYLING === */
115
+ #detail_title {
116
+ background: $bg_2;
117
+ color: $fg_1;
118
+ height: 4;
119
+ padding: 1 2;
120
+ text-align: center;
121
+ text-style: bold;
122
+ margin: 1 0 2 0;
123
+ border: solid $blue;
124
+ }
125
+
126
+ #main_content {
127
+ height: auto;
128
+ margin: 0 2;
129
+ }
130
+
131
+ #connection_details, #process_info {
132
+ background: $bg_1;
133
+ padding: 2;
134
+ margin: 0 1 2 0;
135
+ border: solid $dim_0;
136
+ height: auto;
137
+ width: 1fr;
138
+ }
139
+
140
+ .section_header {
141
+ background: $bg_2;
142
+ color: $fg_1;
143
+ padding: 1 2;
144
+ text-align: center;
145
+ text-style: bold;
146
+ margin: 0 0 1 0;
147
+ height: 3;
148
+ border: solid $cyan;
149
+ }
150
+
151
+ .detail_title {
152
+ margin: 0 0 1 0;
153
+ padding: 0 1;
154
+ color: $fg_1;
155
+ text-style: bold;
156
+ border-bottom: solid $cyan;
157
+ }
158
+
159
+ .detail_item {
160
+ margin: 0 0 1 0;
161
+ padding: 0 1;
162
+ color: $fg_0;
163
+ border-left: solid $cyan;
164
+ padding-left: 2;
165
+ }
166
+
167
+ .detail_item:hover {
168
+ color: $fg_1;
169
+ background: $bg_2;
170
+ }
171
+
172
+ /* === BUTTONS === */
173
+ #button_container {
174
+ align: center middle;
175
+ height: auto;
176
+ margin: 2 0;
177
+ }
178
+
179
+ #back_button {
180
+ background: $blue;
181
+ color: $bg_0;
182
+ border: solid $blue;
183
+ width: 25;
184
+ height: 3;
185
+ text-style: bold;
186
+ }
187
+
188
+ #back_button:hover {
189
+ background: $br_blue;
190
+ border: solid $br_blue;
191
+ }
192
+
193
+ #back_button:focus {
194
+ background: $cyan;
195
+ border: solid $cyan;
196
+ }
197
+
198
+ Button:focus {
199
+ border: solid $blue;
200
+ }
201
+
202
+ /* === SCROLLABLE CONTAINERS === */
203
+ ScrollableContainer {
204
+ background: transparent;
205
+ scrollbar-background: $bg_1;
206
+ scrollbar-color: $blue;
207
+ scrollbar-color-hover: $br_blue;
208
+ scrollbar-color-active: $cyan;
209
+ }
210
+
211
+ /* === CONNECTION STATUS INDICATORS === */
212
+ .status-ESTABLISHED {
213
+ color: $green;
214
+ text-style: bold;
215
+ }
216
+
217
+ .status-LISTEN {
218
+ color: $blue;
219
+ text-style: bold;
220
+ }
221
+
222
+ .status-TIME_WAIT {
223
+ color: $yellow;
224
+ }
225
+
226
+ .status-CLOSE_WAIT {
227
+ color: $red;
228
+ }
229
+
230
+ /* === ACCESSIBILITY === */
231
+ *:focus {
232
+ outline: solid $blue;
233
+ }
234
+ """
@@ -0,0 +1,152 @@
1
+ Metadata-Version: 2.4
2
+ Name: netshow
3
+ Version: 0.1.1
4
+ Summary: A real-time network connection monitor with friendly service names
5
+ Author: Taylor Wilsdon
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: connections,monitoring,network,tui
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Monitoring
21
+ Classifier: Topic :: System :: Networking :: Monitoring
22
+ Requires-Python: >=3.9
23
+ Requires-Dist: psutil>=5.9.0
24
+ Requires-Dist: textual>=0.40.0
25
+ Provides-Extra: dev
26
+ Requires-Dist: black>=23.0.0; extra == 'dev'
27
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
30
+ Requires-Dist: types-psutil>=5.9.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ <h1 align="center">🚦 NetShow</h1>
34
+ <p align="center"><em>Friendly, process-aware network monitoring for your terminal</em></p>
35
+
36
+ <p align="center">
37
+ <img src="https://img.shields.io/badge/python-3.9%2B-blue?logo=python&logoColor=white" alt="Python versions">
38
+ <img src="https://img.shields.io/github/license/taylorwilsdon/netshow?color=green" alt="License">
39
+ <img src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey" alt="Platform">
40
+ <img src="https://img.shields.io/badge/code%20style-ruff-black?logo=ruff" alt="Code style: ruff">
41
+ <img src="https://img.shields.io/badge/UI-Textual-purple" alt="Built with Textual">
42
+ <img src="https://img.shields.io/badge/dependencies-uv-orange?logo=uv" alt="uv">
43
+ </p>
44
+
45
+ <div align="center">
46
+ <video width="1012px" src="https://github.com/user-attachments/assets/97f5af92-ec0a-4243-9894-d0c9d9470e1a"></video>
47
+ </div>
48
+
49
+ ---
50
+
51
+ <details>
52
+ <summary><strong>Table&nbsp;of&nbsp;Contents</strong></summary>
53
+
54
+ - [Features](#features)
55
+ - [Quickstart](#quickstart)
56
+ - [Usage](#usage)
57
+ - [Keybindings](#keybindings)
58
+ - [Development](#development)
59
+ - [Requirements](#requirements)
60
+ - [Contributing](#contributing)
61
+ - [License](#license)
62
+ </details>
63
+
64
+ ---
65
+
66
+ ## ✨ Features
67
+
68
+ | Capability | Details |
69
+ |------------|---------|
70
+ | **Live TCP monitor** | Refreshes every 3 s (configurable) while preserving scroll position |
71
+ | **Human-friendly service names** | Shows *Docker*, *Plex*, *VS Code*, etc. instead of cryptic binaries |
72
+ | **Deep process drill-down** | Path, PID, cmdline, cwd, threads, CPU %, memory %, open files, active connections |
73
+ | **Clickable / keyboard navigation** | Press `↡` or click a row for a dedicated detail screen; refresh pauses automatically |
74
+ | **Runs privileged <br>or unprivileged** | Uses `psutil` (root) for full fidelity, falls back to `lsof` if run as a regular user |
75
+ | **Modern Textual UI** | Smooth scrolling, dark theme, status bar with connection count & data source |
76
+ | **Zero-pain install** | Powered by [`uv`](https://github.com/astral-sh/uv) for lightning-fast dependency resolution |
77
+
78
+ ---
79
+
80
+ ## πŸš€ Quickstart
81
+
82
+ ```bash
83
+ # Install (recommended)
84
+ uv pip install netshow
85
+
86
+ # Run
87
+ netshow
88
+ ````
89
+
90
+ > **Tip:** Without root/sudo, NetShow silently switches to `lsof` and still gives you most connections.
91
+
92
+ ---
93
+
94
+ ## πŸ› οΈ Usage
95
+
96
+ ```bash
97
+ netshow [--interval 1.0] [--no-colors]
98
+ ```
99
+
100
+ | Option | Description | Default |
101
+ | ------------------ | -------------------- | ------- |
102
+ | `--interval <sec>` | Refresh rate (float) | `3.0` |
103
+ | `--no-colors` | Disable ANSI colors | Off |
104
+
105
+ ### Keybindings
106
+
107
+ | Key / Mouse | Action |
108
+ | ----------- | ---------------- |
109
+ | ↑ / ↓ | Move cursor |
110
+ | ↡ / Click | Open detail view |
111
+ | Esc / ← | Back to list |
112
+ | **q** | Quit NetShow |
113
+
114
+ ---
115
+
116
+ ## πŸ‘©β€πŸ’» Development
117
+
118
+ ```bash
119
+ git clone https://github.com/taylorwilsdon/netshow.git
120
+ cd netshow
121
+ uv sync --extra dev
122
+ ```
123
+
124
+ ### Quality Gates
125
+
126
+ ```bash
127
+ pytest # tests
128
+ ruff format . # auto-format
129
+ ruff check . # lint
130
+ mypy src/ # type check
131
+ ```
132
+
133
+ ---
134
+
135
+ ## πŸ“‹ Requirements
136
+
137
+ * Python **β‰₯ 3.9**
138
+ * macOS or Linux
139
+ * `lsof` (usually pre-installed)
140
+
141
+ ---
142
+
143
+ ## 🀝 Contributing
144
+
145
+ Pull requests and ⭐ stars are welcome! Found a bug or have a feature request? Please [open an issue](https://github.com/taylorwilsdon/netshow/issues).
146
+
147
+ ---
148
+
149
+ ## πŸ“œ License
150
+
151
+ MIT – see [`LICENSE`](LICENSE) for full text.
152
+
@@ -0,0 +1,10 @@
1
+ netshow/__init__.py,sha256=Ii5MvKekxwbMOFspk84VayHSrNbacGOtSpEw8cUmMz8,107
2
+ netshow/app.py,sha256=jozqlrDRtuswkOi71zm51a2wySJylH4ZCmg6eRtucSQ,14494
3
+ netshow/cli.py,sha256=Gf2bP_jkcXHjG6TBnP-gd3t5EfzdAr_lgNahN6j60Ik,256
4
+ netshow/helpers.py,sha256=dkbxdtQnXGv8k7-J9c9ZtM4OFmYw9a91lL44g4xT4Vc,4139
5
+ netshow/styles.py,sha256=8ljAqE3UpWPdWzdwOTw8I18-UULsT6LOo7lMQwtFLSY,3786
6
+ netshow-0.1.1.dist-info/METADATA,sha256=NhIsGKeokJ6-4y_x-debaVboZOsCPa1VX5SUODFJzsc,4662
7
+ netshow-0.1.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ netshow-0.1.1.dist-info/entry_points.txt,sha256=UYD75nElh3HHMSzxCSG7q-BqrgsbELsTU6jrzD0yOw4,45
9
+ netshow-0.1.1.dist-info/licenses/LICENSE,sha256=hFwE7mP1CbnwuyD3PdviRJ5AmeIMnu5sCq52Two_Rnc,1120
10
+ netshow-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ netshow = netshow.cli:main
@@ -0,0 +1,22 @@
1
+ (base) ➜ reddacted git:(main) βœ— cat LICENSE
2
+ MIT License
3
+
4
+ Copyright (c) 2025 Taylor Wilsdon
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.