bytesim 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.
- bytesim/__init__.py +14 -0
- bytesim/__main__.py +3 -0
- bytesim/cli.py +45 -0
- bytesim/constants.py +48 -0
- bytesim/demos.py +74 -0
- bytesim/model/__init__.py +5 -0
- bytesim/model/addressing.py +160 -0
- bytesim/model/base.py +57 -0
- bytesim/model/cloning.py +249 -0
- bytesim/model/equivalence.py +86 -0
- bytesim/model/groups.py +133 -0
- bytesim/model/ha.py +93 -0
- bytesim/model/layer2.py +106 -0
- bytesim/model/layout.py +203 -0
- bytesim/model/naming.py +114 -0
- bytesim/model/network.py +138 -0
- bytesim/model/routing.py +117 -0
- bytesim/model/stp.py +315 -0
- bytesim/model/types.py +107 -0
- bytesim/py.typed +0 -0
- bytesim/scripting.py +108 -0
- bytesim/synthesis.py +644 -0
- bytesim/topology.py +517 -0
- bytesim/tui/__init__.py +4 -0
- bytesim/tui/app.py +161 -0
- bytesim/tui/cables.py +58 -0
- bytesim/tui/canvas.py +489 -0
- bytesim/tui/command_input.py +29 -0
- bytesim/tui/commands/__init__.py +1 -0
- bytesim/tui/commands/addressing.py +235 -0
- bytesim/tui/commands/base.py +43 -0
- bytesim/tui/commands/groups.py +215 -0
- bytesim/tui/commands/history.py +134 -0
- bytesim/tui/commands/interpreter.py +85 -0
- bytesim/tui/commands/naming.py +68 -0
- bytesim/tui/commands/registry.py +28 -0
- bytesim/tui/commands/scripting.py +54 -0
- bytesim/tui/commands/switching.py +192 -0
- bytesim/tui/commands/topology.py +255 -0
- bytesim/tui/commands/traffic.py +312 -0
- bytesim/tui/help.py +50 -0
- bytesim/tui/inspector.py +115 -0
- bytesim/tui/theme.py +12 -0
- bytesim-0.1.0.dist-info/METADATA +178 -0
- bytesim-0.1.0.dist-info/RECORD +48 -0
- bytesim-0.1.0.dist-info/WHEEL +4 -0
- bytesim-0.1.0.dist-info/entry_points.txt +2 -0
- bytesim-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Addresses: keeping them in step with the cabling, DHCP, ad-hoc, static config."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from rich.markup import escape
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from ...model import Device, Iface, NetError, fmt
|
|
8
|
+
from ..inspector import mode_text
|
|
9
|
+
from .base import CommandHost
|
|
10
|
+
from .registry import command
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AddressCommands(CommandHost):
|
|
14
|
+
@command("dhcp")
|
|
15
|
+
def cmd_dhcp(self, args: list[str], opts: dict[str, str]) -> None:
|
|
16
|
+
self._need_addr_mode("dhcp")
|
|
17
|
+
if not args:
|
|
18
|
+
raise NetError("usage: dhcp <id[:nic]|all> ...")
|
|
19
|
+
for nic in self._nics(args):
|
|
20
|
+
self._dhcp_exchange(nic)
|
|
21
|
+
self.refresh_sidebar()
|
|
22
|
+
|
|
23
|
+
@command("release")
|
|
24
|
+
def cmd_release(self, args: list[str], opts: dict[str, str]) -> None:
|
|
25
|
+
if not args:
|
|
26
|
+
raise NetError("usage: release <id[:nic]|all> ...")
|
|
27
|
+
self._need_addr_mode("release")
|
|
28
|
+
for nic in self._nics(args):
|
|
29
|
+
ifc = self.net.iface(*nic)
|
|
30
|
+
if ifc.addr:
|
|
31
|
+
self.log_msg(f"[gold1]DHCP[/] {self._ifname(nic)} RELEASE {fmt(ifc.addr)}")
|
|
32
|
+
ifc.mode = "none"
|
|
33
|
+
ifc.clear()
|
|
34
|
+
self.changed()
|
|
35
|
+
|
|
36
|
+
@command("adhoc")
|
|
37
|
+
def cmd_adhoc(self, args: list[str], opts: dict[str, str]) -> None:
|
|
38
|
+
if not args:
|
|
39
|
+
raise NetError("usage: adhoc <id[:nic]|all> ...")
|
|
40
|
+
self._need_addr_mode("adhoc")
|
|
41
|
+
for nic in self._nics(args):
|
|
42
|
+
ifc = self.net.iface(*nic)
|
|
43
|
+
ifc.mode = "adhoc"
|
|
44
|
+
ifc.clear()
|
|
45
|
+
self.net.adhoc(nic)
|
|
46
|
+
self.log_msg(f"{self._ifname(nic)} → {fmt(ifc.addr)} [yellow](ad-hoc: free host "
|
|
47
|
+
f"on network {ifc.net}, no server)[/]")
|
|
48
|
+
self.changed()
|
|
49
|
+
|
|
50
|
+
@command("dhcpd")
|
|
51
|
+
def cmd_dhcpd(self, args: list[str], opts: dict[str, str]) -> None:
|
|
52
|
+
net = self.net
|
|
53
|
+
if not args:
|
|
54
|
+
for d in net.sorted_devices():
|
|
55
|
+
if d.kind in "rg":
|
|
56
|
+
self.log_msg(f"{d.id}: DHCP server {'[red]off[/]' if d.id in net.dhcp_off else '[green]on[/]'}")
|
|
57
|
+
return
|
|
58
|
+
did = net.get(self._ref(args[0])[0]).id
|
|
59
|
+
if not net.is_router(did):
|
|
60
|
+
raise NetError("only routers and gateways run a DHCP server")
|
|
61
|
+
if len(args) > 1:
|
|
62
|
+
if args[1].lower() not in ("on", "off"):
|
|
63
|
+
raise NetError("usage: dhcpd <router> [on|off]")
|
|
64
|
+
(net.dhcp_off.discard if args[1].lower() == "on" else net.dhcp_off.add)(did)
|
|
65
|
+
self.log_msg(f"{did}: DHCP server {'[red]off[/]' if did in net.dhcp_off else '[green]on[/]'}")
|
|
66
|
+
self.changed()
|
|
67
|
+
|
|
68
|
+
def reconcile(self, skip: frozenset[tuple[str, int]] = frozenset()) -> None:
|
|
69
|
+
"""Bring STP and addresses in line with the cabling, and say what changed."""
|
|
70
|
+
for note in self.net.stp_update():
|
|
71
|
+
color = "green" if note.startswith("broadcast storm over") else "cyan"
|
|
72
|
+
self.log_msg(f"[{color}]{escape(note)}[/]")
|
|
73
|
+
if self.graph:
|
|
74
|
+
return
|
|
75
|
+
notes = self.net.reconcile(skip) # STP is already current, so these are addresses
|
|
76
|
+
if len(notes) > 4:
|
|
77
|
+
self.log_msg(f"[dim]auto-config: {len(notes)} address changes (type [b]ip[/b] to list)[/]")
|
|
78
|
+
else:
|
|
79
|
+
for n in notes:
|
|
80
|
+
self.log_msg(f"[dim]{escape(n)}[/]")
|
|
81
|
+
|
|
82
|
+
def _need_addr_mode(self, what: str) -> None:
|
|
83
|
+
if self.graph:
|
|
84
|
+
raise NetError(f"{what} needs addressing: switch with `mode addr`")
|
|
85
|
+
|
|
86
|
+
def _nics(self, toks: list[str], kinds: str = "c") -> list[tuple[str, int]]:
|
|
87
|
+
"""Expand c1 / c1:2 / all into cabled NICs."""
|
|
88
|
+
net = self.net
|
|
89
|
+
out: list[tuple[str, int]] = []
|
|
90
|
+
for t in toks:
|
|
91
|
+
if t.lower() in ("all", "*"):
|
|
92
|
+
devs = [d for d in net.sorted_devices() if d.kind in kinds]
|
|
93
|
+
out += [(d.id, p) for d in devs for p in range(1, d.nports + 1)
|
|
94
|
+
if net.link_index(d.id, p) is not None]
|
|
95
|
+
continue
|
|
96
|
+
did, port = self._ref(t)
|
|
97
|
+
dev = net.get(did)
|
|
98
|
+
if dev.kind not in kinds:
|
|
99
|
+
raise NetError(f"{did} is a {dev.name}: this only applies to computers"
|
|
100
|
+
+ (" (routers serve DHCP: see dhcpd)" if dev.kind in "rg" else ""))
|
|
101
|
+
if port is not None:
|
|
102
|
+
if not 1 <= port <= dev.nports:
|
|
103
|
+
raise NetError(f"{did} has no port {port}")
|
|
104
|
+
out.append((did, port))
|
|
105
|
+
else:
|
|
106
|
+
out += [(did, p) for p in range(1, dev.nports + 1)
|
|
107
|
+
if net.link_index(did, p) is not None] or [(did, 1)]
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
def _dhcp_exchange(self, nic: tuple[str, int]) -> None:
|
|
111
|
+
"""Animated DISCOVER (broadcast) then OFFER/ACK (unicast), or ad-hoc fallback."""
|
|
112
|
+
net, c = self.net, self.canvas
|
|
113
|
+
name = self._ifname(nic)
|
|
114
|
+
ifc = net.iface(*nic)
|
|
115
|
+
if net.link_index(*nic) is None:
|
|
116
|
+
self.log_msg(f"[yellow]{name}: no cable, so no DHCP[/]")
|
|
117
|
+
return
|
|
118
|
+
ifc.mode = "dhcp"
|
|
119
|
+
mac = net.devices[nic[0]].mac(nic[1])
|
|
120
|
+
if net.storm_near(nic[0]):
|
|
121
|
+
self.log_msg(f"[b gold1]DHCP[/] {name} [red]DISCOVER lost: the links are saturated"
|
|
122
|
+
f"{self.storm_hint(nic[0])}[/]") # type: ignore[attr-defined]
|
|
123
|
+
return
|
|
124
|
+
fl = net.flood(nic)
|
|
125
|
+
if fl.storm:
|
|
126
|
+
self.log_msg(f"[b gold1]DHCP[/] {name} [dim]{mac}[/] DISCOVER → broadcast")
|
|
127
|
+
self.set_off_storm(fl, f"the DHCP DISCOVER from {name}") # type: ignore[attr-defined]
|
|
128
|
+
self.log_msg(" [red]DHCP failed: the DISCOVER drowned in the storm it caused[/]")
|
|
129
|
+
return
|
|
130
|
+
kind, server = net.lease(nic)
|
|
131
|
+
net.learn_flood(fl)
|
|
132
|
+
reached = len(fl.delivered)
|
|
133
|
+
self.log_msg(f"[b gold1]DHCP[/] {name} [dim]{mac}[/] DISCOVER → broadcast "
|
|
134
|
+
f"(reaches {reached} interface{'s' if reached != 1 else ''})")
|
|
135
|
+
|
|
136
|
+
def done(back: list[tuple[str, int]] | None) -> None:
|
|
137
|
+
if back:
|
|
138
|
+
c.cool([li for _, li in back[1:]])
|
|
139
|
+
c.flash_device(nic[0], "gold1" if server else "yellow")
|
|
140
|
+
if kind == "adhoc":
|
|
141
|
+
self.log_msg(f" [yellow]no DHCP server answered: took free address "
|
|
142
|
+
f"{fmt(ifc.addr)} on network {ifc.net} (ad-hoc)[/]")
|
|
143
|
+
else:
|
|
144
|
+
verb = "ACK (renewed)" if kind == "renew" else "OFFER → REQUEST → ACK"
|
|
145
|
+
self.log_msg(f" [gold1]{verb}[/] from {server[0]} {fmt(net.addr_of(server))}: "
|
|
146
|
+
f"network {ifc.net} → host {ifc.host}, gateway {ifc.gw}")
|
|
147
|
+
self.refresh_sidebar()
|
|
148
|
+
c.refresh()
|
|
149
|
+
|
|
150
|
+
def discovered() -> None:
|
|
151
|
+
if server is None:
|
|
152
|
+
done(None)
|
|
153
|
+
return
|
|
154
|
+
back = net.l2path(server, nic)
|
|
155
|
+
net.learn(server, back)
|
|
156
|
+
c.heat([li for _, li in back[1:]])
|
|
157
|
+
c.send_packet(self._cells(back), "green", lambda: done(back))
|
|
158
|
+
|
|
159
|
+
if not fl.generations:
|
|
160
|
+
done(None)
|
|
161
|
+
else:
|
|
162
|
+
self.animate_flood(fl, "gold1", discovered) # type: ignore[attr-defined]
|
|
163
|
+
|
|
164
|
+
@command("ip")
|
|
165
|
+
def cmd_ip(self, args: list[str], opts: dict[str, str]) -> None:
|
|
166
|
+
self._need_addr_mode("ip")
|
|
167
|
+
net = self.net
|
|
168
|
+
if not args:
|
|
169
|
+
self.log_msg("[b]DEVICE MAC NET HOST MODE GW[/]")
|
|
170
|
+
for d in net.sorted_devices():
|
|
171
|
+
for p in range(1, d.nports + 1):
|
|
172
|
+
ifc = net.ifaces.get(f"{d.id}:{p}")
|
|
173
|
+
if ifc and (ifc.addr or (d.kind == "c" and net.link_index(d.id, p) is not None)):
|
|
174
|
+
self._log_iface(d, p, ifc)
|
|
175
|
+
if not net.ifaces:
|
|
176
|
+
self.log_msg("[dim]no addresses yet: cable a computer to a router or switch[/]")
|
|
177
|
+
return
|
|
178
|
+
did, port = self._ref(args[0])
|
|
179
|
+
dev = net.get(did)
|
|
180
|
+
if dev.kind == "s":
|
|
181
|
+
raise NetError(f"{did} is a switch: layer 2 only, no addresses (its MAC table is in the inspector)")
|
|
182
|
+
if len(args) == 1:
|
|
183
|
+
for p in ([port] if port else range(1, dev.nports + 1)):
|
|
184
|
+
self._log_iface(dev, p, net.iface(did, p))
|
|
185
|
+
return
|
|
186
|
+
if port is None:
|
|
187
|
+
if dev.nports != 1:
|
|
188
|
+
raise NetError(f"{did} has {dev.nports} ports: say which, e.g. {did}:1")
|
|
189
|
+
port = 1
|
|
190
|
+
nic, ifc = (did, port), net.iface(did, port)
|
|
191
|
+
what = args[1].lower()
|
|
192
|
+
if what == "dhcp" or (what == "auto" and dev.kind == "c"):
|
|
193
|
+
if dev.kind != "c":
|
|
194
|
+
raise NetError("only computers are DHCP clients")
|
|
195
|
+
self._dhcp_exchange(nic)
|
|
196
|
+
elif what == "adhoc" and dev.kind == "c":
|
|
197
|
+
ifc.mode = "adhoc"
|
|
198
|
+
ifc.clear()
|
|
199
|
+
net.adhoc(nic)
|
|
200
|
+
self.log_msg(f"{self._ifname(nic)} → {fmt(ifc.addr)} [yellow](ad-hoc)[/]")
|
|
201
|
+
elif what == "auto":
|
|
202
|
+
ifc.mode = "auto"
|
|
203
|
+
ifc.clear()
|
|
204
|
+
self.log_msg(f"{self._ifname(nic)} back to automatic addressing")
|
|
205
|
+
elif what in ("none", "clear", "off"):
|
|
206
|
+
ifc.mode = "none"
|
|
207
|
+
ifc.clear()
|
|
208
|
+
self.log_msg(f"{self._ifname(nic)} unconfigured")
|
|
209
|
+
else:
|
|
210
|
+
usage = "usage: ip <id:port> (net,host) [gateway-host] | dhcp | adhoc | none"
|
|
211
|
+
try:
|
|
212
|
+
addr = self._parse_addr(args[1])
|
|
213
|
+
gw = int(args[2]) if len(args) > 2 else None
|
|
214
|
+
except ValueError:
|
|
215
|
+
raise NetError(usage) from None
|
|
216
|
+
if addr is None:
|
|
217
|
+
raise NetError(usage)
|
|
218
|
+
owner = net.resolve(addr)
|
|
219
|
+
if owner and owner != nic:
|
|
220
|
+
raise NetError(f"{fmt(addr)} is already used by {self._ifname(owner)} (ARP conflict)")
|
|
221
|
+
ifc.mode, ifc.via = "static", "static"
|
|
222
|
+
ifc.net, ifc.host = addr
|
|
223
|
+
ifc.gw = gw if dev.kind == "c" else None
|
|
224
|
+
self.log_msg(f"{self._ifname(nic)} → {fmt(addr)}"
|
|
225
|
+
+ (f" gateway {fmt((addr[0], gw))}" if ifc.gw else "") + " [cyan](static)[/]")
|
|
226
|
+
self.changed()
|
|
227
|
+
|
|
228
|
+
def _log_iface(self, d: Device, p: int, ifc: Iface) -> None:
|
|
229
|
+
cabled = self.net.link_index(d.id, p) is not None
|
|
230
|
+
net = str(ifc.net) if ifc.addr else "—"
|
|
231
|
+
host = str(ifc.host) if ifc.addr else "—"
|
|
232
|
+
gw = str(ifc.gw) if ifc.gw is not None else ""
|
|
233
|
+
state = "" if cabled else " [dim](no cable)[/]"
|
|
234
|
+
self.log_msg(f"[{d.color}]{d.id + ':' + d.port_name(p):<9}[/] {d.mac(p):<7} {net:>3} {host:>4} "
|
|
235
|
+
f"{mode_text(ifc):<10} {gw}{state}")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What every command mixin may rely on from the app."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from ...constants import REF
|
|
7
|
+
from ...model import NetError, Network
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..canvas import NetCanvas
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CommandHost:
|
|
14
|
+
"""Attributes and helpers the app provides to the command mixins."""
|
|
15
|
+
|
|
16
|
+
net: Network
|
|
17
|
+
auto: bool
|
|
18
|
+
graph: bool
|
|
19
|
+
replaying: int = 0 # > 0 while a script runs (hosts may batch their side effects)
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def canvas(self) -> "NetCanvas":
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
def log_msg(self, msg: str) -> None:
|
|
26
|
+
raise NotImplementedError
|
|
27
|
+
|
|
28
|
+
def changed(self, skip: frozenset[tuple[str, int]] = frozenset()) -> None:
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
def refresh_sidebar(self) -> None:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
|
|
34
|
+
def dispatch(self, toks: list[str]) -> None:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def _ref(tok: str) -> tuple[str, int | None]:
|
|
39
|
+
"""Parse a device token like ``r1`` or ``s2:5`` into (id, port)."""
|
|
40
|
+
m = REF.match(tok)
|
|
41
|
+
if not m:
|
|
42
|
+
raise NetError(f"not a device id: {tok!r} (expected something like r1 or s2:5)")
|
|
43
|
+
return f"{m[1].lower()}{int(m[2])}", int(m[3]) if m[3] else None
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Groups (nestable) and HA clusters."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from ...constants import POLICIES, VIP_HOST
|
|
5
|
+
from ...model import NetError, fmt
|
|
6
|
+
from ..theme import HA_COLOR, group_color
|
|
7
|
+
from .base import CommandHost
|
|
8
|
+
from .registry import command
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _call(tok: str) -> tuple[str, list[str] | None]:
|
|
12
|
+
"""'link2(rfab2,rfab3)' → ('link2', ['rfab2', 'rfab3']); 'link(1)' stays an auto name."""
|
|
13
|
+
if not tok.endswith(")") or "(" not in tok:
|
|
14
|
+
return tok, None
|
|
15
|
+
at = tok.rindex("(")
|
|
16
|
+
name, inner = tok[:at], tok[at + 1:-1]
|
|
17
|
+
if inner.isdigit() and "(" not in name: # fab(1): auto-numbering, not endpoints
|
|
18
|
+
return tok, None
|
|
19
|
+
ends = [e for e in inner.split(",") if e]
|
|
20
|
+
if not ends or not name:
|
|
21
|
+
raise NetError(f"{tok}: expected name(endpoint, …)")
|
|
22
|
+
return name, [e.lower() for e in ends]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GroupCommands(CommandHost):
|
|
26
|
+
@command("group")
|
|
27
|
+
def cmd_group(self, args: list[str], opts: dict[str, str]) -> None:
|
|
28
|
+
net = self.net
|
|
29
|
+
if not args:
|
|
30
|
+
self.dispatch(["groups"])
|
|
31
|
+
return
|
|
32
|
+
name = args[0].lower()
|
|
33
|
+
if len(args) == 1:
|
|
34
|
+
if name not in net.groups:
|
|
35
|
+
raise NetError(f"no such group: {name} (create one with group {name} <ids...>)")
|
|
36
|
+
self.canvas.select_group(name)
|
|
37
|
+
self.canvas.focus()
|
|
38
|
+
return
|
|
39
|
+
name = net.resolve_group_name(name) # fab(1) → the next free fabN
|
|
40
|
+
moved = net.group_add(name, self._members(args[1:]))
|
|
41
|
+
if moved:
|
|
42
|
+
self.log_msg(f"[dim]moved {' '.join(moved)} out of their old group[/]")
|
|
43
|
+
self.log_msg(f"[{group_color(name)}]▣ group {name}[/]: {self._group_text(name)}")
|
|
44
|
+
self.changed()
|
|
45
|
+
|
|
46
|
+
@command("clone", "copy", "dup")
|
|
47
|
+
def cmd_clone(self, args: list[str], opts: dict[str, str]) -> None:
|
|
48
|
+
net = self.net
|
|
49
|
+
usage = ("usage: clone <group> [new-name] · clone <group>(old, …) <new-name>(new, …)\n"
|
|
50
|
+
" e.g. copy link1(rfab1, rfab2) link2(rfab2, rfab3) creates link2 cabled to rfab2 and rfab3 "
|
|
51
|
+
"the way link1 is cabled to rfab1 and rfab2")
|
|
52
|
+
if not 1 <= len(args) <= 2:
|
|
53
|
+
raise NetError(usage)
|
|
54
|
+
name, old_ends = _call(args[0])
|
|
55
|
+
new_tok, new_ends = _call(args[1]) if len(args) == 2 else (None, None)
|
|
56
|
+
name = name.lower()
|
|
57
|
+
if name not in net.groups:
|
|
58
|
+
raise NetError(f"no such group: {name}\n{usage}")
|
|
59
|
+
if (old_ends is None) != (new_ends is None):
|
|
60
|
+
raise NetError("give the endpoints on both sides: group(old, …) new(new, …)")
|
|
61
|
+
if old_ends is not None and len(old_ends) != len(new_ends):
|
|
62
|
+
raise NetError(f"{len(old_ends)} old endpoints but {len(new_ends)} new ones: they're paired in order")
|
|
63
|
+
dx = 0
|
|
64
|
+
if not self.auto: # manual layout: put the copy just right of the original
|
|
65
|
+
x0, _, x1, _ = net.group_bounds(name)
|
|
66
|
+
dx = x1 - x0 + 3
|
|
67
|
+
new_name = net.resolve_group_name(new_tok) if new_tok else None
|
|
68
|
+
res = net.clone_group(name, new_name, dx=dx, rewire=list(zip(old_ends or [], new_ends or [])))
|
|
69
|
+
devs = " ".join(f"{o}→{n}" for o, n in res.devices.items())
|
|
70
|
+
subs = [f"{o}→{n}" for o, n in res.groups.items() if o != name]
|
|
71
|
+
self.log_msg(f"[{group_color(res.group)}]▣ cloned {name} → {res.group}[/]: {len(res.devices)} devices, "
|
|
72
|
+
f"{res.cables} internal cable{'s' if res.cables != 1 else ''}"
|
|
73
|
+
+ (f", {res.rewired} rewired to {' '.join(new_ends or [])}" if res.rewired else "")
|
|
74
|
+
+ (f" [dim]({res.skipped} to the outside not copied)[/]" if res.skipped else ""))
|
|
75
|
+
if res.moved:
|
|
76
|
+
self.log_msg(f" [dim]ports already taken, used the next free: {' '.join(res.moved)}[/]")
|
|
77
|
+
self.log_msg(f" [dim]{devs}{' · groups ' + ' '.join(subs) if subs else ''}[/]")
|
|
78
|
+
self.changed()
|
|
79
|
+
self.canvas.select_group(res.group)
|
|
80
|
+
|
|
81
|
+
def copy_cabling(self, args: list[str]) -> None:
|
|
82
|
+
"""ports copy link1(rfab1, rfab2) link2(rfab2, rfab3)"""
|
|
83
|
+
net = self.net
|
|
84
|
+
usage = ("usage: ports copy <group>(old, …) <group>(new, …)\n"
|
|
85
|
+
" e.g. ports copy link1(rfab1, rfab2) link2(rfab2, rfab3): cable link2 to rfab2 and rfab3 "
|
|
86
|
+
"the way link1 is cabled to rfab1 and rfab2")
|
|
87
|
+
if len(args) != 2:
|
|
88
|
+
raise NetError(usage)
|
|
89
|
+
(src, old_ends), (dst, new_ends) = _call(args[0]), _call(args[1])
|
|
90
|
+
if old_ends is None or new_ends is None:
|
|
91
|
+
raise NetError(f"give the endpoints on both sides\n{usage}")
|
|
92
|
+
if len(old_ends) != len(new_ends):
|
|
93
|
+
raise NetError(f"{len(old_ends)} old endpoints but {len(new_ends)} new ones: they're paired in order")
|
|
94
|
+
src, dst = src.lower(), dst.lower()
|
|
95
|
+
if dst not in net.groups and src in net.groups:
|
|
96
|
+
raise NetError(f"no group {dst}: `ports copy` only cables existing devices. To create {dst} "
|
|
97
|
+
f"with this cabling: copy {args[0]} {args[1]}")
|
|
98
|
+
res = net.copy_cabling(src, dst, list(zip(old_ends, new_ends)))
|
|
99
|
+
pairs = ", ".join(f"{o}→{n}" for o, n in zip(old_ends, new_ends))
|
|
100
|
+
if res.made:
|
|
101
|
+
self.log_msg(f"[green]⇄[/] {dst} now cabled like {src} [dim]({pairs})[/]: "
|
|
102
|
+
f"{res.made} cable{'s' if res.made != 1 else ''} made"
|
|
103
|
+
+ (f", {res.already} already there" if res.already else ""))
|
|
104
|
+
else:
|
|
105
|
+
self.log_msg(f"{dst} is already cabled like {src} ({pairs}): nothing to do")
|
|
106
|
+
if res.moved:
|
|
107
|
+
self.log_msg(f" [dim]ports already taken, used the next free: {' '.join(res.moved)}[/]")
|
|
108
|
+
self.changed()
|
|
109
|
+
|
|
110
|
+
@command("ungroup")
|
|
111
|
+
def cmd_ungroup(self, args: list[str], opts: dict[str, str]) -> None:
|
|
112
|
+
net = self.net
|
|
113
|
+
if not args:
|
|
114
|
+
raise NetError("usage: ungroup <group> [member ...]")
|
|
115
|
+
name = args[0].lower()
|
|
116
|
+
parent = net.parent_of(name)
|
|
117
|
+
n = net.ungroup(name, self._members(args[1:]) or None)
|
|
118
|
+
where = f"into {parent}" if parent else "to the top level"
|
|
119
|
+
if name not in net.groups:
|
|
120
|
+
self.log_msg(f"[yellow]▢[/] dissolved {name}: {n} member{'s' if n != 1 else ''} moved {where}")
|
|
121
|
+
else:
|
|
122
|
+
self.log_msg(f"[yellow]▢[/] took {n} out of {name}, {where}")
|
|
123
|
+
self.changed()
|
|
124
|
+
|
|
125
|
+
@command("groups")
|
|
126
|
+
def cmd_groups(self, args: list[str], opts: dict[str, str]) -> None:
|
|
127
|
+
net = self.net
|
|
128
|
+
|
|
129
|
+
def tree(g: str, depth: int) -> None:
|
|
130
|
+
devs = [m for m in net.groups[g] if m not in net.groups]
|
|
131
|
+
tag = " [dim](HA cluster)[/]" if g in net.clusters else ""
|
|
132
|
+
self.log_msg(f"{' ' * depth}[{group_color(g)}]▣ {g}[/]{tag} {' '.join(devs)}")
|
|
133
|
+
for sub in net.subgroups(g):
|
|
134
|
+
tree(sub, depth + 1)
|
|
135
|
+
|
|
136
|
+
for g in net.groups:
|
|
137
|
+
if net.parent_of(g) is None:
|
|
138
|
+
tree(g, 0)
|
|
139
|
+
if not net.groups:
|
|
140
|
+
self.log_msg("[dim]no groups — try: group fab1 s1 s2 s3[/]")
|
|
141
|
+
|
|
142
|
+
@command("uncluster")
|
|
143
|
+
def cmd_uncluster(self, args: list[str], opts: dict[str, str]) -> None:
|
|
144
|
+
net = self.net
|
|
145
|
+
if not args or args[0].lower() not in net.clusters:
|
|
146
|
+
raise NetError("usage: uncluster <cluster> (the routers stay grouped)")
|
|
147
|
+
del net.clusters[args[0].lower()]
|
|
148
|
+
self.log_msg(f"{args[0].lower()} is a plain group again")
|
|
149
|
+
self.changed()
|
|
150
|
+
|
|
151
|
+
@command("clusters")
|
|
152
|
+
def cmd_clusters(self, args: list[str], opts: dict[str, str]) -> None:
|
|
153
|
+
net = self.net
|
|
154
|
+
for c in net.clusters:
|
|
155
|
+
st = net.ha_state(c)
|
|
156
|
+
self.log_msg(f"[{HA_COLOR[st['level']]}]{c:<8}[/] {' '.join(st['members'])} {st['text']}")
|
|
157
|
+
if not net.clusters:
|
|
158
|
+
self.log_msg("[dim]no clusters: try cluster rfab1 r1 r2[/]")
|
|
159
|
+
|
|
160
|
+
def _members(self, toks: list[str]) -> list[str]:
|
|
161
|
+
"""Group-command tokens: existing group names stay names, the rest are device ids."""
|
|
162
|
+
return [t.lower() if t.lower() in self.net.groups else self._ref(t)[0] for t in toks]
|
|
163
|
+
|
|
164
|
+
def _group_text(self, g: str) -> str:
|
|
165
|
+
"""Members, with subgroups shown as name[device count]."""
|
|
166
|
+
net = self.net
|
|
167
|
+
return " ".join(f"[{group_color(m)}]{m}[/]\\[{len(net.devices_in(m))}]" if m in net.groups else m
|
|
168
|
+
for m in net.groups[g])
|
|
169
|
+
|
|
170
|
+
@command("cluster")
|
|
171
|
+
def cmd_cluster(self, args: list[str], opts: dict[str, str]) -> None:
|
|
172
|
+
net = self.net
|
|
173
|
+
if not args:
|
|
174
|
+
self.dispatch(["clusters"])
|
|
175
|
+
return
|
|
176
|
+
name = args[0].lower()
|
|
177
|
+
policy = opts.get("policy")
|
|
178
|
+
if policy is not None and policy not in POLICIES:
|
|
179
|
+
raise NetError(f"policy must be one of: {', '.join(POLICIES)}")
|
|
180
|
+
if len(args) > 1:
|
|
181
|
+
ids = [net.get(self._ref(t)[0]).id for t in args[1:]]
|
|
182
|
+
bad = [d for d in ids if not net.is_router(d)]
|
|
183
|
+
if bad:
|
|
184
|
+
raise NetError(f"only routers and gateways can be clustered: {' '.join(bad)}")
|
|
185
|
+
net.group_add(name, ids)
|
|
186
|
+
net.clusters[name] = policy or net.clusters.get(name, "none")
|
|
187
|
+
self.log_msg(f"[{group_color(name)}]▣ cluster {name}[/]: {' '.join(net.groups[name])} "
|
|
188
|
+
f"[dim](policy {net.clusters[name]}; virtual gateway host {VIP_HOST} on each network)[/]")
|
|
189
|
+
elif name not in net.clusters:
|
|
190
|
+
raise NetError(f"no such cluster: {name} (create: cluster {name} r1 r2)")
|
|
191
|
+
elif policy:
|
|
192
|
+
net.clusters[name] = policy
|
|
193
|
+
self.log_msg(f"{name}: split policy → {policy}")
|
|
194
|
+
else:
|
|
195
|
+
st = net.ha_state(name)
|
|
196
|
+
self.log_msg(f"[{HA_COLOR[st['level']]}]{name}: {st['text']}[/]")
|
|
197
|
+
for netid, owners in sorted(st["owners"].items()):
|
|
198
|
+
self.log_msg(f" {fmt((netid, VIP_HOST))} → {' and '.join(o[0] for o in owners)}")
|
|
199
|
+
return
|
|
200
|
+
self.changed()
|
|
201
|
+
|
|
202
|
+
def _ha_report(self) -> None:
|
|
203
|
+
"""Log cluster state changes (failover, split-brain, recovery) as they happen."""
|
|
204
|
+
seen = getattr(self, "_ha_seen", {})
|
|
205
|
+
now = {}
|
|
206
|
+
for c in self.net.clusters:
|
|
207
|
+
st = self.net.ha_state(c)
|
|
208
|
+
owners = " ".join(f"{fmt((n, VIP_HOST))}→{'+'.join(o[0] for o in os)}"
|
|
209
|
+
for n, os in sorted(st["owners"].items()))
|
|
210
|
+
now[c] = (st["level"], st["text"], owners)
|
|
211
|
+
if seen.get(c) != now[c]:
|
|
212
|
+
icon = {"ok": "✓", "degraded": "▲", "split": "⚡", "down": "✕"}[st["level"]]
|
|
213
|
+
self.log_msg(f"[{HA_COLOR[st['level']]}]{icon} {c}: {st['text']}[/]"
|
|
214
|
+
+ (f"\n [dim]virtual gateways: {owners}[/]" if owners else ""))
|
|
215
|
+
self._ha_seen = now
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Multi-level undo and redo.
|
|
2
|
+
|
|
3
|
+
Every command that changes the network is one step: a whole ``run`` script,
|
|
4
|
+
``clear`` and ``load`` included. A burst of moves (arrow keys, or one drag) is
|
|
5
|
+
one step too. Read-only commands don't create steps, and neither does traffic
|
|
6
|
+
that only teaches switches MAC addresses. A storm it sets off does count.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import copy
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from rich.markup import escape
|
|
15
|
+
|
|
16
|
+
from ...model import NetError, Network
|
|
17
|
+
from .base import CommandHost
|
|
18
|
+
from .registry import command
|
|
19
|
+
|
|
20
|
+
UNDO_LIMIT = 100
|
|
21
|
+
MOVE_BURST = 2.0 # seconds: moves closer together than this are one step
|
|
22
|
+
NOT_RECORDED = {"undo", "redo", "history"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Step:
|
|
27
|
+
label: str
|
|
28
|
+
net: Network
|
|
29
|
+
auto: bool
|
|
30
|
+
selection: tuple[str | None, str | None]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fingerprint(net: Network) -> str:
|
|
34
|
+
"""What counts as a change: everything saved, plus storms (not MAC tables)."""
|
|
35
|
+
return net.to_json() + "|" + ",".join(sorted(net.storming))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HistoryCommands(CommandHost):
|
|
39
|
+
@property
|
|
40
|
+
def undo_stack(self) -> list[Step]:
|
|
41
|
+
return self.__dict__.setdefault("_undo", [])
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def redo_stack(self) -> list[Step]:
|
|
45
|
+
return self.__dict__.setdefault("_redo", [])
|
|
46
|
+
|
|
47
|
+
def snapshot(self, label: str = "") -> Step:
|
|
48
|
+
c = self.canvas
|
|
49
|
+
return Step(label, copy.deepcopy(self.net), self.auto, (c.selected, c.selected_group))
|
|
50
|
+
|
|
51
|
+
def record(self, step: Step) -> None:
|
|
52
|
+
"""Push a 'before' state; a new change makes the redo stack meaningless."""
|
|
53
|
+
self.undo_stack.append(step)
|
|
54
|
+
del self.undo_stack[:-UNDO_LIMIT]
|
|
55
|
+
self.redo_stack.clear()
|
|
56
|
+
self.__dict__["_last_move"] = None
|
|
57
|
+
|
|
58
|
+
def recorded(self, label: str, action) -> None:
|
|
59
|
+
"""Run an action, keeping one undo step if it changed anything."""
|
|
60
|
+
words = label.split()
|
|
61
|
+
if not words or words[0].lower() in NOT_RECORDED:
|
|
62
|
+
action()
|
|
63
|
+
return
|
|
64
|
+
before, mark = self.snapshot(label), fingerprint(self.net)
|
|
65
|
+
try:
|
|
66
|
+
action()
|
|
67
|
+
finally:
|
|
68
|
+
if fingerprint(self.net) != mark:
|
|
69
|
+
self.record(before)
|
|
70
|
+
|
|
71
|
+
def before_move(self, what: str) -> None:
|
|
72
|
+
"""Called just before devices move: one step per burst of moves of the same thing."""
|
|
73
|
+
last = self.__dict__.get("_last_move")
|
|
74
|
+
now = time.monotonic()
|
|
75
|
+
if last and last[0] == what and now - last[1] < MOVE_BURST:
|
|
76
|
+
self.__dict__["_last_move"] = (what, now)
|
|
77
|
+
return
|
|
78
|
+
self.record(self.snapshot(f"move {what}"))
|
|
79
|
+
self.__dict__["_last_move"] = (what, now)
|
|
80
|
+
|
|
81
|
+
def _restore(self, step: Step) -> None:
|
|
82
|
+
self.net = step.net
|
|
83
|
+
self.auto = step.auto
|
|
84
|
+
c = self.canvas
|
|
85
|
+
c.reset()
|
|
86
|
+
sel, group = step.selection
|
|
87
|
+
if sel in self.net.devices:
|
|
88
|
+
c.selected = sel
|
|
89
|
+
elif group in self.net.groups:
|
|
90
|
+
c.selected_group = group
|
|
91
|
+
self.__dict__["_last_move"] = None
|
|
92
|
+
self.changed()
|
|
93
|
+
|
|
94
|
+
def _travel(self, args: list[str], back: bool) -> None:
|
|
95
|
+
word = "undo" if back else "redo"
|
|
96
|
+
try:
|
|
97
|
+
n = int(args[0]) if args else 1
|
|
98
|
+
except ValueError:
|
|
99
|
+
raise NetError(f"usage: {word} [steps]") from None
|
|
100
|
+
source, target = (self.undo_stack, self.redo_stack) if back else (self.redo_stack, self.undo_stack)
|
|
101
|
+
if not source:
|
|
102
|
+
raise NetError(f"nothing to {word}")
|
|
103
|
+
labels = []
|
|
104
|
+
for _ in range(max(1, n)):
|
|
105
|
+
if not source:
|
|
106
|
+
break
|
|
107
|
+
step = source.pop()
|
|
108
|
+
target.append(self.snapshot(step.label))
|
|
109
|
+
labels.append(step.label)
|
|
110
|
+
self._restore(step)
|
|
111
|
+
arrow = "↶ undid" if back else "↷ redid"
|
|
112
|
+
self.log_msg(f"[cyan]{arrow}[/] " + " · ".join(escape(x) for x in labels)
|
|
113
|
+
+ f" [dim]({len(self.undo_stack)} to undo, {len(self.redo_stack)} to redo)[/]")
|
|
114
|
+
|
|
115
|
+
@command("undo")
|
|
116
|
+
def cmd_undo(self, args: list[str], opts: dict[str, str]) -> None:
|
|
117
|
+
self._travel(args, back=True)
|
|
118
|
+
|
|
119
|
+
@command("redo")
|
|
120
|
+
def cmd_redo(self, args: list[str], opts: dict[str, str]) -> None:
|
|
121
|
+
self._travel(args, back=False)
|
|
122
|
+
|
|
123
|
+
@command("history")
|
|
124
|
+
def cmd_history(self, args: list[str], opts: dict[str, str]) -> None:
|
|
125
|
+
if not self.undo_stack and not self.redo_stack:
|
|
126
|
+
self.log_msg("[dim]nothing to undo yet[/]")
|
|
127
|
+
return
|
|
128
|
+
for i, step in enumerate(reversed(self.undo_stack[-15:]), 1):
|
|
129
|
+
self.log_msg(f" {i:>2} {escape(step.label)}" + (" [dim]← undo goes back past this[/]" if i == 1 else ""))
|
|
130
|
+
if len(self.undo_stack) > 15:
|
|
131
|
+
self.log_msg(f" [dim]… {len(self.undo_stack) - 15} older[/]")
|
|
132
|
+
if self.redo_stack:
|
|
133
|
+
self.log_msg(f" [dim]{len(self.redo_stack)} undone step(s) can be redone: "
|
|
134
|
+
f"{escape(self.redo_stack[-1].label)} next[/]")
|