ifpretty 0.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.
- ifpretty/__init__.py +6 -0
- ifpretty/__main__.py +8 -0
- ifpretty/cli.py +1215 -0
- ifpretty/data/oui.tsv.gz +0 -0
- ifpretty-0.1.0.dist-info/METADATA +98 -0
- ifpretty-0.1.0.dist-info/RECORD +10 -0
- ifpretty-0.1.0.dist-info/WHEEL +5 -0
- ifpretty-0.1.0.dist-info/entry_points.txt +2 -0
- ifpretty-0.1.0.dist-info/licenses/LICENSE +21 -0
- ifpretty-0.1.0.dist-info/top_level.txt +1 -0
ifpretty/__init__.py
ADDED
ifpretty/__main__.py
ADDED
ifpretty/cli.py
ADDED
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
"""ifpretty — render `ifconfig` output as aligned tables (using rich).
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
ifconfig | ifpretty # boxed table per interface (default), borders aligned
|
|
5
|
+
ifconfig | ifpretty --records # compact label/value record layout
|
|
6
|
+
ifconfig | ifpretty --table # single wide table, all fields as columns (<=240)
|
|
7
|
+
ifconfig | ifpretty --live # +route, traffic, friendly names, DNS, DHCP (macOS)
|
|
8
|
+
ifconfig | ifpretty --check # +ping the default gateway for latency (macOS)
|
|
9
|
+
ifconfig | ifpretty --explain # append a legend explaining the flags/options seen
|
|
10
|
+
ifconfig en0 | ifpretty
|
|
11
|
+
|
|
12
|
+
Architecture (thin layers, one file):
|
|
13
|
+
MODEL dataclasses Inet / Inet6 / Interface
|
|
14
|
+
PARSE parse(text) -> list[Interface]
|
|
15
|
+
ENRICH Tier 1 (pure derivation: role, IP scope, MAC kind+vendor, net info,
|
|
16
|
+
EUI-64, shared MACs, MTU/media notes) and Tier 2 (--live / --check:
|
|
17
|
+
route/netstat/networksetup/scutil/ipconfig/ping cross-reference)
|
|
18
|
+
FORMAT value formatters — the single source of truth for how a value looks;
|
|
19
|
+
interface_fields() gives the canonical (label, value) ordering
|
|
20
|
+
RENDER render_boxed / render_records / render_table (selected via RENDERERS)
|
|
21
|
+
|
|
22
|
+
Every field ifpretty understands is preserved; unmodeled lines (e.g. a bridge's
|
|
23
|
+
config block) are kept verbatim and shown under the "bridge" label.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import gzip
|
|
30
|
+
import importlib.resources
|
|
31
|
+
import ipaddress
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Callable
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from rich import box
|
|
44
|
+
from rich.console import Console
|
|
45
|
+
from rich.table import Table
|
|
46
|
+
from rich.text import Text
|
|
47
|
+
except ImportError:
|
|
48
|
+
sys.exit(
|
|
49
|
+
"ifpretty: the 'rich' library is required.\n"
|
|
50
|
+
" install it with: python3 -m pip install rich"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── MODEL ─────────────────────────────────────────────────────────────────────
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Inet:
|
|
57
|
+
addr: str
|
|
58
|
+
netmask: str = "" # already CIDR form, e.g. "255.255.255.0/24"
|
|
59
|
+
broadcast: str = ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Inet6:
|
|
64
|
+
addr: str
|
|
65
|
+
prefixlen: str = ""
|
|
66
|
+
attrs: tuple[str, ...] = ()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class Interface:
|
|
71
|
+
name: str
|
|
72
|
+
mtu: str = ""
|
|
73
|
+
flags: list[str] = field(default_factory=list)
|
|
74
|
+
options: list[str] = field(default_factory=list)
|
|
75
|
+
ether: str = ""
|
|
76
|
+
inet: list[Inet] = field(default_factory=list)
|
|
77
|
+
inet6: list[Inet6] = field(default_factory=list)
|
|
78
|
+
nd6: list[str] = field(default_factory=list)
|
|
79
|
+
media: str = ""
|
|
80
|
+
status: str = ""
|
|
81
|
+
other: list[str] = field(default_factory=list) # unmodeled lines, verbatim
|
|
82
|
+
|
|
83
|
+
# Tier-2 live enrichment (populated only with --live; -1 / "" mean "unknown")
|
|
84
|
+
hw_port: str = "" # friendly name from networksetup, e.g. "Wi-Fi"
|
|
85
|
+
gateway: str = "" # set on the default-route interface
|
|
86
|
+
is_default: bool = False
|
|
87
|
+
rx_bytes: int = -1
|
|
88
|
+
tx_bytes: int = -1
|
|
89
|
+
rx_pkts: int = -1
|
|
90
|
+
tx_pkts: int = -1
|
|
91
|
+
rx_errs: int = 0
|
|
92
|
+
tx_errs: int = 0
|
|
93
|
+
collisions: int = 0
|
|
94
|
+
dns: list[str] = field(default_factory=list)
|
|
95
|
+
search_domains: list[str] = field(default_factory=list)
|
|
96
|
+
dhcp_server: str = ""
|
|
97
|
+
dhcp_lease: int = -1 # seconds
|
|
98
|
+
gw_latency_ms: float = -1.0 # set by --check
|
|
99
|
+
gw_loss: bool = False
|
|
100
|
+
shares_mac_with: list[str] = field(default_factory=list) # Tier-1 derivation
|
|
101
|
+
rx_hist: list[float] = field(default_factory=list) # bytes/s, from --record history
|
|
102
|
+
tx_hist: list[float] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def is_up(self) -> bool:
|
|
106
|
+
return "UP" in self.flags
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def role(self) -> str:
|
|
110
|
+
return interface_role(self.name)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ── PARSE ─────────────────────────────────────────────────────────────────────
|
|
114
|
+
def hexmask_to_cidr(mask: str) -> str:
|
|
115
|
+
"""Convert an ifconfig netmask (0xffffff00) to '255.255.255.0/24'."""
|
|
116
|
+
try:
|
|
117
|
+
value = int(mask, 16)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return mask
|
|
120
|
+
octets = [(value >> shift) & 0xFF for shift in (24, 16, 8, 0)]
|
|
121
|
+
bits = bin(value).count("1")
|
|
122
|
+
return f"{'.'.join(map(str, octets))}/{bits}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# words that are values even though they're alphabetic (no digit/colon to give them away)
|
|
126
|
+
_VALUE_WORDS = {
|
|
127
|
+
"stp",
|
|
128
|
+
"rstp",
|
|
129
|
+
"disabled",
|
|
130
|
+
"enabled",
|
|
131
|
+
"up",
|
|
132
|
+
"down",
|
|
133
|
+
"active",
|
|
134
|
+
"inactive",
|
|
135
|
+
"none",
|
|
136
|
+
"auto",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_value(tok: str) -> bool:
|
|
141
|
+
if "=" in tok or tok.endswith(":"):
|
|
142
|
+
return False
|
|
143
|
+
if any(ch.isdigit() for ch in tok):
|
|
144
|
+
return True
|
|
145
|
+
if ":" in tok: # mac / ipv6 fragment
|
|
146
|
+
return True
|
|
147
|
+
return tok.lower() in _VALUE_WORDS
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def kv_pairs(s: str) -> list[tuple[str, str]]:
|
|
151
|
+
"""Split a multi-field line into (key, value) pairs.
|
|
152
|
+
|
|
153
|
+
Handles `key value` runs (multi-word keys allowed), `label:` headers, and
|
|
154
|
+
`name=hex<FLAG,FLAG>` blobs. Returns ('', value) for a bare value and
|
|
155
|
+
(label, '') for a header so the renderer can style each kind.
|
|
156
|
+
"""
|
|
157
|
+
pairs: list[tuple[str, str]] = []
|
|
158
|
+
key_parts: list[str] = []
|
|
159
|
+
|
|
160
|
+
def flush_label() -> None:
|
|
161
|
+
if key_parts:
|
|
162
|
+
pairs.append((" ".join(key_parts), ""))
|
|
163
|
+
key_parts.clear()
|
|
164
|
+
|
|
165
|
+
for tok in s.split():
|
|
166
|
+
if "=" in tok: # name=hex<FLAGS> or name=value
|
|
167
|
+
flush_label()
|
|
168
|
+
name, _, val = tok.partition("=")
|
|
169
|
+
fm = re.search(r"<([^>]*)>", val)
|
|
170
|
+
if fm:
|
|
171
|
+
val = " ".join(f for f in fm.group(1).split(",") if f)
|
|
172
|
+
pairs.append((name, val))
|
|
173
|
+
elif tok.endswith(":"): # completes a label phrase ("Address cache:")
|
|
174
|
+
key_parts.append(tok[:-1])
|
|
175
|
+
elif _is_value(tok):
|
|
176
|
+
pairs.append((" ".join(key_parts), tok))
|
|
177
|
+
key_parts.clear()
|
|
178
|
+
else:
|
|
179
|
+
key_parts.append(tok)
|
|
180
|
+
flush_label()
|
|
181
|
+
return pairs
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_inet(parts: list[str]) -> Inet:
|
|
185
|
+
addr = parts[1] if len(parts) > 1 else ""
|
|
186
|
+
netmask = broadcast = ""
|
|
187
|
+
i = 2
|
|
188
|
+
while i < len(parts) - 1:
|
|
189
|
+
if parts[i] == "netmask":
|
|
190
|
+
netmask = hexmask_to_cidr(parts[i + 1])
|
|
191
|
+
elif parts[i] == "broadcast":
|
|
192
|
+
broadcast = parts[i + 1]
|
|
193
|
+
i += 2
|
|
194
|
+
return Inet(addr, netmask, broadcast)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_inet6(parts: list[str]) -> Inet6:
|
|
198
|
+
addr = parts[1] if len(parts) > 1 else ""
|
|
199
|
+
prefixlen = ""
|
|
200
|
+
attrs: list[str] = []
|
|
201
|
+
i = 2
|
|
202
|
+
while i < len(parts):
|
|
203
|
+
if parts[i] in ("prefixlen", "scopeid") and i + 1 < len(parts):
|
|
204
|
+
if parts[i] == "prefixlen":
|
|
205
|
+
prefixlen = parts[i + 1]
|
|
206
|
+
i += 2
|
|
207
|
+
else:
|
|
208
|
+
attrs.append(parts[i])
|
|
209
|
+
i += 1
|
|
210
|
+
return Inet6(addr, prefixlen, tuple(attrs))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _flags_in(rest: str) -> list[str]:
|
|
214
|
+
m = re.search(r"flags=\w+<([^>]*)>", rest)
|
|
215
|
+
return [f for f in m.group(1).split(",") if f] if m else []
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def parse(text: str) -> list[Interface]:
|
|
219
|
+
"""Parse ifconfig text into a list of Interface records."""
|
|
220
|
+
interfaces: list[Interface] = []
|
|
221
|
+
cur: Interface | None = None
|
|
222
|
+
|
|
223
|
+
for raw in text.splitlines():
|
|
224
|
+
if not raw.strip():
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
if not raw[0].isspace(): # interface header at column 0
|
|
228
|
+
m = re.match(r"^(\S+?):\s+(.*)$", raw)
|
|
229
|
+
name, rest = m.groups() if m else (raw.rstrip(":"), "")
|
|
230
|
+
mtu = re.search(r"mtu (\d+)", rest)
|
|
231
|
+
cur = Interface(
|
|
232
|
+
name=name, flags=_flags_in(rest), mtu=mtu.group(1) if mtu else ""
|
|
233
|
+
)
|
|
234
|
+
interfaces.append(cur)
|
|
235
|
+
continue
|
|
236
|
+
|
|
237
|
+
if cur is None: # detail before any header — shouldn't happen
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
s = raw.strip()
|
|
241
|
+
if s.startswith("inet "):
|
|
242
|
+
cur.inet.append(_parse_inet(s.split()))
|
|
243
|
+
elif s.startswith("inet6 "):
|
|
244
|
+
cur.inet6.append(_parse_inet6(s.split()))
|
|
245
|
+
elif s.startswith("ether "):
|
|
246
|
+
cur.ether = s.split(None, 1)[1]
|
|
247
|
+
elif s.startswith("status:"):
|
|
248
|
+
cur.status = s.split(":", 1)[1].strip()
|
|
249
|
+
elif s.startswith("media:"):
|
|
250
|
+
cur.media = s.split(":", 1)[1].strip()
|
|
251
|
+
elif m := re.match(r"^options=\w+<([^>]*)>$", s):
|
|
252
|
+
cur.options = [f for f in m.group(1).split(",") if f]
|
|
253
|
+
elif m := re.match(r"^nd6 options=\w+<([^>]*)>$", s):
|
|
254
|
+
cur.nd6 = [f for f in m.group(1).split(",") if f]
|
|
255
|
+
else:
|
|
256
|
+
cur.other.append(s)
|
|
257
|
+
|
|
258
|
+
return interfaces
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── ENRICH (Tier 1: pure derivation, offline, deterministic) ──────────────────
|
|
262
|
+
_ROLES = {
|
|
263
|
+
"lo": "loopback",
|
|
264
|
+
"gif": "tunnel",
|
|
265
|
+
"stf": "6to4 tunnel",
|
|
266
|
+
"awdl": "Apple Wireless Direct",
|
|
267
|
+
"llw": "low-latency WLAN",
|
|
268
|
+
"utun": "VPN/tunnel",
|
|
269
|
+
"bridge": "bridge",
|
|
270
|
+
"ap": "access point",
|
|
271
|
+
"en": "Ethernet/Wi-Fi",
|
|
272
|
+
"anpi": "Apple private",
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
_ULA = ipaddress.ip_network("fc00::/7")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def interface_role(name: str) -> str:
|
|
279
|
+
m = re.match(r"^([a-z]+)", name)
|
|
280
|
+
return _ROLES.get(m.group(1), "") if m else ""
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def mac_kind(ether: str) -> str:
|
|
284
|
+
"""'universal' (burned-in) vs 'locally administered' (randomized/virtual)."""
|
|
285
|
+
if not ether:
|
|
286
|
+
return ""
|
|
287
|
+
try:
|
|
288
|
+
first = int(ether.split(":")[0], 16)
|
|
289
|
+
except ValueError:
|
|
290
|
+
return ""
|
|
291
|
+
if first & 0x01:
|
|
292
|
+
return "multicast"
|
|
293
|
+
return "locally administered" if first & 0x02 else "universal"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# OUI vendor lookup, lazily loaded from the bundled IEEE registry (offline).
|
|
297
|
+
_OUI: dict[str, str] | None = None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _load_oui() -> dict[str, str]:
|
|
301
|
+
global _OUI
|
|
302
|
+
if _OUI is None:
|
|
303
|
+
_OUI = {}
|
|
304
|
+
try:
|
|
305
|
+
res = importlib.resources.files("ifpretty").joinpath("data/oui.tsv.gz")
|
|
306
|
+
text = gzip.decompress(res.read_bytes()).decode("utf-8")
|
|
307
|
+
for line in text.splitlines():
|
|
308
|
+
prefix, _, org = line.partition("\t")
|
|
309
|
+
if org:
|
|
310
|
+
_OUI[prefix] = org
|
|
311
|
+
except (OSError, FileNotFoundError, ModuleNotFoundError):
|
|
312
|
+
pass # no bundled DB → vendor lookup silently disabled
|
|
313
|
+
return _OUI
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def mac_vendor(ether: str) -> str:
|
|
317
|
+
"""IEEE-registered vendor for a universal MAC; '' for local/multicast/unknown."""
|
|
318
|
+
if mac_kind(ether) != "universal":
|
|
319
|
+
return ""
|
|
320
|
+
key = ether.replace(":", "").upper()[:6]
|
|
321
|
+
return _load_oui().get(key, "")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def is_eui64(addr: str) -> bool:
|
|
325
|
+
"""True if an IPv6 interface ID is derived from a MAC (modified EUI-64: ff:fe)."""
|
|
326
|
+
try:
|
|
327
|
+
ip = ipaddress.ip_address(addr.split("%")[0])
|
|
328
|
+
except ValueError:
|
|
329
|
+
return False
|
|
330
|
+
if ip.version != 6:
|
|
331
|
+
return False
|
|
332
|
+
iid = int(ip) & ((1 << 64) - 1) # low 64 bits
|
|
333
|
+
return (iid >> 24) & 0xFFFF == 0xFFFE # bytes 3-4 of the interface identifier
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def mtu_note(mtu: str) -> str:
|
|
337
|
+
if not mtu.isdigit():
|
|
338
|
+
return ""
|
|
339
|
+
n = int(mtu)
|
|
340
|
+
if n >= 9000:
|
|
341
|
+
return " [dim]· jumbo frames[/]"
|
|
342
|
+
if n < 1280:
|
|
343
|
+
return " [yellow]· below IPv6 minimum[/]"
|
|
344
|
+
return ""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def derive_shared_macs(interfaces: list[Interface]) -> None:
|
|
348
|
+
"""Tier 1: note interfaces that share an Ethernet address (e.g. bridge members)."""
|
|
349
|
+
seen: dict[str, list[str]] = {}
|
|
350
|
+
for i in interfaces:
|
|
351
|
+
if i.ether:
|
|
352
|
+
seen.setdefault(i.ether, []).append(i.name)
|
|
353
|
+
for i in interfaces:
|
|
354
|
+
peers = [n for n in seen.get(i.ether, []) if n != i.name]
|
|
355
|
+
i.shares_mac_with = peers
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def ip_scope(addr: str) -> str:
|
|
359
|
+
"""Classify an address: loopback / link-local / ULA / private / global."""
|
|
360
|
+
try:
|
|
361
|
+
ip = ipaddress.ip_address(addr.split("%")[0])
|
|
362
|
+
except ValueError:
|
|
363
|
+
return ""
|
|
364
|
+
if ip.is_loopback:
|
|
365
|
+
return "loopback"
|
|
366
|
+
if ip.is_link_local:
|
|
367
|
+
return "link-local"
|
|
368
|
+
if ip.version == 6 and ip in _ULA:
|
|
369
|
+
return "ULA"
|
|
370
|
+
if ip.is_private:
|
|
371
|
+
return "private"
|
|
372
|
+
if ip.is_global:
|
|
373
|
+
return "global"
|
|
374
|
+
return "reserved"
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def ipv4_net_info(x: Inet) -> str:
|
|
378
|
+
"""'net 192.168.1.0/24 · 254 hosts' from an IPv4 address + netmask."""
|
|
379
|
+
if "/" not in x.netmask:
|
|
380
|
+
return ""
|
|
381
|
+
try:
|
|
382
|
+
net = ipaddress.ip_network(f"{x.addr}/{x.netmask.split('/')[-1]}", strict=False)
|
|
383
|
+
except ValueError:
|
|
384
|
+
return ""
|
|
385
|
+
usable = net.num_addresses - 2 if net.prefixlen <= 30 else net.num_addresses
|
|
386
|
+
return f"net {net.with_prefixlen} · {usable} hosts"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def humanize_bytes(n: int) -> str:
|
|
390
|
+
if n < 0:
|
|
391
|
+
return ""
|
|
392
|
+
size = float(n)
|
|
393
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
394
|
+
if size < 1024 or unit == "TB":
|
|
395
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
396
|
+
size /= 1024
|
|
397
|
+
return f"{size:.1f} PB"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def humanize_count(n: int) -> str:
|
|
401
|
+
if n < 0:
|
|
402
|
+
return ""
|
|
403
|
+
size = float(n)
|
|
404
|
+
for unit in ("", "k", "M", "G"):
|
|
405
|
+
if size < 1000 or unit == "G":
|
|
406
|
+
return f"{size:.0f}{unit}" if unit == "" else f"{size:.1f}{unit}"
|
|
407
|
+
size /= 1000
|
|
408
|
+
return f"{size:.1f}G"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def humanize_secs(s: int) -> str:
|
|
412
|
+
if s < 0:
|
|
413
|
+
return ""
|
|
414
|
+
for limit, unit in ((86400, "d"), (3600, "h"), (60, "m")):
|
|
415
|
+
if s >= limit:
|
|
416
|
+
return f"{s // limit}{unit}"
|
|
417
|
+
return f"{s}s"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def humanize_rate(bytes_per_s: float) -> str:
|
|
421
|
+
bits = bytes_per_s * 8
|
|
422
|
+
for unit in ("bps", "Kbps", "Mbps", "Gbps"):
|
|
423
|
+
if bits < 1000 or unit == "Gbps":
|
|
424
|
+
return f"{bits:.0f} {unit}" if unit == "bps" else f"{bits:.1f} {unit}"
|
|
425
|
+
bits /= 1000
|
|
426
|
+
return f"{bits:.1f} Gbps"
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
_SPARK = "▁▂▃▄▅▆▇█"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def sparkline(values: list[float]) -> str:
|
|
433
|
+
"""Unicode block sparkline scaled 0..peak (single-width chars, alignment-safe)."""
|
|
434
|
+
if not values:
|
|
435
|
+
return ""
|
|
436
|
+
peak = max(values) or 1.0
|
|
437
|
+
return "".join(_SPARK[min(7, int(v / peak * 7.999))] for v in values)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def bar(frac: float, width: int = 10) -> str:
|
|
441
|
+
"""Horizontal gauge from full/light blocks (single-width, alignment-safe)."""
|
|
442
|
+
n = round(max(0.0, min(1.0, frac)) * width)
|
|
443
|
+
return "█" * n + "░" * (width - n)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def link_mbps(media: str) -> int:
|
|
447
|
+
m = re.search(r"(\d+)(G?)[Bb]ase", media or "")
|
|
448
|
+
if not m:
|
|
449
|
+
return 0
|
|
450
|
+
return int(m.group(1)) * 1000 if m.group(2) == "G" else int(m.group(1))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def media_note(media: str) -> str:
|
|
454
|
+
"""Derive 'speed full-duplex' from a media string; warn on half-duplex."""
|
|
455
|
+
if not media:
|
|
456
|
+
return ""
|
|
457
|
+
parts = []
|
|
458
|
+
m = re.search(r"(\d+)(G?)[Bb]ase", media)
|
|
459
|
+
if m:
|
|
460
|
+
n = int(m.group(1))
|
|
461
|
+
if m.group(2) == "G":
|
|
462
|
+
parts.append(f"{n} Gbps")
|
|
463
|
+
elif n >= 1000 and n % 1000 == 0:
|
|
464
|
+
parts.append(f"{n // 1000} Gbps")
|
|
465
|
+
else:
|
|
466
|
+
parts.append(f"{n} Mbps")
|
|
467
|
+
if "full-duplex" in media:
|
|
468
|
+
parts.append("full-duplex")
|
|
469
|
+
out = f" [dim]· {' '.join(parts)}[/]" if parts else ""
|
|
470
|
+
if "half-duplex" in media:
|
|
471
|
+
out += " [yellow]⚠ half-duplex[/]"
|
|
472
|
+
return out
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def traffic_value(iface: Interface) -> str:
|
|
476
|
+
if iface.rx_bytes < 0:
|
|
477
|
+
return ""
|
|
478
|
+
rp = f" ({humanize_count(iface.rx_pkts)} pkts)" if iface.rx_pkts >= 0 else ""
|
|
479
|
+
tp = f" ({humanize_count(iface.tx_pkts)} pkts)" if iface.tx_pkts >= 0 else ""
|
|
480
|
+
return (
|
|
481
|
+
f"↓ {humanize_bytes(iface.rx_bytes)}{rp}"
|
|
482
|
+
f" ↑ {humanize_bytes(iface.tx_bytes)}{tp}"
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def errors_value(iface: Interface) -> str:
|
|
487
|
+
"""Non-empty only when traffic was collected (--live) and something is nonzero."""
|
|
488
|
+
if iface.rx_bytes < 0 or (iface.rx_errs == iface.tx_errs == iface.collisions == 0):
|
|
489
|
+
return ""
|
|
490
|
+
return f"in {iface.rx_errs} out {iface.tx_errs} coll {iface.collisions}"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ── ENRICH (Tier 2: local system cross-reference, opt-in via --live) ──────────
|
|
494
|
+
def _run(cmd: list[str], timeout: float = 3.0) -> str | None:
|
|
495
|
+
"""Run a helper command; return stdout, or None (with a stderr note) on failure."""
|
|
496
|
+
try:
|
|
497
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
498
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
499
|
+
print(f"ifpretty: --live: {cmd[0]} failed ({e})", file=sys.stderr)
|
|
500
|
+
return None
|
|
501
|
+
return r.stdout or None # route(8) returns nonzero with no default route — tolerate
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _apply_default_route(by_name: dict[str, Interface]) -> None:
|
|
505
|
+
out = _run(["route", "-n", "get", "default"])
|
|
506
|
+
if not out:
|
|
507
|
+
return
|
|
508
|
+
gateway = iface = ""
|
|
509
|
+
for line in out.splitlines():
|
|
510
|
+
line = line.strip()
|
|
511
|
+
if line.startswith("gateway:"):
|
|
512
|
+
gateway = line.split(":", 1)[1].strip()
|
|
513
|
+
elif line.startswith("interface:"):
|
|
514
|
+
iface = line.split(":", 1)[1].strip()
|
|
515
|
+
if iface in by_name:
|
|
516
|
+
by_name[iface].is_default = True
|
|
517
|
+
by_name[iface].gateway = gateway
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _read_link_counters() -> dict[str, tuple[int, ...]]:
|
|
521
|
+
"""{ifname: (rx_pkts, rx_errs, rx_bytes, tx_pkts, tx_errs, tx_bytes, coll)}."""
|
|
522
|
+
out: dict[str, tuple[int, ...]] = {}
|
|
523
|
+
text = _run(["netstat", "-ib"])
|
|
524
|
+
if not text:
|
|
525
|
+
return out
|
|
526
|
+
for line in text.splitlines()[1:]:
|
|
527
|
+
f = line.split()
|
|
528
|
+
if len(f) < 6 or not f[2].startswith("<Link"):
|
|
529
|
+
continue
|
|
530
|
+
nums = f[3:]
|
|
531
|
+
if nums and ":" in nums[0]: # drop the MAC column when present
|
|
532
|
+
nums = nums[1:]
|
|
533
|
+
try: # nums = [Ipkts, Ierrs, Ibytes, Opkts, Oerrs, Obytes, Coll]
|
|
534
|
+
vals = tuple(int(nums[i]) for i in range(6))
|
|
535
|
+
except (ValueError, IndexError):
|
|
536
|
+
continue
|
|
537
|
+
coll = int(nums[6]) if len(nums) > 6 and nums[6].isdigit() else 0
|
|
538
|
+
out[f[0]] = vals + (coll,)
|
|
539
|
+
return out
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _apply_traffic(by_name: dict[str, Interface]) -> None:
|
|
543
|
+
for name, v in _read_link_counters().items():
|
|
544
|
+
if name in by_name:
|
|
545
|
+
i = by_name[name]
|
|
546
|
+
(
|
|
547
|
+
i.rx_pkts,
|
|
548
|
+
i.rx_errs,
|
|
549
|
+
i.rx_bytes,
|
|
550
|
+
i.tx_pkts,
|
|
551
|
+
i.tx_errs,
|
|
552
|
+
i.tx_bytes,
|
|
553
|
+
i.collisions,
|
|
554
|
+
) = v
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _apply_hw_ports(by_name: dict[str, Interface]) -> None:
|
|
558
|
+
out = _run(["networksetup", "-listallhardwareports"])
|
|
559
|
+
if not out:
|
|
560
|
+
return
|
|
561
|
+
port = ""
|
|
562
|
+
for line in out.splitlines():
|
|
563
|
+
line = line.strip()
|
|
564
|
+
if line.startswith("Hardware Port:"):
|
|
565
|
+
port = line.split(":", 1)[1].strip()
|
|
566
|
+
elif line.startswith("Device:"):
|
|
567
|
+
dev = line.split(":", 1)[1].strip()
|
|
568
|
+
if dev in by_name:
|
|
569
|
+
by_name[dev].hw_port = port
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _apply_dns(by_name: dict[str, Interface]) -> None:
|
|
573
|
+
out = _run(["scutil", "--dns"])
|
|
574
|
+
if not out:
|
|
575
|
+
return
|
|
576
|
+
for block in re.split(r"resolver #\d+", out):
|
|
577
|
+
m = re.search(r"if_index\s*:\s*\d+\s*\(([^)]+)\)", block)
|
|
578
|
+
if not m or m.group(1) not in by_name:
|
|
579
|
+
continue
|
|
580
|
+
iface = by_name[m.group(1)]
|
|
581
|
+
for ns in re.findall(r"nameserver\[\d+\]\s*:\s*(\S+)", block):
|
|
582
|
+
if ns not in iface.dns:
|
|
583
|
+
iface.dns.append(ns)
|
|
584
|
+
for sd in re.findall(r"search domain\[\d+\]\s*:\s*(\S+)", block):
|
|
585
|
+
if sd not in iface.search_domains:
|
|
586
|
+
iface.search_domains.append(sd)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _apply_dhcp(by_name: dict[str, Interface]) -> None:
|
|
590
|
+
for name, iface in by_name.items():
|
|
591
|
+
if not iface.inet: # only DHCP-capable (IPv4-configured) interfaces
|
|
592
|
+
continue
|
|
593
|
+
server = _run(["ipconfig", "getoption", name, "server_identifier"])
|
|
594
|
+
if server and server.strip():
|
|
595
|
+
iface.dhcp_server = server.strip()
|
|
596
|
+
lease = _run(["ipconfig", "getoption", name, "lease_time"])
|
|
597
|
+
if lease and lease.strip().isdigit():
|
|
598
|
+
iface.dhcp_lease = int(lease.strip())
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def enrich_live(interfaces: list[Interface]) -> None:
|
|
602
|
+
"""Augment interfaces with route, traffic, names, DNS and DHCP lease."""
|
|
603
|
+
if sys.platform != "darwin":
|
|
604
|
+
print("ifpretty: --live enrichment is macOS-only; skipping", file=sys.stderr)
|
|
605
|
+
return
|
|
606
|
+
by_name = {i.name: i for i in interfaces}
|
|
607
|
+
_apply_default_route(by_name)
|
|
608
|
+
_apply_traffic(by_name)
|
|
609
|
+
_apply_hw_ports(by_name)
|
|
610
|
+
_apply_dns(by_name)
|
|
611
|
+
_apply_dhcp(by_name)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _apply_gateway_ping(interfaces: list[Interface]) -> None:
|
|
615
|
+
for iface in interfaces:
|
|
616
|
+
if not (iface.is_default and iface.gateway):
|
|
617
|
+
continue
|
|
618
|
+
out = _run(["ping", "-c", "2", "-t", "2", iface.gateway], timeout=5.0)
|
|
619
|
+
m = re.search(r"=\s*[\d.]+/([\d.]+)/", out) if out else None
|
|
620
|
+
if m:
|
|
621
|
+
iface.gw_latency_ms = float(m.group(1))
|
|
622
|
+
else:
|
|
623
|
+
iface.gw_loss = True
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def enrich_check(interfaces: list[Interface]) -> None:
|
|
627
|
+
"""Active health probe (--check): ping the default gateway."""
|
|
628
|
+
if sys.platform != "darwin":
|
|
629
|
+
print("ifpretty: --check is macOS-only; skipping", file=sys.stderr)
|
|
630
|
+
return
|
|
631
|
+
by_name = {i.name: i for i in interfaces}
|
|
632
|
+
if not any(i.is_default for i in interfaces):
|
|
633
|
+
_apply_default_route(by_name) # need the gateway if --live wasn't used
|
|
634
|
+
_apply_gateway_ping(interfaces)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
# ── ENRICH (bandwidth history: --record samples counters; rendered as a 1h graph)
|
|
638
|
+
_HISTORY_WINDOW = 3600 # seconds of history kept / graphed
|
|
639
|
+
_HISTORY_PATH = Path(
|
|
640
|
+
os.environ.get("IFPRETTY_HISTORY")
|
|
641
|
+
or Path.home() / ".cache" / "ifpretty" / "samples.csv"
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _load_samples() -> list[tuple[int, str, int, int]]:
|
|
646
|
+
"""Each row: (epoch, ifname, rx_bytes, tx_bytes)."""
|
|
647
|
+
rows: list[tuple[int, str, int, int]] = []
|
|
648
|
+
try:
|
|
649
|
+
text = _HISTORY_PATH.read_text()
|
|
650
|
+
except OSError:
|
|
651
|
+
return rows
|
|
652
|
+
for line in text.splitlines():
|
|
653
|
+
parts = line.split(",")
|
|
654
|
+
if len(parts) != 4:
|
|
655
|
+
continue
|
|
656
|
+
try:
|
|
657
|
+
rows.append((int(parts[0]), parts[1], int(parts[2]), int(parts[3])))
|
|
658
|
+
except ValueError:
|
|
659
|
+
continue
|
|
660
|
+
return rows
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def record_sample() -> int:
|
|
664
|
+
"""Append current per-interface byte counters to the history file (for --record)."""
|
|
665
|
+
if sys.platform != "darwin":
|
|
666
|
+
print("ifpretty: --record is macOS-only", file=sys.stderr)
|
|
667
|
+
return 1
|
|
668
|
+
counters = _read_link_counters()
|
|
669
|
+
if not counters:
|
|
670
|
+
print("ifpretty: --record: could not read counters", file=sys.stderr)
|
|
671
|
+
return 1
|
|
672
|
+
now = int(time.time())
|
|
673
|
+
cutoff = now - 2 * _HISTORY_WINDOW
|
|
674
|
+
rows = [r for r in _load_samples() if r[0] >= cutoff]
|
|
675
|
+
rows += [(now, name, v[2], v[5]) for name, v in counters.items()]
|
|
676
|
+
_HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
677
|
+
_HISTORY_PATH.write_text("".join(f"{t},{n},{rx},{tx}\n" for t, n, rx, tx in rows))
|
|
678
|
+
return 0
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _rates(points: list[tuple[int, int, int]]) -> tuple[list[float], list[float]]:
|
|
682
|
+
"""Consecutive (ts, rx, tx) samples -> (rx_bytes/s, tx_bytes/s), resets clamped."""
|
|
683
|
+
rx_rates, tx_rates = [], []
|
|
684
|
+
for (t0, rx0, tx0), (t1, rx1, tx1) in zip(points, points[1:]):
|
|
685
|
+
dt = t1 - t0
|
|
686
|
+
if dt <= 0:
|
|
687
|
+
continue
|
|
688
|
+
rx_rates.append(max(0, rx1 - rx0) / dt)
|
|
689
|
+
tx_rates.append(max(0, tx1 - tx0) / dt)
|
|
690
|
+
return rx_rates, tx_rates
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def enrich_history(interfaces: list[Interface]) -> None:
|
|
694
|
+
"""Attach the last hour of throughput rates from the --record datastore."""
|
|
695
|
+
samples = _load_samples()
|
|
696
|
+
if not samples:
|
|
697
|
+
return
|
|
698
|
+
now = max(ts for ts, _, _, _ in samples)
|
|
699
|
+
by_iface: dict[str, list[tuple[int, int, int]]] = {}
|
|
700
|
+
for ts, name, rx, tx in samples:
|
|
701
|
+
if now - ts <= _HISTORY_WINDOW:
|
|
702
|
+
by_iface.setdefault(name, []).append((ts, rx, tx))
|
|
703
|
+
for iface in interfaces:
|
|
704
|
+
pts = sorted(by_iface.get(iface.name, []))[-61:] # ~1h at 1/min
|
|
705
|
+
iface.rx_hist, iface.tx_hist = _rates(pts)
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ── FORMAT — single source of truth for how each value looks ───────────────────
|
|
709
|
+
DASH = "[grey50]—[/]"
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def status_badge(iface: Interface) -> str:
|
|
713
|
+
if iface.status == "active":
|
|
714
|
+
return "[green]● active[/]"
|
|
715
|
+
if iface.status == "inactive":
|
|
716
|
+
return "[red]● inactive[/]"
|
|
717
|
+
return "[green]● up[/]" if iface.is_up else "[grey50]● down[/]"
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _color_kv_line(line: str, sep: str = " ") -> str:
|
|
721
|
+
"""Color a verbatim multi-field line (bridge config) into key/value chunks."""
|
|
722
|
+
chunks = []
|
|
723
|
+
for k, v in kv_pairs(line):
|
|
724
|
+
if k and v:
|
|
725
|
+
chunks.append(f"[cyan]{k}[/] {v}")
|
|
726
|
+
elif k:
|
|
727
|
+
chunks.append(f"[bold]{k}[/]")
|
|
728
|
+
else:
|
|
729
|
+
chunks.append(v)
|
|
730
|
+
return sep.join(chunks) or f"[dim]{line}[/]"
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def inet_detail(x: Inet) -> str:
|
|
734
|
+
val = f"[bold green]{x.addr}[/]"
|
|
735
|
+
if x.netmask:
|
|
736
|
+
val += f" netmask {x.netmask}"
|
|
737
|
+
if x.broadcast:
|
|
738
|
+
val += f" broadcast {x.broadcast}"
|
|
739
|
+
ann = [a for a in (ip_scope(x.addr), ipv4_net_info(x)) if a]
|
|
740
|
+
if ann:
|
|
741
|
+
val += " [dim]· " + " · ".join(ann) + "[/]"
|
|
742
|
+
return val
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# IPv6 attributes map to fixed display columns so they line up vertically.
|
|
746
|
+
_V6_SLOTS = ("state", "config", "source")
|
|
747
|
+
_V6_ATTR_SLOT = {
|
|
748
|
+
"deprecated": "state",
|
|
749
|
+
"detached": "state",
|
|
750
|
+
"tentative": "state",
|
|
751
|
+
"duplicated": "state",
|
|
752
|
+
"optimistic": "state",
|
|
753
|
+
"autoconf": "config",
|
|
754
|
+
"secured": "source",
|
|
755
|
+
"temporary": "source",
|
|
756
|
+
"dynamic": "source",
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@dataclass
|
|
761
|
+
class V6Layout:
|
|
762
|
+
"""Column widths for aligning a single interface's IPv6 rows."""
|
|
763
|
+
|
|
764
|
+
addr_w: int = 0
|
|
765
|
+
slot_w: dict[str, int] = field(default_factory=lambda: {s: 0 for s in _V6_SLOTS})
|
|
766
|
+
extra_w: int = 0
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _v6_slotted(attrs: tuple[str, ...]) -> tuple[dict[str, str], list[str]]:
|
|
770
|
+
slots: dict[str, list[str]] = {s: [] for s in _V6_SLOTS}
|
|
771
|
+
extra: list[str] = []
|
|
772
|
+
for a in attrs:
|
|
773
|
+
slot = _V6_ATTR_SLOT.get(a)
|
|
774
|
+
(slots[slot] if slot else extra).append(a)
|
|
775
|
+
return {s: " ".join(v) for s, v in slots.items()}, extra
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def plan_inet6(items: list[Inet6]) -> V6Layout:
|
|
779
|
+
layout = V6Layout()
|
|
780
|
+
for x in items:
|
|
781
|
+
addr = f"{x.addr}/{x.prefixlen}" if x.prefixlen else x.addr
|
|
782
|
+
layout.addr_w = max(layout.addr_w, len(addr))
|
|
783
|
+
slots, extra = _v6_slotted(x.attrs)
|
|
784
|
+
for s in _V6_SLOTS:
|
|
785
|
+
layout.slot_w[s] = max(layout.slot_w[s], len(slots[s]))
|
|
786
|
+
layout.extra_w = max(layout.extra_w, len(" ".join(extra)))
|
|
787
|
+
return layout
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _pad(markup: str, visible: int, width: int) -> str:
|
|
791
|
+
"""Right-pad `markup` to `width` columns, counting only `visible` chars."""
|
|
792
|
+
return markup + " " * max(0, width - visible)
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def fmt_inet6_aligned(x: Inet6, layout: V6Layout) -> str:
|
|
796
|
+
addr = f"{x.addr}/{x.prefixlen}" if x.prefixlen else x.addr
|
|
797
|
+
parts = [_pad(f"[bold magenta]{addr}[/]", len(addr), layout.addr_w)]
|
|
798
|
+
slots, extra = _v6_slotted(x.attrs)
|
|
799
|
+
for s in _V6_SLOTS:
|
|
800
|
+
if layout.slot_w[s]:
|
|
801
|
+
v = slots[s]
|
|
802
|
+
parts.append(_pad(f"[yellow]{v}[/]" if v else "", len(v), layout.slot_w[s]))
|
|
803
|
+
if layout.extra_w:
|
|
804
|
+
ex = " ".join(extra)
|
|
805
|
+
parts.append(_pad(f"[yellow]{ex}[/]" if ex else "", len(ex), layout.extra_w))
|
|
806
|
+
line = " ".join(parts)
|
|
807
|
+
tail = [t for t in (ip_scope(x.addr), "EUI-64" if is_eui64(x.addr) else "") if t]
|
|
808
|
+
if tail:
|
|
809
|
+
line += " [dim]· " + " · ".join(tail) + "[/]"
|
|
810
|
+
return line
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def ether_value(iface: Interface) -> str:
|
|
814
|
+
"""MAC + (kind · vendor) + shared-with note."""
|
|
815
|
+
if not iface.ether:
|
|
816
|
+
return ""
|
|
817
|
+
out = f"[yellow]{iface.ether}[/]"
|
|
818
|
+
tags = [t for t in (mac_kind(iface.ether), mac_vendor(iface.ether)) if t]
|
|
819
|
+
if tags:
|
|
820
|
+
out += " [dim](" + " · ".join(tags) + ")[/]"
|
|
821
|
+
if iface.shares_mac_with:
|
|
822
|
+
out += " [dim]· shared with " + " ".join(iface.shares_mac_with) + "[/]"
|
|
823
|
+
return out
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def gateway_value(iface: Interface) -> str:
|
|
827
|
+
if not iface.gateway:
|
|
828
|
+
return ""
|
|
829
|
+
out = iface.gateway
|
|
830
|
+
if iface.gw_latency_ms >= 0:
|
|
831
|
+
out += f" [dim]· {iface.gw_latency_ms:.1f} ms[/]"
|
|
832
|
+
elif iface.gw_loss:
|
|
833
|
+
out += " [red]· unreachable[/]"
|
|
834
|
+
return out
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def dhcp_value(iface: Interface) -> str:
|
|
838
|
+
if not iface.dhcp_server:
|
|
839
|
+
return ""
|
|
840
|
+
lease = humanize_secs(iface.dhcp_lease)
|
|
841
|
+
return iface.dhcp_server + (f" [dim]· lease {lease}[/]" if lease else "")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def bandwidth_rows(iface: Interface) -> list[tuple[str, str]]:
|
|
845
|
+
"""1h throughput sparklines + link-utilization gauge (only with --record data)."""
|
|
846
|
+
rows: list[tuple[str, str]] = []
|
|
847
|
+
if iface.rx_hist:
|
|
848
|
+
rows.append(
|
|
849
|
+
(
|
|
850
|
+
"1h in",
|
|
851
|
+
sparkline(iface.rx_hist)
|
|
852
|
+
+ f" [dim]peak {humanize_rate(max(iface.rx_hist))}[/]",
|
|
853
|
+
)
|
|
854
|
+
)
|
|
855
|
+
if iface.tx_hist:
|
|
856
|
+
rows.append(
|
|
857
|
+
(
|
|
858
|
+
"1h out",
|
|
859
|
+
sparkline(iface.tx_hist)
|
|
860
|
+
+ f" [dim]peak {humanize_rate(max(iface.tx_hist))}[/]",
|
|
861
|
+
)
|
|
862
|
+
)
|
|
863
|
+
spd = link_mbps(iface.media)
|
|
864
|
+
if iface.rx_hist and iface.tx_hist and spd:
|
|
865
|
+
frac = max(iface.rx_hist[-1], iface.tx_hist[-1]) * 8 / (spd * 1e6)
|
|
866
|
+
rows.append(
|
|
867
|
+
("link use", f"{bar(frac)} [dim]{frac * 100:.0f}% of {spd} Mbps[/]")
|
|
868
|
+
)
|
|
869
|
+
return rows
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def interface_fields(iface: Interface) -> list[tuple[str, str]]:
|
|
873
|
+
"""Canonical ordered (label, styled-value) rows for the row-based layouts."""
|
|
874
|
+
rows: list[tuple[str, str]] = []
|
|
875
|
+
|
|
876
|
+
def add(label: str, value: str) -> None:
|
|
877
|
+
if value:
|
|
878
|
+
rows.append((label, value))
|
|
879
|
+
|
|
880
|
+
add("mtu", iface.mtu + mtu_note(iface.mtu) if iface.mtu else "")
|
|
881
|
+
add("flags", " ".join(iface.flags))
|
|
882
|
+
add("options", " ".join(iface.options))
|
|
883
|
+
add("ether", ether_value(iface))
|
|
884
|
+
for v4 in iface.inet:
|
|
885
|
+
add("inet", inet_detail(v4))
|
|
886
|
+
v6plan = plan_inet6(iface.inet6)
|
|
887
|
+
for v6 in iface.inet6:
|
|
888
|
+
add("inet6", fmt_inet6_aligned(v6, v6plan))
|
|
889
|
+
add("nd6 options", " ".join(iface.nd6))
|
|
890
|
+
add("media", iface.media + media_note(iface.media) if iface.media else "")
|
|
891
|
+
add("dns", " ".join(iface.dns)) # Tier 2 (--live)
|
|
892
|
+
add("search", " ".join(iface.search_domains))
|
|
893
|
+
add("dhcp", dhcp_value(iface))
|
|
894
|
+
add("gateway", gateway_value(iface))
|
|
895
|
+
add("traffic", traffic_value(iface))
|
|
896
|
+
errs = errors_value(iface)
|
|
897
|
+
add("errors", f"[red]{errs}[/]" if errs else "")
|
|
898
|
+
for label, value in bandwidth_rows(iface):
|
|
899
|
+
add(label, value)
|
|
900
|
+
for i, line in enumerate(iface.other):
|
|
901
|
+
add("bridge" if i == 0 else "", _color_kv_line(line, sep=" "))
|
|
902
|
+
return rows
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
# compact formatters for the column-oriented table layout
|
|
906
|
+
def inet_compact(x: Inet) -> str:
|
|
907
|
+
bits = x.netmask.split("/")[-1] if "/" in x.netmask else ""
|
|
908
|
+
s = f"{x.addr}/{bits}" if bits else x.addr
|
|
909
|
+
if x.broadcast:
|
|
910
|
+
s += f" [dim]bcast {x.broadcast}[/]"
|
|
911
|
+
return s
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def inet6_compact(x: Inet6) -> str:
|
|
915
|
+
s = f"{x.addr}/{x.prefixlen}" if x.prefixlen else x.addr
|
|
916
|
+
if x.attrs:
|
|
917
|
+
s += " [yellow]" + " ".join(x.attrs) + "[/]"
|
|
918
|
+
return s
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def details_cell(iface: Interface) -> str:
|
|
922
|
+
"""flags / options / nd6 / media / bridge-config as labeled lines (table view)."""
|
|
923
|
+
lines = []
|
|
924
|
+
if iface.flags:
|
|
925
|
+
lines.append("[dim cyan]flags[/] [green]" + " ".join(iface.flags) + "[/]")
|
|
926
|
+
if iface.options:
|
|
927
|
+
lines.append("[dim cyan]opts [/] " + " ".join(iface.options))
|
|
928
|
+
if iface.nd6:
|
|
929
|
+
lines.append("[dim cyan]nd6 [/] " + " ".join(iface.nd6))
|
|
930
|
+
if iface.media:
|
|
931
|
+
lines.append("[dim cyan]media[/] " + iface.media + media_note(iface.media))
|
|
932
|
+
if iface.dns:
|
|
933
|
+
lines.append("[dim cyan]dns [/] " + " ".join(iface.dns))
|
|
934
|
+
if iface.search_domains:
|
|
935
|
+
lines.append("[dim cyan]srch [/] " + " ".join(iface.search_domains))
|
|
936
|
+
if dhcp_value(iface):
|
|
937
|
+
lines.append("[dim cyan]dhcp [/] " + dhcp_value(iface))
|
|
938
|
+
if gateway_value(iface):
|
|
939
|
+
lines.append("[dim cyan]gw [/] " + gateway_value(iface))
|
|
940
|
+
if traffic_value(iface):
|
|
941
|
+
lines.append("[dim cyan]traf [/] " + traffic_value(iface))
|
|
942
|
+
if errors_value(iface):
|
|
943
|
+
lines.append("[dim cyan]err [/] [red]" + errors_value(iface) + "[/]")
|
|
944
|
+
for label, value in bandwidth_rows(iface):
|
|
945
|
+
lines.append(f"[dim cyan]{label:<5}[/] {value}")
|
|
946
|
+
lines += [_color_kv_line(line) for line in iface.other]
|
|
947
|
+
return "\n".join(lines) or DASH
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def caption_suffix(iface: Interface) -> str:
|
|
951
|
+
"""Role / friendly-name / default-route badge appended to an interface header."""
|
|
952
|
+
parts = []
|
|
953
|
+
label = iface.hw_port or iface.role
|
|
954
|
+
if label:
|
|
955
|
+
parts.append(f"[cyan]{label}[/]")
|
|
956
|
+
if iface.is_default:
|
|
957
|
+
parts.append("[green]default ↑[/]")
|
|
958
|
+
return (" · " + " · ".join(parts)) if parts else ""
|
|
959
|
+
|
|
960
|
+
|
|
961
|
+
# ── RENDER — strategies over the same model/formatters ─────────────────────────
|
|
962
|
+
def _console(non_tty_width: int) -> Console:
|
|
963
|
+
return Console(width=None if sys.stdout.isatty() else non_tty_width)
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def render_boxed(interfaces: list[Interface]) -> None:
|
|
967
|
+
"""Boxed table per interface; every box shares one width so borders align."""
|
|
968
|
+
console = _console(200)
|
|
969
|
+
all_rows = [interface_fields(iface) for iface in interfaces]
|
|
970
|
+
label_w = max(
|
|
971
|
+
(len(lbl) for rows in all_rows for lbl, _ in rows), default=len("nd6 options")
|
|
972
|
+
)
|
|
973
|
+
value_w = max(
|
|
974
|
+
(Text.from_markup(val).cell_len for rows in all_rows for _, val in rows),
|
|
975
|
+
default=1,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
for iface, rows in zip(interfaces, all_rows):
|
|
979
|
+
table = Table(
|
|
980
|
+
box=box.ROUNDED,
|
|
981
|
+
show_header=False,
|
|
982
|
+
title=f"[bold blue]{iface.name}[/] {status_badge(iface)}"
|
|
983
|
+
f"{caption_suffix(iface)}",
|
|
984
|
+
title_justify="left",
|
|
985
|
+
)
|
|
986
|
+
table.add_column(
|
|
987
|
+
"Field", style="cyan", justify="right", width=label_w, no_wrap=True
|
|
988
|
+
)
|
|
989
|
+
table.add_column("Value", width=value_w, no_wrap=True)
|
|
990
|
+
for label, value in rows:
|
|
991
|
+
table.add_row(label, value)
|
|
992
|
+
console.print(table)
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def render_records(interfaces: list[Interface]) -> None:
|
|
996
|
+
"""Compact label/value blocks — width bounded by the longest line."""
|
|
997
|
+
console = _console(100)
|
|
998
|
+
all_rows = [interface_fields(iface) for iface in interfaces]
|
|
999
|
+
label_w = max((len(lbl) for rows in all_rows for lbl, _ in rows), default=1)
|
|
1000
|
+
|
|
1001
|
+
for i, (iface, rows) in enumerate(zip(interfaces, all_rows)):
|
|
1002
|
+
if i:
|
|
1003
|
+
console.print()
|
|
1004
|
+
mtu = f" · [dim]mtu {iface.mtu}[/]" if iface.mtu else ""
|
|
1005
|
+
console.print(
|
|
1006
|
+
f"[bold blue]{iface.name}[/] {status_badge(iface)}"
|
|
1007
|
+
f"{caption_suffix(iface)}{mtu}"
|
|
1008
|
+
)
|
|
1009
|
+
for label, value in rows:
|
|
1010
|
+
if label == "mtu": # already shown in the header
|
|
1011
|
+
continue
|
|
1012
|
+
console.print(f" [cyan]{label:<{label_w}}[/] {value}")
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def render_table(interfaces: list[Interface]) -> None:
|
|
1016
|
+
"""All interfaces and all fields in one wide table; cap width at 240."""
|
|
1017
|
+
cap = 240
|
|
1018
|
+
term = shutil.get_terminal_size((cap, 24)).columns if sys.stdout.isatty() else cap
|
|
1019
|
+
console = Console(width=min(term, cap))
|
|
1020
|
+
|
|
1021
|
+
table = Table(
|
|
1022
|
+
box=box.ROUNDED, header_style="bold cyan", title="ifconfig", show_lines=True
|
|
1023
|
+
)
|
|
1024
|
+
table.add_column("Interface", no_wrap=True)
|
|
1025
|
+
table.add_column("Status", no_wrap=True)
|
|
1026
|
+
table.add_column("MTU", justify="right", style="dim", no_wrap=True)
|
|
1027
|
+
table.add_column("MAC", style="yellow", no_wrap=True)
|
|
1028
|
+
table.add_column("IPv4", style="green", no_wrap=True)
|
|
1029
|
+
table.add_column("IPv6", style="magenta", no_wrap=True)
|
|
1030
|
+
table.add_column("Details")
|
|
1031
|
+
|
|
1032
|
+
for iface in interfaces:
|
|
1033
|
+
name_cell = f"[bold blue]{iface.name}[/]"
|
|
1034
|
+
sub = iface.hw_port or iface.role
|
|
1035
|
+
if sub:
|
|
1036
|
+
name_cell += f"\n[dim]{sub}[/]"
|
|
1037
|
+
if iface.is_default:
|
|
1038
|
+
name_cell += "\n[green]default ↑[/]"
|
|
1039
|
+
table.add_row(
|
|
1040
|
+
name_cell,
|
|
1041
|
+
status_badge(iface),
|
|
1042
|
+
iface.mtu or DASH,
|
|
1043
|
+
iface.ether or DASH,
|
|
1044
|
+
"\n".join(inet_compact(x) for x in iface.inet) or DASH,
|
|
1045
|
+
"\n".join(inet6_compact(x) for x in iface.inet6) or DASH,
|
|
1046
|
+
details_cell(iface),
|
|
1047
|
+
)
|
|
1048
|
+
console.print(table)
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def render_overview(interfaces: list[Interface]) -> None:
|
|
1052
|
+
"""One-line summary above the layout (shown only when >1 interface)."""
|
|
1053
|
+
console = _console(120)
|
|
1054
|
+
up = sum(1 for i in interfaces if i.is_up)
|
|
1055
|
+
parts = [f"[bold]{len(interfaces)}[/] interfaces", f"[green]{up} up[/]"]
|
|
1056
|
+
default = next((i for i in interfaces if i.is_default), None)
|
|
1057
|
+
if default:
|
|
1058
|
+
gw = f" ({default.gateway})" if default.gateway else ""
|
|
1059
|
+
parts.append(f"default via [bold blue]{default.name}[/]{gw}")
|
|
1060
|
+
live = [i for i in interfaces if i.rx_bytes >= 0]
|
|
1061
|
+
if live:
|
|
1062
|
+
rx = sum(i.rx_bytes for i in live)
|
|
1063
|
+
tx = sum(i.tx_bytes for i in live)
|
|
1064
|
+
parts.append(f"↓ {humanize_bytes(rx)} ↑ {humanize_bytes(tx)}")
|
|
1065
|
+
console.print(" · ".join(parts))
|
|
1066
|
+
console.print()
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
Renderer = Callable[[list[Interface]], None]
|
|
1070
|
+
RENDERERS: dict[str, Renderer] = {
|
|
1071
|
+
"boxed": render_boxed,
|
|
1072
|
+
"records": render_records,
|
|
1073
|
+
"table": render_table,
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# ── EXPLAIN — flag/option glossary (--explain) ────────────────────────────────
|
|
1078
|
+
_GLOSSARY = {
|
|
1079
|
+
# flags
|
|
1080
|
+
"UP": "interface is up",
|
|
1081
|
+
"DOWN": "interface is down",
|
|
1082
|
+
"BROADCAST": "supports broadcast",
|
|
1083
|
+
"MULTICAST": "supports multicast",
|
|
1084
|
+
"LOOPBACK": "loopback interface",
|
|
1085
|
+
"POINTOPOINT": "point-to-point link",
|
|
1086
|
+
"RUNNING": "resources allocated",
|
|
1087
|
+
"SIMPLEX": "can't hear its own transmissions",
|
|
1088
|
+
"SMART": "managed by the system",
|
|
1089
|
+
"PROMISC": "promiscuous (sees all traffic)",
|
|
1090
|
+
"NOARP": "ARP disabled",
|
|
1091
|
+
# options (hardware offloads etc.)
|
|
1092
|
+
"RXCSUM": "hardware RX checksum offload",
|
|
1093
|
+
"TXCSUM": "hardware TX checksum offload",
|
|
1094
|
+
"TSO4": "TCP segmentation offload (IPv4)",
|
|
1095
|
+
"TSO6": "TCP segmentation offload (IPv6)",
|
|
1096
|
+
"LRO": "large receive offload",
|
|
1097
|
+
"VLAN_MTU": "VLAN-sized MTU support",
|
|
1098
|
+
"CHANNEL_IO": "multi-queue channel I/O",
|
|
1099
|
+
"PARTIAL_CSUM": "partial checksum offload",
|
|
1100
|
+
"ZEROINVERT_CSUM": "zero-invert checksum offload",
|
|
1101
|
+
"TXSTATUS": "TX completion status",
|
|
1102
|
+
"SW_TIMESTAMP": "software packet timestamping",
|
|
1103
|
+
# nd6
|
|
1104
|
+
"PERFORMNUD": "neighbor unreachability detection",
|
|
1105
|
+
"DAD": "duplicate address detection",
|
|
1106
|
+
"IFDISABLED": "IPv6 disabled on this interface",
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def render_legend(interfaces: list[Interface]) -> None:
|
|
1111
|
+
console = Console(width=None if sys.stdout.isatty() else 100)
|
|
1112
|
+
sections = [
|
|
1113
|
+
("flags", sorted({t for i in interfaces for t in i.flags})),
|
|
1114
|
+
("options", sorted({t for i in interfaces for t in i.options})),
|
|
1115
|
+
("nd6", sorted({t for i in interfaces for t in i.nd6})),
|
|
1116
|
+
]
|
|
1117
|
+
console.print("\n[bold]Legend[/]")
|
|
1118
|
+
for title, tokens in sections:
|
|
1119
|
+
if not tokens:
|
|
1120
|
+
continue
|
|
1121
|
+
console.print(f"[dim cyan]{title}[/]")
|
|
1122
|
+
for tok in tokens:
|
|
1123
|
+
desc = _GLOSSARY.get(tok, "[dim](no description)[/]")
|
|
1124
|
+
console.print(f" [green]{tok:<16}[/] {desc}")
|
|
1125
|
+
|
|
1126
|
+
|
|
1127
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
1128
|
+
def _cli() -> int:
|
|
1129
|
+
p = argparse.ArgumentParser(
|
|
1130
|
+
prog="ifpretty",
|
|
1131
|
+
description="Prettify `ifconfig` output. Pipe ifconfig into me.",
|
|
1132
|
+
epilog="bandwidth graph: schedule `* * * * * %s --record` (cron/launchd) to "
|
|
1133
|
+
"sample counters; the 1h sparkline then appears automatically."
|
|
1134
|
+
% (shutil.which("ifpretty") or "ifpretty"),
|
|
1135
|
+
)
|
|
1136
|
+
g = p.add_mutually_exclusive_group()
|
|
1137
|
+
g.add_argument(
|
|
1138
|
+
"-r",
|
|
1139
|
+
"--records",
|
|
1140
|
+
dest="layout",
|
|
1141
|
+
action="store_const",
|
|
1142
|
+
const="records",
|
|
1143
|
+
help="compact label/value record layout",
|
|
1144
|
+
)
|
|
1145
|
+
g.add_argument(
|
|
1146
|
+
"-t",
|
|
1147
|
+
"--table",
|
|
1148
|
+
dest="layout",
|
|
1149
|
+
action="store_const",
|
|
1150
|
+
const="table",
|
|
1151
|
+
help="single wide table, all fields as columns",
|
|
1152
|
+
)
|
|
1153
|
+
p.add_argument(
|
|
1154
|
+
"-l",
|
|
1155
|
+
"--live",
|
|
1156
|
+
action="store_true",
|
|
1157
|
+
help="enrich with live system data (macOS: route, traffic, "
|
|
1158
|
+
"friendly names, DNS, DHCP lease)",
|
|
1159
|
+
)
|
|
1160
|
+
p.add_argument(
|
|
1161
|
+
"-c",
|
|
1162
|
+
"--check",
|
|
1163
|
+
action="store_true",
|
|
1164
|
+
help="ping the default gateway and report latency (macOS)",
|
|
1165
|
+
)
|
|
1166
|
+
p.add_argument(
|
|
1167
|
+
"--record",
|
|
1168
|
+
action="store_true",
|
|
1169
|
+
help="sample byte counters into the history file and exit "
|
|
1170
|
+
"(run periodically via cron to feed the 1h graph)",
|
|
1171
|
+
)
|
|
1172
|
+
p.add_argument(
|
|
1173
|
+
"-x",
|
|
1174
|
+
"--explain",
|
|
1175
|
+
action="store_true",
|
|
1176
|
+
help="append a legend explaining the flags/options seen",
|
|
1177
|
+
)
|
|
1178
|
+
p.set_defaults(layout="boxed")
|
|
1179
|
+
args = p.parse_args()
|
|
1180
|
+
|
|
1181
|
+
if args.record: # no stdin needed; samples counters and exits
|
|
1182
|
+
return record_sample()
|
|
1183
|
+
|
|
1184
|
+
data = sys.stdin.read()
|
|
1185
|
+
if not data.strip():
|
|
1186
|
+
print("ifpretty: no input (pipe `ifconfig` into me)", file=sys.stderr)
|
|
1187
|
+
return 1
|
|
1188
|
+
|
|
1189
|
+
interfaces = parse(data)
|
|
1190
|
+
if args.live:
|
|
1191
|
+
enrich_live(interfaces)
|
|
1192
|
+
if args.check:
|
|
1193
|
+
enrich_check(interfaces)
|
|
1194
|
+
derive_shared_macs(interfaces) # Tier 1: always-on, deterministic
|
|
1195
|
+
enrich_history(interfaces) # 1h bandwidth from the --record datastore
|
|
1196
|
+
if len(interfaces) > 1:
|
|
1197
|
+
render_overview(interfaces)
|
|
1198
|
+
RENDERERS[args.layout](interfaces)
|
|
1199
|
+
if args.explain:
|
|
1200
|
+
render_legend(interfaces)
|
|
1201
|
+
return 0
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def main() -> int:
|
|
1205
|
+
"""Console-script entry point: run and translate signals to exit codes."""
|
|
1206
|
+
try:
|
|
1207
|
+
return _cli()
|
|
1208
|
+
except BrokenPipeError:
|
|
1209
|
+
return 0
|
|
1210
|
+
except KeyboardInterrupt:
|
|
1211
|
+
return 130
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
if __name__ == "__main__":
|
|
1215
|
+
sys.exit(main())
|
ifpretty/data/oui.tsv.gz
ADDED
|
Binary file
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ifpretty
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Prettify and enrich ifconfig output: aligned tables, IP/MAC/scope annotations, OUI vendor lookup, live DNS/DHCP/route, and a 1h bandwidth graph.
|
|
5
|
+
Author-email: iklo <tiktachack@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: ifconfig,network,networking,cli,terminal,rich,macos
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: System Administrators
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Operating System :: POSIX
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Topic :: System :: Networking
|
|
16
|
+
Classifier: Topic :: System :: Monitoring
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: rich>=13
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# ifpretty
|
|
24
|
+
|
|
25
|
+
Prettify and **enrich** `ifconfig` output — aligned tables, color, decoded flags,
|
|
26
|
+
IP/MAC/scope annotations, OUI vendor lookup, live DNS/DHCP/route, and a 1‑hour
|
|
27
|
+
bandwidth graph. Built on [rich](https://github.com/Textualize/rich); macOS‑focused.
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
ifconfig | ifpretty
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
pip install ifpretty
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
ifconfig | ifpretty # boxed table per interface (default), borders aligned
|
|
43
|
+
ifconfig | ifpretty --records # compact label/value record layout
|
|
44
|
+
ifconfig | ifpretty --table # single wide table, all fields as columns (<=240)
|
|
45
|
+
ifconfig | ifpretty --live # + route, traffic, friendly names, DNS, DHCP (macOS)
|
|
46
|
+
ifconfig | ifpretty --check # + ping the default gateway for latency (macOS)
|
|
47
|
+
ifconfig | ifpretty --explain # append a legend explaining the flags/options seen
|
|
48
|
+
ifconfig en0 | ifpretty
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It reads raw `ifconfig` on **stdin**, so you stay in control of which interfaces
|
|
52
|
+
to show.
|
|
53
|
+
|
|
54
|
+
## What it adds
|
|
55
|
+
|
|
56
|
+
**Always on (offline, deterministic):**
|
|
57
|
+
|
|
58
|
+
- Per‑interface **role** (loopback / Ethernet·Wi‑Fi / VPN·tunnel / bridge / …).
|
|
59
|
+
- **IP scope** for every address — loopback / link‑local / ULA / private / global —
|
|
60
|
+
plus IPv4 network + usable‑host count.
|
|
61
|
+
- **MAC** kind (universal vs locally‑administered), **OUI vendor** (bundled IEEE
|
|
62
|
+
registry, no network), shared‑MAC notes (e.g. bridge members), and EUI‑64 detection.
|
|
63
|
+
- **Media** speed/duplex with a half‑duplex ⚠ warning; jumbo / sub‑1280 MTU notes.
|
|
64
|
+
- Decoded flag/option/nd6 bitfields; aligned IPv6 attribute columns.
|
|
65
|
+
|
|
66
|
+
**`--live` (macOS, cross‑references the system):**
|
|
67
|
+
|
|
68
|
+
- Default route + gateway, friendly hardware‑port names, per‑interface DNS and
|
|
69
|
+
search domains, DHCP server + lease, traffic counters (bytes + packets) and
|
|
70
|
+
error/collision counts.
|
|
71
|
+
|
|
72
|
+
**`--check`:** pings the default gateway and reports latency.
|
|
73
|
+
|
|
74
|
+
**1‑hour bandwidth graph:** schedule the recorder, then a Unicode sparkline of
|
|
75
|
+
the last hour appears automatically:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
# once, e.g. in crontab -e
|
|
79
|
+
* * * * * /usr/local/bin/ifpretty --record
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
1h in ▁▁▁▂▄█▅▂▁▁▁▁ peak 10.0 Mbps
|
|
84
|
+
1h out ▁▂▂▄▆█▄▂▂▁▁▁ peak 533.3 Kbps
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
History is stored at `~/.cache/ifpretty/samples.csv` (override with
|
|
88
|
+
`IFPRETTY_HISTORY`).
|
|
89
|
+
|
|
90
|
+
## Notes
|
|
91
|
+
|
|
92
|
+
- Colors auto‑disable when piped; force with `CLICOLOR_FORCE=1`, disable with `NO_COLOR=1`.
|
|
93
|
+
- `--live` / `--check` / `--record` use macOS tools (`route`, `netstat`,
|
|
94
|
+
`networksetup`, `scutil`, `ipconfig`, `ping`) and no‑op elsewhere.
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
ifpretty/__init__.py,sha256=PTN2KgYem_jt0VzqfftwuU6ncCRzC-Yv-2zlLpAjMDA,138
|
|
2
|
+
ifpretty/__main__.py,sha256=uZnYIf4DigA8XAM1rpBcnl_kZMNAMlS6Q_rnM4UMXCo,119
|
|
3
|
+
ifpretty/cli.py,sha256=f9LyToh9MPbxKebmeZxFB3KGuzkCuTRFVKTVgEY0_DM,41740
|
|
4
|
+
ifpretty/data/oui.tsv.gz,sha256=SsoMSzpQCkfd5iA2Gu-aGpPMzhY7gpIg0h6o1q0o9b0,383392
|
|
5
|
+
ifpretty-0.1.0.dist-info/licenses/LICENSE,sha256=BmnKRIJzpO5vnb3_d58OfVyK6hLXjgMoRoGcNHkHVsc,1061
|
|
6
|
+
ifpretty-0.1.0.dist-info/METADATA,sha256=V5AEmhmGJM43ZkRiliLYi7AeyYfDktz4O9t1pG3bTr4,3435
|
|
7
|
+
ifpretty-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
ifpretty-0.1.0.dist-info/entry_points.txt,sha256=pXnVylPg1NGTJ3tw5KhEqEuOpoAq9B6Vo2wvfDA6sOU,47
|
|
9
|
+
ifpretty-0.1.0.dist-info/top_level.txt,sha256=DQdGS2WYCG9iSqYevTVLX__L_4hcuEKfAt10Sm6Hm6M,9
|
|
10
|
+
ifpretty-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iklo
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ifpretty
|