gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/ui/inspector.py ADDED
@@ -0,0 +1,1055 @@
1
+ """Inspector — design properties + compiler addressing + live runtime state.
2
+
3
+ Properties stay editable. Interfaces/Routes show the IP/MAC/subnet/gateway the
4
+ compiler assigns (so addressing is visible before anything runs). The Live tab queries
5
+ the element's control socket when the topology is running. A "Log in" button opens the
6
+ device's terminal/console.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+
12
+ from PySide6.QtCore import Qt, QTimer, Signal
13
+ from PySide6.QtWidgets import (
14
+ QComboBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QPlainTextEdit, QPushButton,
15
+ QScrollArea, QSlider, QTabWidget, QVBoxLayout, QWidget,
16
+ )
17
+
18
+ from ..agent.api import GiniAPI
19
+ from ..app import AppContext
20
+ from .theme import ThemeManager, icons
21
+ from .theme.manager import scale_css as _scss
22
+
23
+
24
+ _FUNCTION_STARTER = (
25
+ "def handle(event, context):\n"
26
+ " # event: method, path, query, headers, body, source\n"
27
+ " # context: function_name, invocation_id, remaining_ms\n"
28
+ " name = event.get(\"body\") or \"world\"\n"
29
+ " return {\"statusCode\": 200, \"body\": f\"Hello, {name}!\"}\n"
30
+ )
31
+
32
+
33
+ def _optnum(v):
34
+ """A metric value -> float, or None (so the chart line skips missing samples)."""
35
+ if v is None:
36
+ return None
37
+ try:
38
+ return float(v)
39
+ except (TypeError, ValueError):
40
+ return None
41
+
42
+
43
+ class Inspector(QWidget):
44
+ live_ready = Signal(str)
45
+ metrics_ready = Signal(object) # (cpu%, mem MiB, KB/s, latency) tuple
46
+
47
+ def __init__(self, ctx: AppContext, api: GiniAPI, theme: ThemeManager) -> None:
48
+ super().__init__()
49
+ self.setObjectName("Inspector")
50
+ self.ctx = ctx
51
+ self.api = api
52
+ self.theme = theme
53
+ self._device_id: str | None = None
54
+ self.query_fn = None # set by MainWindow: (device_name, command) -> str
55
+ # set by MainWindow: () -> the relay's view of every real GINI32 board.
56
+ # A board has no container to exec into, so its Live tab is served from here.
57
+ self.board_status_fn = None
58
+ # set by MainWindow: (action, board_name) -> bool. claim/release/blink.
59
+ self.board_action_fn = None
60
+
61
+ root = QVBoxLayout(self)
62
+ root.setContentsMargins(0, 0, 0, 0)
63
+
64
+ # header
65
+ header = QWidget()
66
+ hl = QVBoxLayout(header)
67
+ hl.setContentsMargins(14, 12, 14, 8)
68
+ top = QHBoxLayout()
69
+ self.icon_lbl = QLabel()
70
+ top.addWidget(self.icon_lbl)
71
+ top.addStretch(1)
72
+ self.login_btn = QPushButton(" Log in")
73
+ self.login_btn.setObjectName("Accent")
74
+ self.login_btn.setIcon(icons.icon("link", "#ffffff", 14))
75
+ self.login_btn.clicked.connect(self._login)
76
+ self.login_btn.hide()
77
+ top.addWidget(self.login_btn)
78
+ hl.addLayout(top)
79
+ self.name_lbl = QLabel("No selection")
80
+ self.name_lbl.setStyleSheet(_scss("font-size:15px; font-weight:600;"))
81
+ self.type_lbl = QLabel("Select a device to edit it")
82
+ self.type_lbl.setObjectName("Faint")
83
+ hl.addWidget(self.name_lbl)
84
+ hl.addWidget(self.type_lbl)
85
+ # "what does this actually run?" — the backing image + tools, so users don't guess
86
+ self.runs_lbl = QLabel("")
87
+ self.runs_lbl.setObjectName("Muted")
88
+ self.runs_lbl.setWordWrap(True)
89
+ self.runs_lbl.setVisible(False)
90
+ hl.addWidget(self.runs_lbl)
91
+ root.addWidget(header)
92
+
93
+ self.tabs = QTabWidget()
94
+ # properties
95
+ self.props_host = QWidget()
96
+ self.props_form = QFormLayout(self.props_host)
97
+ self.props_form.setContentsMargins(14, 10, 14, 14)
98
+ self.props_form.setSpacing(8)
99
+ ps = QScrollArea(); ps.setWidgetResizable(True); ps.setWidget(self.props_host)
100
+ # interfaces (a rebuilt widget so IPs can be edited in manual mode) / routes / live
101
+ self.ifaces_host = QWidget()
102
+ self.ifaces_lay = QVBoxLayout(self.ifaces_host)
103
+ self.ifaces_lay.setContentsMargins(14, 12, 14, 12)
104
+ self.ifaces_lay.setSpacing(8)
105
+ self.ifaces_lay.setAlignment(Qt.AlignTop)
106
+ self._ifaces_scroll = QScrollArea()
107
+ self._ifaces_scroll.setWidgetResizable(True)
108
+ self._ifaces_scroll.setWidget(self.ifaces_host)
109
+ self.routes = self._scroll_label()
110
+ self.live = QPlainTextEdit(); self.live.setReadOnly(True)
111
+ self.live.setObjectName("Console")
112
+ from .live_metrics import LiveMetrics
113
+ self.metrics = LiveMetrics(self.theme) # real-time CPU/mem plots for containers
114
+ self.metrics.setVisible(False)
115
+ self._live_host = QWidget(); lv = QVBoxLayout(self._live_host)
116
+ lv.setContentsMargins(12, 10, 12, 12)
117
+ self.refresh_btn = QPushButton("Refresh live state")
118
+ self.refresh_btn.clicked.connect(self._refresh_live)
119
+ lv.addWidget(self.refresh_btn)
120
+ lv.addWidget(self.metrics, 1)
121
+ self.kpis_lbl = QLabel("") # cloud-fabric app KPIs (req/s, queue, …)
122
+ self.kpis_lbl.setObjectName("DashChips")
123
+ self.kpis_lbl.setTextFormat(Qt.RichText)
124
+ self.kpis_lbl.setWordWrap(True)
125
+ self.kpis_lbl.setVisible(False)
126
+ lv.addWidget(self.kpis_lbl)
127
+ lv.addWidget(self.live, 1)
128
+ live_host = self._live_host
129
+
130
+ self.tabs.addTab(ps, "Properties")
131
+ self.tabs.addTab(self._ifaces_scroll, "Interfaces")
132
+ self.tabs.addTab(self._wrap(self.routes), "Routes")
133
+ self.tabs.addTab(live_host, "Live")
134
+ root.addWidget(self.tabs, 1)
135
+
136
+ ctx.bus.selection_changed.connect(self._on_select)
137
+ ctx.bus.device_changed.connect(self._on_changed)
138
+ ctx.bus.addressing_changed.connect(self._rebuild)
139
+ # The board panel reads hardware state ONCE, when it is built. Without this the
140
+ # Inspector kept describing a board that had been unplugged — claim, MAC and all
141
+ # — which is the most misleading thing on the screen, because every other pane
142
+ # had already noticed it was gone.
143
+ ctx.bus.boards_changed.connect(self._on_boards_changed)
144
+ ctx.bus.function_invoke_result.connect(self._on_invoke_result)
145
+ ctx.bus.rider_ran.connect(self._on_rider_ran)
146
+ self._invoke_result = None # the Function Invoke panel's result widget
147
+ self._invoke_btn = None # the Invoke button (enabled only while running)
148
+ self._deploy_btn = None # the Deploy button (enabled only while running)
149
+ self.live_ready.connect(self.live.setPlainText)
150
+
151
+ # real-time metrics: stats_fn(name) -> {"cpu","mem_used"} | None (set by MainWindow)
152
+ from PySide6.QtCore import QTimer
153
+ self.stats_fn = None
154
+ self.stats_all_fn = None # () -> {svc: {cpu,mem_used,net_bytes}}
155
+ self._live_running = False
156
+ self._polling = False
157
+ self._fabric: dict = {} # latest cloud-fabric snapshot
158
+ self._k8s: dict = {} # latest kubernetes metrics snapshot
159
+ self._live_metrics_on = False
160
+ self._hist: dict = {} # svc -> {cpu,mem,thru,lat: deque} per element
161
+ self._prev_net: dict = {} # svc -> (net_bytes, monotonic) for KB/s
162
+ self._live_timer = QTimer(self)
163
+ self._live_timer.setInterval(1500)
164
+ self._live_timer.timeout.connect(self._poll_metrics)
165
+ self.metrics_ready.connect(self._on_metrics)
166
+ self.tabs.currentChanged.connect(lambda _i: self._update_live_mode())
167
+
168
+ # helpers --------------------------------------------------------------- #
169
+ @staticmethod
170
+ def _scroll_label() -> QLabel:
171
+ lbl = QLabel("—")
172
+ lbl.setWordWrap(True)
173
+ lbl.setAlignment(Qt.AlignTop)
174
+ lbl.setTextFormat(Qt.RichText)
175
+ lbl.setContentsMargins(14, 12, 14, 12)
176
+ return lbl
177
+
178
+ @staticmethod
179
+ def _wrap(label: QLabel) -> QScrollArea:
180
+ s = QScrollArea(); s.setWidgetResizable(True); s.setWidget(label)
181
+ return s
182
+
183
+ def _login(self) -> None:
184
+ if self._device_id:
185
+ self.ctx.bus.device_activated.emit(self._device_id)
186
+
187
+ def _login_allowed(self) -> bool:
188
+ """Log in needs the lab running — except a Router, whose Router Lab opens offline."""
189
+ from ..services.compiler import _role
190
+ d = self.ctx.topology.devices.get(self._device_id)
191
+ # A GINI32 is a board on a desk, not a container: there is no shell to open.
192
+ # Offering one would promise something that cannot exist.
193
+ if d is not None and d.type_key == "gini32":
194
+ return False
195
+ if self._live_running:
196
+ return True
197
+ return d is not None and _role(d.type_key) == "router"
198
+
199
+ def _login_hint(self) -> str:
200
+ if self._login_allowed():
201
+ return ""
202
+ d = self.ctx.topology.devices.get(self._device_id)
203
+ if d is not None and d.type_key == "gini32":
204
+ return ("This is a real board, not a container — there is no shell to open.\n"
205
+ "Use its serial console (`gini32 monitor`), or the Live tab here.")
206
+ return "Run the topology first"
207
+
208
+ def _hide_container_tabs(self, d) -> None:
209
+ """Interfaces and Routes describe an emulated node's stack. A GINI32 has
210
+ neither — its addressing lives in the Live tab, reported by the hardware."""
211
+ board = d is not None and d.type_key == "gini32"
212
+ for i in range(self.tabs.count()):
213
+ if self.tabs.tabText(i) in ("Interfaces", "Routes"):
214
+ self.tabs.setTabVisible(i, not board)
215
+
216
+ # selection ------------------------------------------------------------- #
217
+ def _on_select(self, device_id) -> None:
218
+ self._device_id = device_id
219
+ self._rebuild()
220
+ self._update_live_mode()
221
+
222
+ def _on_changed(self, device_id) -> None:
223
+ # Defer the rebuild: this fires from inside an editor's own signal (slider drag,
224
+ # combo change, line-edit commit). Rebuilding now would delete that widget while
225
+ # Qt is still using it -> segfault. singleShot(0) runs it after the signal unwinds.
226
+ if device_id == self._device_id:
227
+ QTimer.singleShot(0, self._rebuild)
228
+
229
+ def _on_boards_changed(self) -> None:
230
+ """A real board came online or went quiet — refresh, if we are showing one.
231
+
232
+ Deferred for the same reason as _on_changed: this arrives on a poll tick that may
233
+ be nested inside a widget's own signal, and rebuilding the form under Qt's feet
234
+ deletes a widget it is still using. Restricted to gini32 so a board appearing
235
+ does not disturb someone editing an unrelated element's properties.
236
+ """
237
+ d = self.ctx.topology.devices.get(self._device_id) if self._device_id else None
238
+ if d is not None and d.type_key == "gini32":
239
+ QTimer.singleShot(0, self._rebuild)
240
+
241
+ def _clear_form(self) -> None:
242
+ while self.props_form.rowCount():
243
+ self.props_form.removeRow(0)
244
+
245
+ def _rebuild(self) -> None:
246
+ self._clear_form()
247
+ self._invoke_result = None # cleared widgets must not be touched
248
+ self._invoke_btn = None
249
+ self._deploy_btn = None
250
+ self._rider_btn = None
251
+ t = self.theme.theme
252
+ if not self._device_id or self._device_id not in self.ctx.topology.devices:
253
+ self.name_lbl.setText("No selection")
254
+ self.type_lbl.setText("Select a device to edit it")
255
+ self.icon_lbl.clear()
256
+ self.login_btn.hide()
257
+ self._clear_layout(self.ifaces_lay)
258
+ self.routes.setText("—")
259
+ return
260
+ d = self.ctx.topology.devices[self._device_id]
261
+ dt = d.type
262
+ accent = t.accent_for(dt.accent.value)
263
+ self.icon_lbl.setPixmap(icons.render_pixmap(dt.icon, accent, size=30))
264
+ self.name_lbl.setText(d.name)
265
+ self.type_lbl.setText(f"{dt.label} · {dt.category.value}")
266
+ self.login_btn.setVisible(dt.key not in
267
+ ("vpc", "cloud_subnet", "region",
268
+ "k8s_cluster", "instance_group", "pod"))
269
+ self.login_btn.setEnabled(self._login_allowed()) # needs the lab up (Router excepted)
270
+ self.login_btn.setToolTip(self._login_hint())
271
+ self._hide_container_tabs(d) # a board has no stack of ours to show
272
+ note = self._runtime_note(d)
273
+ self.runs_lbl.setText(note)
274
+ self.runs_lbl.setVisible(bool(note))
275
+
276
+ # size tier (resizable elements) — mirrors the on-node + / - stepper
277
+ from ..domain import pricing
278
+ if pricing.resizable(dt.key):
279
+ sizebox = QComboBox()
280
+ for lvl in range(pricing.SIZE_MIN, pricing.SIZE_MAX + 1):
281
+ lab, vcpu, _mem, mult = pricing.size_tier(lvl)
282
+ sizebox.addItem(f"{lab} · {vcpu:g} vCPU · ×{mult} cost", lvl)
283
+ sizebox.blockSignals(True)
284
+ sizebox.setCurrentIndex(pricing.size_level(getattr(d, "size", 1))
285
+ - pricing.SIZE_MIN)
286
+ sizebox.blockSignals(False)
287
+ sizebox.currentIndexChanged.connect(
288
+ lambda _i, sb=sizebox: self._commit_size(sb.currentData()))
289
+ self.props_form.addRow("Size", sizebox)
290
+
291
+ # Kubernetes live knobs: Pod replicas (kubectl scale) + HPA target CPU (patch)
292
+ if dt.key == "pod":
293
+ self.props_form.addRow("Replicas", self._int_slider("Replicas", 0, 10, 2))
294
+ if dt.key == "instance_group":
295
+ self.props_form.addRow("Target CPU %", self._int_slider("TargetCPU", 1, 100, 60))
296
+
297
+ # Load Generator gets a live rate throttle (drives Fortio while running)
298
+ if dt.key == "load_generator":
299
+ row = QWidget(); rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0)
300
+ sl = QSlider(Qt.Horizontal); sl.setRange(0, 1000)
301
+ try:
302
+ sl.setValue(int(float(d.properties.get("QPS", "100"))))
303
+ except ValueError:
304
+ sl.setValue(100)
305
+ vlbl = QLabel(str(sl.value())); vlbl.setMinimumWidth(34)
306
+ sl.valueChanged.connect(lambda v, lb=vlbl: lb.setText(str(v)))
307
+ sl.sliderReleased.connect(lambda s=sl: self._commit("QPS", str(s.value())))
308
+ rl.addWidget(sl, 1); rl.addWidget(vlbl)
309
+ self.props_form.addRow("Rate (req/s)", row)
310
+
311
+ choices = dt.property_choices
312
+ for key, value in d.properties.items():
313
+ if (dt.key, key) in (("load_generator", "QPS"), ("pod", "Replicas"),
314
+ ("instance_group", "TargetCPU"), ("function", "Code")):
315
+ continue # shown as a slider / code editor below
316
+ if key in choices: # render a dropdown for enum props
317
+ combo = QComboBox()
318
+ opts = list(choices[key])
319
+ if str(value) and str(value) not in opts:
320
+ opts.insert(0, str(value)) # keep any custom value selectable
321
+ combo.addItems(opts)
322
+ combo.setCurrentText(str(value))
323
+ combo.currentTextChanged.connect(lambda t, k=key: self._commit(k, t))
324
+ self.props_form.addRow(key, combo)
325
+ elif key in getattr(dt, "readonly_properties", ()):
326
+ # Observed, not requested: the element reports this from the real
327
+ # world, so showing an editable box would promise control we do not
328
+ # have (a GINI32's channel is forced by the uplink it joins).
329
+ lbl = QLabel(str(value) if str(value) else "—")
330
+ lbl.setStyleSheet("color: %s;" % self.theme.theme.muted)
331
+ lbl.setToolTip("Reported by the device — not settable here.")
332
+ self.props_form.addRow(key, lbl)
333
+ else:
334
+ edit = QLineEdit(str(value))
335
+ if not str(value) and key in ("BoardID", "ApSSID", "PhysicalSubnet"):
336
+ edit.setPlaceholderText(
337
+ {"BoardID": "the id on the board's label, e.g. gini-5",
338
+ "ApSSID": "blank = named after this element",
339
+ "PhysicalSubnet": "blank = allocated automatically"}[key])
340
+ edit.editingFinished.connect(
341
+ lambda e=edit, k=key: self._commit(k, e.text()))
342
+ self.props_form.addRow(key, edit)
343
+
344
+ if dt.key == "gini32": # real hardware: claim a board to this element
345
+ self._build_board_panel(d, accent)
346
+
347
+ if dt.key == "function": # serverless: code editor + Invoke (Test) panel
348
+ self._build_function_panel(d, accent)
349
+
350
+ if getattr(dt, "rider", False): # Source/Sink: a Run button; output shows in Live tab
351
+ self._build_rider_panel(d, accent)
352
+
353
+ self._build_interfaces(d, accent)
354
+ self.routes.setText(self._render_routes(d.name))
355
+
356
+ @staticmethod
357
+ def _clear_layout(lay) -> None:
358
+ while lay.count():
359
+ item = lay.takeAt(0)
360
+ w = item.widget()
361
+ if w is not None:
362
+ w.deleteLater()
363
+
364
+ def _build_interfaces(self, d, accent: str) -> None:
365
+ """Render the Interfaces tab. In manual addressing mode each interface's IP is
366
+ an editable field; otherwise it's read-only text."""
367
+ lay = self.ifaces_lay
368
+ self._clear_layout(lay)
369
+ name = d.name
370
+ addr = self.ctx.addressing.get(name)
371
+ manual = getattr(self.ctx.topology, "manual_addressing", False)
372
+
373
+ def add(text: str, obj: str = "", mono: bool = False, rich: bool = False) -> QLabel:
374
+ lbl = QLabel(text)
375
+ lbl.setWordWrap(True)
376
+ if rich:
377
+ lbl.setTextFormat(Qt.RichText)
378
+ if obj:
379
+ lbl.setObjectName(obj)
380
+ if mono:
381
+ lbl.setStyleSheet("font-family:monospace")
382
+ lay.addWidget(lbl)
383
+ return lbl
384
+
385
+ if not addr:
386
+ add("Addressing appears after you Compile.\n\nConnected to: "
387
+ + self._neighbors(name), obj="Faint")
388
+ return
389
+ if addr.get("role") == "switch":
390
+ peers = ", ".join(addr.get("peers", [])) or "nothing yet"
391
+ add(f"<b>Layer-2 switch</b> · {addr.get('ports', 0)} ports<br>"
392
+ f"Ports to: {peers}", rich=True)
393
+ return
394
+
395
+ if manual:
396
+ add("Manual addressing is <b>on</b> — type an IP for each interface. "
397
+ "Leave one blank to auto-fill it.", obj="Muted", rich=True)
398
+
399
+ for itf in addr["interfaces"]:
400
+ add(f"<b>{itf['name']}</b> &rarr; {itf['peer']}", rich=True)
401
+ if manual:
402
+ row = QWidget()
403
+ rl = QHBoxLayout(row)
404
+ rl.setContentsMargins(0, 0, 0, 0)
405
+ rl.addWidget(QLabel("IP"))
406
+ edit = QLineEdit(str(itf["ip"]).split("/")[0])
407
+ edit.setPlaceholderText("auto")
408
+ lid = itf.get("link_id", "")
409
+ edit.editingFinished.connect(
410
+ lambda e=edit, lid=lid: self._commit_iface_ip(lid, e.text()))
411
+ rl.addWidget(edit, 1)
412
+ lay.addWidget(row)
413
+ else:
414
+ add(itf["ip"], mono=True)
415
+ gw = f" · <span style='color:{accent}'>gateway</span> {itf['gateway']}" \
416
+ if itf.get("gateway") else ""
417
+ add(f"subnet {itf['subnet']}{gw}", obj="Faint", rich=True)
418
+ add(itf["mac"], obj="Faint", mono=True)
419
+
420
+ def _commit_iface_ip(self, link_id: str, text: str) -> None:
421
+ if self._device_id and link_id:
422
+ self.api.set_interface_ip(self._device_id, link_id, text)
423
+
424
+ def _render_routes(self, name: str) -> str:
425
+ addr = self.ctx.addressing.get(name)
426
+ if not addr:
427
+ return "<i>Routes appear after you Compile.</i>"
428
+ if addr["role"] == "switch":
429
+ return "Switches forward at Layer 2 — no IP routes."
430
+ lines = [f"{itf['subnet']} &rarr; dev {itf['name']} (connected)"
431
+ for itf in addr["interfaces"]]
432
+ gw = next((i["gateway"] for i in addr["interfaces"] if i.get("gateway")), None)
433
+ if gw:
434
+ lines.append(f"default &rarr; via {gw}")
435
+ return "<br>".join(lines)
436
+
437
+ def _neighbors(self, name: str) -> str:
438
+ d = self.ctx.topology.find_by_name(name)
439
+ if not d:
440
+ return "—"
441
+ nb = [n.name for n in self.ctx.topology.neighbors(d.id)]
442
+ return ", ".join(nb) if nb else "nothing yet"
443
+
444
+ # -- real-time CPU/memory metrics (Live tab, container elements) --------- #
445
+ def set_live_running(self, on: bool) -> None:
446
+ """Called by MainWindow on run/stop. Drives whether the metrics plots poll and
447
+ enables run-only panel actions (e.g. a Function's Invoke button)."""
448
+ self._live_running = bool(on)
449
+ self._hist.clear(); self._prev_net.clear() # fresh history each session
450
+ if not on:
451
+ self.metrics.set_series({})
452
+ for attr in ("_invoke_btn", "_deploy_btn"): # toggle run-only buttons in place
453
+ btn = getattr(self, attr)
454
+ if btn is not None:
455
+ try:
456
+ btn.setEnabled(on)
457
+ btn.setToolTip("" if on else "Run the topology first.")
458
+ except RuntimeError:
459
+ setattr(self, attr, None) # widget was rebuilt away
460
+ self.login_btn.setEnabled(self._login_allowed()) # Log in follows the same gate
461
+ self._update_live_mode()
462
+
463
+ _HIST_KEYS = ("cpu", "mem", "thru", "lat", "cpu_pct", "target_pct", "replicas")
464
+
465
+ def _hist_for(self, key: str) -> dict:
466
+ from collections import deque
467
+ from .live_metrics import LiveMetrics
468
+ h = self._hist.get(key)
469
+ if h is None:
470
+ h = {k: deque(maxlen=LiveMetrics.WINDOW) for k in self._HIST_KEYS}
471
+ self._hist[key] = h
472
+ return h
473
+
474
+ def _is_container(self, d) -> bool:
475
+ from ..services.compiler import _role
476
+ return _role(d.type_key) in ("compute", "service", "machine", "k8scluster")
477
+
478
+ def _is_k8s_workload(self, d) -> bool:
479
+ from ..services.compiler import _role
480
+ return _role(d.type_key) in ("k8sworkload", "hpa")
481
+
482
+ def _k8s_dep(self, d) -> str:
483
+ """The Deployment name a K8s element reports on (a Pod = itself; an Autoscaling
484
+ Group = its connected Pod)."""
485
+ from ..services.compiler import _role, _svc
486
+ if _role(d.type_key) == "k8sworkload":
487
+ return _svc(d.name)
488
+ for l in self.ctx.topology.links.values():
489
+ other = (l.target_id if l.source_id == d.id else
490
+ l.source_id if l.target_id == d.id else None)
491
+ od = self.ctx.topology.devices.get(other) if other else None
492
+ if od and _role(od.type_key) == "k8sworkload":
493
+ return _svc(od.name)
494
+ return _svc(d.name)
495
+
496
+ # -- Sources / Sinks: a Run button + output rendered in the Live tab ----- #
497
+ def _build_rider_panel(self, d, accent: str) -> None:
498
+ donor = self.ctx.topology.donor_of(d.id)
499
+ row = QWidget(); rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0)
500
+ self._rider_btn = QPushButton("Stop" if self.ctx.is_rider_running(d.id) else "Start")
501
+ self._rider_btn.setToolTip("Start/stop this on its donor — output streams to the Live tab")
502
+ self._rider_btn.clicked.connect(lambda: self._toggle_rider(d.id))
503
+ rl.addWidget(self._rider_btn)
504
+ rl.addWidget(QLabel(f"on {donor.name}" if donor else "not attached to a donor"))
505
+ rl.addStretch(1)
506
+ self.props_form.addRow("Runs", row)
507
+ self._show_rider_live(d) # reflect its last run (or a hint) in the Live tab
508
+
509
+ def _toggle_rider(self, device_id: str) -> None:
510
+ import threading
511
+ threading.Thread(target=lambda: self.ctx.toggle_rider(device_id), daemon=True).start()
512
+
513
+ def _on_rider_ran(self, device_id: str, result) -> None:
514
+ if device_id != self._device_id:
515
+ return
516
+ d = self.ctx.topology.devices.get(device_id)
517
+ if d is None:
518
+ return
519
+ if getattr(self, "_rider_btn", None) is not None:
520
+ self._rider_btn.setText("Stop" if (result or {}).get("running") else "Start")
521
+ self._show_rider_live(d)
522
+ self._refresh_kpis()
523
+ if self.tabs.currentWidget() is not self._live_host: # pop the Live tab once, then leave it
524
+ self.tabs.setCurrentWidget(self._live_host)
525
+
526
+ def _show_rider_live(self, d) -> None:
527
+ res = self.ctx.rider_results.get(d.id)
528
+ if res and (res.get("raw") or res.get("error")):
529
+ self.live.setPlainText(res.get("raw") or res.get("error"))
530
+ elif res:
531
+ self.live.setPlainText("(ran, no output)")
532
+ else:
533
+ self.live.setPlainText("Double-click this element, or press Run, to execute it on its "
534
+ "donor — its output appears here.")
535
+
536
+ def _rider_kpis(self, type_key: str, m: dict) -> list:
537
+ if type_key == "ping_probe":
538
+ out = [{"label": "loss", "value": m.get("loss_pct", "—"), "unit": "%"}]
539
+ if "rtt_avg_ms" in m:
540
+ out.append({"label": "avg rtt", "value": m["rtt_avg_ms"], "unit": "ms"})
541
+ return out
542
+ if type_key == "http_probe":
543
+ out = [{"label": "2xx", "value": m.get("ok_pct", "—"), "unit": "%"},
544
+ {"label": "reqs", "value": m.get("requests", 0), "unit": ""}]
545
+ if "avg_ms" in m:
546
+ out.append({"label": "avg", "value": m["avg_ms"], "unit": "ms"})
547
+ return out
548
+ if type_key == "packet_view":
549
+ return [{"label": "packets", "value": m.get("packets", 0), "unit": ""}]
550
+ return []
551
+
552
+ def _update_live_mode(self) -> None:
553
+ from ..services.compiler import _svc
554
+ from .live_metrics import CLOUD_LAYOUT, K8S_LAYOUT
555
+ d = self.ctx.topology.devices.get(self._device_id) if self._device_id else None
556
+ container = bool(d and self._live_running and self.stats_all_fn and self._is_container(d))
557
+ k8s = bool(d and self._live_running and self._is_k8s_workload(d))
558
+ mode_on = container or k8s
559
+ self._live_metrics_on = mode_on
560
+ on_live_tab = self.tabs.currentWidget() is self._live_host
561
+ self.metrics.setVisible(mode_on and on_live_tab)
562
+ self.live.setVisible(not mode_on)
563
+ self.refresh_btn.setVisible(not mode_on)
564
+ self._refresh_kpis()
565
+ if k8s: # fed by MainWindow's k8s poll (kubectl)
566
+ self.metrics.set_layout(K8S_LAYOUT)
567
+ self.metrics.set_series(self._hist_for(self._k8s_dep(d)))
568
+ self._live_timer.stop()
569
+ elif container: # fed by whole-lab docker stats
570
+ self.metrics.set_layout(CLOUD_LAYOUT)
571
+ self.metrics.set_series(self._hist_for(_svc(d.name)))
572
+ if not self._live_timer.isActive():
573
+ self._live_timer.start()
574
+ self._poll_metrics()
575
+ else:
576
+ self._live_timer.stop()
577
+
578
+ def set_k8s_snapshot(self, snap) -> None:
579
+ """Per-deployment K8s metrics (from MainWindow's kubectl poll). Appends to every
580
+ deployment's history so each Pod/Autoscaler keeps a continuous graph."""
581
+ self._k8s = snap or {}
582
+ for dep, m in (self._k8s.get("deployments") or {}).items():
583
+ h = self._hist_for(dep)
584
+ h["cpu_pct"].append(_optnum(m.get("cpu_pct")))
585
+ h["target_pct"].append(_optnum(m.get("target_pct")))
586
+ h["replicas"].append(_optnum(m.get("replicas")))
587
+ self.metrics.update()
588
+ self._refresh_kpis()
589
+
590
+ def set_fabric_snapshot(self, snap) -> None:
591
+ """Latest cloud-fabric app metrics (from MainWindow's 2s poll)."""
592
+ self._fabric = snap or {}
593
+ self._refresh_kpis()
594
+
595
+ def _refresh_kpis(self) -> None:
596
+ from ..services.compiler import _role, _svc
597
+ t = self.theme.theme
598
+ kpis = None
599
+ d = self.ctx.topology.devices.get(self._device_id) if self._device_id else None
600
+ if d and self._live_metrics_on:
601
+ role = _role(d.type_key)
602
+ if role in ("k8sworkload", "hpa"): # kubernetes element -> deployment KPIs
603
+ m = (self._k8s.get("deployments") or {}).get(self._k8s_dep(d))
604
+ if m:
605
+ cpu, tgt = m.get("cpu_pct"), m.get("target_pct")
606
+ kpis = [{"label": "replicas", "value": m.get("replicas", 0), "unit": ""},
607
+ {"label": "CPU", "value": "—" if cpu is None else cpu, "unit": "%"},
608
+ {"label": "target", "value": tgt, "unit": "%"}]
609
+ if m.get("min") is not None:
610
+ kpis.append({"label": "range",
611
+ "value": f"{m.get('min')}–{m.get('max')}", "unit": ""})
612
+ elif role == "k8scluster":
613
+ kpis = [{"label": "pods", "value": self._k8s.get("pods", 0), "unit": ""}]
614
+ else: # cloud service -> fabric KPIs
615
+ s = (self._fabric.get("services") or {}).get(_svc(d.name))
616
+ kpis = s.get("kpis") if s else None
617
+ if not kpis and d and getattr(d.type, "rider", False): # Source/Sink -> its measurement
618
+ res = self.ctx.rider_results.get(d.id)
619
+ m = res.get("measurement") if res else None
620
+ if m and m.get("ok"):
621
+ kpis = self._rider_kpis(d.type_key, m)
622
+ if not kpis:
623
+ self.kpis_lbl.setVisible(False)
624
+ return
625
+ chips = []
626
+ for k in kpis:
627
+ chips.append(
628
+ f'<span style="color:{t.accent};font-weight:600">{k["value"]}'
629
+ f'<span style="color:{t.faint};font-weight:400">{k.get("unit", "")}</span>'
630
+ f'</span> <span style="color:{t.muted}">{k["label"]}</span>')
631
+ self.kpis_lbl.setText(' &nbsp;·&nbsp; '.join(chips))
632
+ self.kpis_lbl.setVisible(True)
633
+
634
+ def _poll_metrics(self) -> None:
635
+ if self._polling or not self.stats_all_fn:
636
+ return
637
+ import time
638
+ self._polling = True
639
+
640
+ def work():
641
+ try:
642
+ allstats = self.stats_all_fn() # whole lab in one docker call
643
+ finally:
644
+ self._polling = False
645
+ if allstats:
646
+ self.metrics_ready.emit((allstats, time.monotonic()))
647
+ threading.Thread(target=work, daemon=True).start()
648
+
649
+ def _on_metrics(self, payload) -> None:
650
+ """Append one whole-lab sample to EVERY element's history (so each keeps a
651
+ continuous graph even while another is selected), then repaint the current one."""
652
+ allstats, now = payload
653
+ fabric_svcs = self._fabric.get("services") or {}
654
+ for svc, s in allstats.items():
655
+ h = self._hist_for(svc)
656
+ net = float(s.get("net_bytes", 0.0))
657
+ thru = 0.0
658
+ pn = self._prev_net.get(svc)
659
+ if pn and now - pn[1] > 0:
660
+ thru = max(0.0, (net - pn[0]) / (now - pn[1]) / 1024.0)
661
+ self._prev_net[svc] = (net, now)
662
+ lat = None
663
+ for k in (fabric_svcs.get(svc, {}) or {}).get("kpis", []):
664
+ if k.get("label") == "latency":
665
+ lat = k.get("value")
666
+ h["cpu"].append(float(s.get("cpu", 0.0)))
667
+ h["mem"].append(float(s.get("mem_used", 0.0)))
668
+ h["thru"].append(thru)
669
+ h["lat"].append(lat)
670
+ self.metrics.update() # repaint the selected element's chart
671
+
672
+ @staticmethod
673
+ def _signal_word(rssi: int) -> str:
674
+ """A bare dBm number means nothing to a student; say what it implies."""
675
+ if not rssi:
676
+ return ""
677
+ if rssi >= -55:
678
+ return "strong"
679
+ if rssi >= -67:
680
+ return "good"
681
+ if rssi >= -75:
682
+ return "fair"
683
+ return "weak — move the board closer"
684
+
685
+ def _board_live_text(self, d) -> str:
686
+ """Everything we know about a real board. No shell, no container — this IS the
687
+ view for a GINI32, because there is nothing to log in to."""
688
+ st = self.board_status_fn() if self.board_status_fn else None
689
+ bid = str((d.properties or {}).get("BoardID", "")).strip()
690
+ if not bid:
691
+ return ("No BoardID set.\n\n"
692
+ "This element is not bound to any physical board, so nothing will\n"
693
+ "ever attach to it. Put the id from the board's label here — the one\n"
694
+ "written by `gini32 provision --id`.")
695
+ if not st:
696
+ return ("Live state is available once the topology is running.\n\n"
697
+ f"Waiting for board {bid!r} to check in.")
698
+ b = next((x for x in st.get("boards", []) if x.get("board_id") == bid), None)
699
+ if b is None:
700
+ return f"The running lab has no board called {bid!r}.\nPress Run again after changing the BoardID."
701
+ if not b.get("online"):
702
+ return (f"{bid} — OFFLINE (never checked in, or gone quiet)\n\n"
703
+ "Most likely, in order:\n"
704
+ " 1. the board is on a different network from this Mac\n"
705
+ " 2. the id flashed on the board differs from BoardID here\n"
706
+ " 3. this Wi-Fi blocks multicast, so discovery finds nothing\n"
707
+ " (fix: `gini32 provision --server <this Mac's IP>`)\n\n"
708
+ "The board's own serial console says which of these it is.")
709
+
710
+ import time as _t
711
+ age = max(0, int(_t.time() - (b.get("last_seen") or 0)))
712
+ sig = self._signal_word(b.get("rssi") or 0)
713
+ rows = [
714
+ f"{bid} — connected · last heard {age}s ago",
715
+ "",
716
+ f" server {b.get('addr') or '?'}",
717
+ f" fabric {b.get('ip') or '?'} mode {b.get('mode') or '?'}",
718
+ f" hotspot {b.get('ap_ssid') or '?'} on {b.get('physical_subnet') or '?'}",
719
+ f" uplink {b.get('uplink') or '?'} ch {b.get('channel') or '?'}"
720
+ + (f" RSSI {b.get('rssi')} dBm ({sig})" if b.get("rssi") else ""),
721
+ f" counters rx {b.get('rx', 0)} tx {b.get('tx', 0)} "
722
+ f"dropped {b.get('dropped', 0)}",
723
+ "",
724
+ ]
725
+ clients = b.get("clients") or []
726
+ if clients:
727
+ rows.append(f"devices on the radio ({len(clients)})")
728
+ for c in clients:
729
+ rows.append(f" {c.get('mac','?'):<20} {c.get('ip','?')}")
730
+ rows.append("")
731
+ rows.append("These are real devices. They appear on the canvas while they are")
732
+ rows.append("associated and are never saved with the topology.")
733
+ else:
734
+ rows.append("No devices on the radio yet — join the hotspot "
735
+ f"{b.get('ap_ssid') or ''} from a phone.")
736
+ return "\n".join(rows)
737
+
738
+ def _build_board_offline_picker(self, d) -> None:
739
+ """Offer the boards set up from this computer, while no lab is running."""
740
+ known = list(getattr(self.ctx.settings, "known_boards", None) or [])
741
+ mine = str((d.properties or {}).get("BoardID", "")).strip()
742
+ if not known and not mine:
743
+ lbl = QLabel("No boards set up yet — use Hardware → Set Up a Board with "
744
+ "one plugged in over USB. Run the topology to see which "
745
+ "boards are on the network.")
746
+ lbl.setStyleSheet("color: %s;" % self.theme.theme.muted)
747
+ lbl.setWordWrap(True)
748
+ self.props_form.addRow("Board", lbl)
749
+ return
750
+
751
+ combo = QComboBox()
752
+ combo.setEditable(True) # an id can still be typed by hand
753
+ combo.addItem("")
754
+ for name in known:
755
+ combo.addItem(f"{name} · set up here", name)
756
+ if mine and mine not in known:
757
+ combo.addItem(f"{mine} · typed", mine)
758
+ idx = next((i for i in range(combo.count()) if combo.itemData(i) == mine), 0)
759
+ combo.setCurrentIndex(idx)
760
+
761
+ def pick(_i: int) -> None:
762
+ name = combo.currentData() or combo.currentText().split(" · ")[0].strip()
763
+ self._commit("BoardID", name)
764
+ combo.activated.connect(pick)
765
+ self.props_form.addRow("Board", combo)
766
+
767
+ hint = QLabel("Boards set up from this computer. Run the topology to see "
768
+ "which are actually on the network.")
769
+ hint.setStyleSheet("color: %s;" % self.theme.theme.muted)
770
+ hint.setWordWrap(True)
771
+ self.props_form.addRow("", hint)
772
+
773
+ def _build_board_panel(self, d, accent: str) -> None:
774
+ """Pick which physical board plays this element, and claim it.
775
+
776
+ Claiming is what stops a room of students taking each other's hardware: a
777
+ claimed board answers only its owner and is invisible to every other gBuilder.
778
+ So this list only ever shows boards that are unclaimed, or already ours.
779
+ """
780
+ st = self.board_status_fn() if self.board_status_fn else None
781
+ if st is None:
782
+ # Nothing is running, so we cannot say who is on the air. We CAN still
783
+ # offer the boards this laptop has set up: retyping an id from memory is
784
+ # exactly where a typo creeps in, and a mistyped BoardID gives a board
785
+ # that is online and healthy but invisible to the topology.
786
+ self._build_board_offline_picker(d)
787
+ return
788
+
789
+ avail = st.get("available") or []
790
+ mine = str((d.properties or {}).get("BoardID", "")).strip()
791
+
792
+ row = QHBoxLayout()
793
+ combo = QComboBox()
794
+ combo.setEditable(True) # a known id can still be typed before it appears
795
+ combo.addItem("")
796
+ for b in avail:
797
+ tag = "yours" if b.get("claimed") else "free"
798
+ state = "online" if b.get("online") else "quiet"
799
+ combo.addItem(f"{b['name']} · {tag}, {state} · {b.get('mac', '')}",
800
+ b["name"])
801
+ # keep whatever is already set, even if that board is not on the air right now
802
+ if mine and mine not in [b["name"] for b in avail]:
803
+ combo.addItem(f"{mine} · not seen", mine)
804
+ idx = next((i for i in range(combo.count()) if combo.itemData(i) == mine), 0)
805
+ combo.setCurrentIndex(idx)
806
+
807
+ def pick(_i: int) -> None:
808
+ name = combo.currentData() or combo.currentText().split(" · ")[0].strip()
809
+ self._commit("BoardID", name)
810
+ combo.activated.connect(pick)
811
+ row.addWidget(combo, 1)
812
+ self.props_form.addRow("Board", self._wrap_row(row))
813
+
814
+ if not avail:
815
+ # A board claimed by another laptop is filtered out before it ever reaches
816
+ # this list, so "nothing is announcing" would be flatly untrue when one is
817
+ # sitting on the bench announcing steadily. Say which case this is: they
818
+ # look identical from here but have completely different fixes.
819
+ foreign = st.get("foreign") or []
820
+ if foreign:
821
+ names = ", ".join(f["board_id"] for f in foreign[:3])
822
+ hint = QLabel(
823
+ f"<b>{names}</b> is on the network but claimed by another laptop, "
824
+ f"so it will not answer this one.<br><br>If that is your board, its "
825
+ f"claim outlived the install that made it. Free it over USB:<br>"
826
+ f"<tt>./gini32 unpair -p &lt;port&gt;</tt> then type <tt>unpair</tt>.")
827
+ hint.setStyleSheet("color: %s;" % self.theme.theme.warning)
828
+ else:
829
+ hint = QLabel("No boards are announcing themselves. A board appears "
830
+ "here once it joins the lab Wi-Fi — and only if it is "
831
+ "unclaimed, or already claimed by this laptop.")
832
+ hint.setStyleSheet("color: %s;" % self.theme.theme.muted)
833
+ hint.setWordWrap(True)
834
+ self.props_form.addRow("", hint)
835
+ return
836
+
837
+ sel = next((b for b in avail if b["name"] == mine), None)
838
+
839
+ # The mismatch case, said plainly. Hardware is on the air, but not under the id
840
+ # this element is asking for — so the relay turns it away and the board simply
841
+ # keeps searching. Neither end can detect this alone: the board does not know
842
+ # what the canvas wants, and the canvas does not know the board exists. A quiet
843
+ # "not seen" suffix in the dropdown is not enough; name the boards that ARE here.
844
+ if mine and sel is None:
845
+ others = ", ".join(b["name"] for b in avail[:4])
846
+ warn = QLabel(f"No board called <b>{mine}</b> is on the network, but these "
847
+ f"are: <b>{others}</b>.<br>Pick one above — the id must match "
848
+ f"the board's label exactly.")
849
+ warn.setStyleSheet("color: %s;" % self.theme.theme.warning)
850
+ warn.setWordWrap(True)
851
+ self.props_form.addRow("", warn)
852
+
853
+ btns = QHBoxLayout()
854
+ blink = QPushButton("Blink")
855
+ blink.setToolTip("Flash this board's LED, so you can tell which one it is "
856
+ "on the bench.")
857
+ blink.setEnabled(sel is not None)
858
+ blink.clicked.connect(lambda: self.board_action_fn and
859
+ self.board_action_fn("blink", mine))
860
+ btns.addWidget(blink)
861
+
862
+ if sel is not None and not sel.get("claimed"):
863
+ claim = QPushButton("Claim this board")
864
+ claim.setToolTip("Bind it to this laptop. It will then ignore every other "
865
+ "gBuilder, so nobody else can take it.")
866
+ claim.clicked.connect(lambda: self.board_action_fn and
867
+ self.board_action_fn("claim", mine))
868
+ btns.addWidget(claim, 1)
869
+ elif sel is not None:
870
+ rel = QPushButton("Release")
871
+ rel.setToolTip("Give the board up so another laptop can claim it. You can "
872
+ "also do this on the board itself with `unpair`.")
873
+ rel.clicked.connect(lambda: self.board_action_fn and
874
+ self.board_action_fn("release", mine))
875
+ btns.addWidget(rel, 1)
876
+ self.props_form.addRow("", self._wrap_row(btns))
877
+
878
+ @staticmethod
879
+ def _wrap_row(layout) -> QWidget:
880
+ w = QWidget()
881
+ layout.setContentsMargins(0, 0, 0, 0)
882
+ w.setLayout(layout)
883
+ return w
884
+
885
+ def _refresh_live(self) -> None:
886
+ if not self._device_id:
887
+ return
888
+ d = self.ctx.topology.devices[self._device_id]
889
+ from ..services.compiler import _role
890
+ role = _role(d.type_key)
891
+ if role == "gini32":
892
+ # Real hardware: no container to query, so this is served from the relay's
893
+ # view rather than by exec'ing into anything.
894
+ self.live.setPlainText(self._board_live_text(d))
895
+ return
896
+ if self.query_fn is None:
897
+ self.live.setPlainText("Live state is available once the topology is running.")
898
+ return
899
+ self.live.setPlainText("loading…")
900
+
901
+ def work():
902
+ if role == "router":
903
+ out = ("interfaces:\n" + self.query_fn(d.name, "ifconfig show")
904
+ + "\n\nroutes:\n" + self.query_fn(d.name, "route show")
905
+ + "\n\narp cache:\n" + self.query_fn(d.name, "arp show"))
906
+ elif role == "switch":
907
+ out = ("ports: " + self.query_fn(d.name, "ports")
908
+ + "\n\nmac table:\n" + self.query_fn(d.name, "mactable"))
909
+ else:
910
+ out = "This is a host container — use “Log in” for a shell."
911
+ self.live_ready.emit(out)
912
+ threading.Thread(target=work, daemon=True).start()
913
+
914
+ @staticmethod
915
+ def _runtime_note(d) -> str:
916
+ """A plain 'here's what this actually runs' line so users (and new ones!) don't
917
+ have to guess the backing image or which tools are available."""
918
+ from ..services.cloud_catalog import service_for
919
+ from ..services.compiler import _norm_image, _toolkit_for
920
+ from ..services.orchestrator import (MACHINE_BASE, MACHINE_TOOLS_HUMAN,
921
+ MACHINE_TOOLS_LEAN_HUMAN,
922
+ MACHINE_TOOLS_SECURITY_HUMAN)
923
+ key = d.type_key
924
+ if key == "host":
925
+ # tell the truth about THIS host's image — the toolkits ship different tools, and a
926
+ # student reaching for `ettercap` on a lean host should learn that here, not at a shell
927
+ tk = _toolkit_for(d)
928
+ if tk == "security":
929
+ return (f"Runs the SECURITY toolkit — {MACHINE_BASE}, the full toolkit plus the "
930
+ f"Part VI security engines: {MACHINE_TOOLS_SECURITY_HUMAN}. Biggest image; "
931
+ f"use it for the crypto, VPN, and intrusion-detection labs.")
932
+ if tk == "full":
933
+ return (f"Runs the FULL toolkit — {MACHINE_BASE} with everything preinstalled: "
934
+ f"{MACHINE_TOOLS_HUMAN}. Big image; use it only when an experiment needs "
935
+ f"these servers. (apt is available for anything else.)")
936
+ return (f"Runs the LEAN toolkit — Alpine + {MACHINE_TOOLS_LEAN_HUMAN}. Small and quick "
937
+ f"to boot. Need bind9/postfix/ettercap/haproxy? Set Toolkit to 'full', or "
938
+ f"'security' for the security engines. (apk is available for anything else.)")
939
+ if key == "instance":
940
+ img = _norm_image(d.properties.get("Image") or "ubuntu:22.04")
941
+ return f"Runs {img} (a cloud VM). Change the Image property to use another."
942
+ if key == "container":
943
+ img = _norm_image(d.properties.get("Image") or "alpine:latest")
944
+ return f"Runs {img}. Set Image/Command to run your own app."
945
+ svc = service_for(key)
946
+ if svc is not None:
947
+ return f"Runs {svc.image} — {svc.summary}"
948
+ return ""
949
+
950
+ def _int_slider(self, prop: str, lo: int, hi: int, default: int):
951
+ """A labelled int slider that commits `prop` on release (live knobs: replicas, CPU%)."""
952
+ row = QWidget(); rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0)
953
+ sl = QSlider(Qt.Horizontal); sl.setRange(lo, hi)
954
+ d = self.ctx.topology.devices.get(self._device_id)
955
+ try:
956
+ sl.setValue(int(float((d.properties.get(prop) if d else None) or default)))
957
+ except (ValueError, TypeError):
958
+ sl.setValue(default)
959
+ vlbl = QLabel(str(sl.value())); vlbl.setMinimumWidth(34)
960
+ sl.valueChanged.connect(lambda v, lb=vlbl: lb.setText(str(v)))
961
+ sl.sliderReleased.connect(lambda s=sl, p=prop: self._commit(p, str(s.value())))
962
+ rl.addWidget(sl, 1); rl.addWidget(vlbl)
963
+ return row
964
+
965
+ def _faint(self, text: str) -> QLabel:
966
+ lbl = QLabel(text); lbl.setObjectName("Faint")
967
+ return lbl
968
+
969
+ def _build_function_panel(self, d, accent: str) -> None:
970
+ """Serverless authoring, in the inspector: a Python code editor for custom handlers
971
+ (the AWS-Lambda 'Code' tab) and an Invoke/Test panel (the 'Test' tab)."""
972
+ box = QWidget(); lay = QVBoxLayout(box)
973
+ lay.setContentsMargins(0, 6, 0, 0); lay.setSpacing(6)
974
+
975
+ editor = None
976
+ if (d.properties.get("Handler") or "echo") == "custom":
977
+ lay.addWidget(self._faint("Handler code — Python 3.12 · standard library"))
978
+ editor = QPlainTextEdit(d.properties.get("Code") or _FUNCTION_STARTER)
979
+ editor.setStyleSheet(_scss("font-family:monospace; font-size:12px"))
980
+ editor.setMinimumHeight(150)
981
+ lay.addWidget(editor)
982
+ save = QPushButton("Save code")
983
+ save.clicked.connect(
984
+ lambda _=False, e=editor: self._commit("Code", e.toPlainText()))
985
+ lay.addWidget(save)
986
+
987
+ # Deploy: push the current code to the running runtime (recreate only the faas
988
+ # container — the rest of the lab keeps running). AWS-style 'Deploy'.
989
+ deploy = QPushButton("Deploy")
990
+ deploy.setObjectName("Accent")
991
+ deploy.setEnabled(self._live_running)
992
+ deploy.setToolTip("Recreate the faas runtime with your latest function code "
993
+ "(the rest of the lab keeps running)." if self._live_running
994
+ else "Run the topology first, then Deploy.")
995
+
996
+ def _do_deploy(_=False, e=editor):
997
+ if e is not None: # save the latest edits before deploying
998
+ self._commit("Code", e.toPlainText())
999
+ self.ctx.bus.function_deploy_requested.emit()
1000
+ deploy.clicked.connect(_do_deploy)
1001
+ lay.addWidget(deploy)
1002
+ self._deploy_btn = deploy
1003
+
1004
+ lay.addWidget(self._faint("Invoke (Test) — runs the live function"))
1005
+ row = QWidget(); rl = QHBoxLayout(row); rl.setContentsMargins(0, 0, 0, 0)
1006
+ method = QComboBox(); method.addItems(["GET", "POST"])
1007
+ body = QLineEdit(); body.setPlaceholderText("request body (sent on POST)")
1008
+ invoke = QPushButton("Invoke")
1009
+ # the Invoke button only works against the live container, so it's enabled only once
1010
+ # the lab is running (docker compose up succeeded -> set_live_running(True)).
1011
+ invoke.setEnabled(self._live_running)
1012
+ if not self._live_running:
1013
+ invoke.setToolTip("Run the topology first (the function must be running).")
1014
+ rl.addWidget(method); rl.addWidget(body, 1); rl.addWidget(invoke)
1015
+ lay.addWidget(row)
1016
+
1017
+ result = QPlainTextEdit(); result.setReadOnly(True); result.setMinimumHeight(96)
1018
+ result.setStyleSheet(_scss("font-family:monospace; font-size:12px"))
1019
+ result.setPlainText("Invoke to see the response." if self._live_running
1020
+ else "Run the topology, then Invoke to see the response.")
1021
+ lay.addWidget(result)
1022
+ self._invoke_result = result
1023
+ self._invoke_btn = invoke
1024
+
1025
+ did = self._device_id
1026
+ invoke.clicked.connect(
1027
+ lambda _=False, m=method, b=body: self.ctx.bus.function_invoke_requested.emit(
1028
+ did, m.currentText(), b.text()))
1029
+ self.props_form.addRow(box)
1030
+
1031
+ def _on_invoke_result(self, device_id: str, text: str) -> None:
1032
+ if device_id == self._device_id and self._invoke_result is not None:
1033
+ try:
1034
+ self._invoke_result.setPlainText(text)
1035
+ except RuntimeError:
1036
+ pass # the panel was rebuilt away
1037
+
1038
+ def _commit(self, key: str, value: str) -> None:
1039
+ if self._device_id:
1040
+ self.api.set_property(self._device_id, key, value)
1041
+
1042
+ def _commit_size(self, level) -> None:
1043
+ from ..domain import pricing
1044
+ if not self._device_id:
1045
+ return
1046
+ d = self.ctx.topology.devices.get(self._device_id)
1047
+ if d is None:
1048
+ return
1049
+ new = pricing.size_level(level)
1050
+ if new == getattr(d, "size", 1): # no-op guard (breaks rebuild loop)
1051
+ return
1052
+ d.size = new
1053
+ self.ctx.bus.device_changed.emit(self._device_id) # resize the node + reroute edges
1054
+ self.ctx.bus.device_resized.emit(self._device_id) # live CPU update if running
1055
+ self.ctx.bus.topology_changed.emit() # rebill the dashboard