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.
Files changed (48) hide show
  1. bytesim/__init__.py +14 -0
  2. bytesim/__main__.py +3 -0
  3. bytesim/cli.py +45 -0
  4. bytesim/constants.py +48 -0
  5. bytesim/demos.py +74 -0
  6. bytesim/model/__init__.py +5 -0
  7. bytesim/model/addressing.py +160 -0
  8. bytesim/model/base.py +57 -0
  9. bytesim/model/cloning.py +249 -0
  10. bytesim/model/equivalence.py +86 -0
  11. bytesim/model/groups.py +133 -0
  12. bytesim/model/ha.py +93 -0
  13. bytesim/model/layer2.py +106 -0
  14. bytesim/model/layout.py +203 -0
  15. bytesim/model/naming.py +114 -0
  16. bytesim/model/network.py +138 -0
  17. bytesim/model/routing.py +117 -0
  18. bytesim/model/stp.py +315 -0
  19. bytesim/model/types.py +107 -0
  20. bytesim/py.typed +0 -0
  21. bytesim/scripting.py +108 -0
  22. bytesim/synthesis.py +644 -0
  23. bytesim/topology.py +517 -0
  24. bytesim/tui/__init__.py +4 -0
  25. bytesim/tui/app.py +161 -0
  26. bytesim/tui/cables.py +58 -0
  27. bytesim/tui/canvas.py +489 -0
  28. bytesim/tui/command_input.py +29 -0
  29. bytesim/tui/commands/__init__.py +1 -0
  30. bytesim/tui/commands/addressing.py +235 -0
  31. bytesim/tui/commands/base.py +43 -0
  32. bytesim/tui/commands/groups.py +215 -0
  33. bytesim/tui/commands/history.py +134 -0
  34. bytesim/tui/commands/interpreter.py +85 -0
  35. bytesim/tui/commands/naming.py +68 -0
  36. bytesim/tui/commands/registry.py +28 -0
  37. bytesim/tui/commands/scripting.py +54 -0
  38. bytesim/tui/commands/switching.py +192 -0
  39. bytesim/tui/commands/topology.py +255 -0
  40. bytesim/tui/commands/traffic.py +312 -0
  41. bytesim/tui/help.py +50 -0
  42. bytesim/tui/inspector.py +115 -0
  43. bytesim/tui/theme.py +12 -0
  44. bytesim-0.1.0.dist-info/METADATA +178 -0
  45. bytesim-0.1.0.dist-info/RECORD +48 -0
  46. bytesim-0.1.0.dist-info/WHEEL +4 -0
  47. bytesim-0.1.0.dist-info/entry_points.txt +2 -0
  48. 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[/]")