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/main_window.py ADDED
@@ -0,0 +1,3153 @@
1
+ """The gBuilder main window — shell that assembles every panel.
2
+
3
+ Layout: device palette (left dock) · canvas (center) · inspector + assistant
4
+ (right docks, tabbed) · console (bottom dock) · toolbar + status bar. All visuals
5
+ flow from the ThemeManager so Dark / Light / GINI Brand swap instantly.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import time
11
+
12
+ from PySide6.QtCore import Qt
13
+ from PySide6.QtGui import QAction, QActionGroup
14
+ from PySide6.QtWidgets import (
15
+ QDockWidget, QFrame, QHBoxLayout, QLabel, QMainWindow, QPlainTextEdit, QToolBar,
16
+ QToolButton, QWidget,
17
+ )
18
+
19
+ from ..agent.api import GiniAPI
20
+ from ..app import AppContext
21
+ from .assistant import Assistant
22
+ from .canvas import NODE_H, NODE_W, CanvasView
23
+ from .inspector import Inspector
24
+ from .palette import Palette
25
+ from .theme import ThemeManager, icons
26
+
27
+
28
+ def _theme_swatch(theme, size: int = 18):
29
+ """A little palette chip for a theme menu row: the theme's surface with its
30
+ accent + success dots, so each theme is recognisable at a glance."""
31
+ from PySide6.QtCore import QRectF, Qt
32
+ from PySide6.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
33
+ s = size * 2
34
+ pm = QPixmap(s, s); pm.fill(QColor(0, 0, 0, 0))
35
+ p = QPainter(pm); p.setRenderHint(QPainter.Antialiasing, True)
36
+ p.setPen(QPen(QColor(theme.line), 1.5)); p.setBrush(QColor(theme.bg))
37
+ p.drawRoundedRect(QRectF(1, 1, s - 2, s - 2), s * 0.30, s * 0.30)
38
+ p.setPen(Qt.NoPen)
39
+ d = s * 0.42
40
+ p.setBrush(QColor(theme.accent)); p.drawEllipse(QRectF(s*0.30 - d/2, s*0.42 - d/2, d, d))
41
+ ds = s * 0.34
42
+ p.setBrush(QColor(theme.success)); p.drawEllipse(QRectF(s*0.66 - ds/2, s*0.60 - ds/2, ds, ds))
43
+ p.end()
44
+ return QIcon(pm)
45
+
46
+
47
+ def _theme_dots_icon(theme, size: int = 19):
48
+ """Three coloured dots for the toolbar button, signalling 'this changes colours'."""
49
+ from PySide6.QtCore import QRectF, Qt
50
+ from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap
51
+ s = size * 2
52
+ pm = QPixmap(s, s); pm.fill(QColor(0, 0, 0, 0))
53
+ p = QPainter(pm); p.setRenderHint(QPainter.Antialiasing, True); p.setPen(Qt.NoPen)
54
+ d = s * 0.44
55
+ for col, x, y in ((theme.accent, 0.28, 0.40), (theme.warning, 0.72, 0.40),
56
+ (theme.success, 0.50, 0.64)):
57
+ p.setBrush(QColor(col)); p.drawEllipse(QRectF(s*x - d/2, s*y - d/2, d, d))
58
+ p.end()
59
+ return QIcon(pm)
60
+
61
+ # Runs INSIDE the faas container (python -c). Reads GINI_FN/METHOD/BODY from the env,
62
+ # checks whether this is the function's first call (cold), invokes it on localhost:8000,
63
+ # and prints one JSON line {code, ms, cold, body} for the inspector to display.
64
+ _FAAS_INVOKE = (
65
+ "import os,time,json,urllib.request,urllib.error\n"
66
+ "fn=os.environ['GINI_FN'];method=os.environ.get('GINI_METHOD','GET')\n"
67
+ "body=os.environ.get('GINI_BODY','');base='http://localhost:8000'\n"
68
+ "try:\n"
69
+ " m=json.load(urllib.request.urlopen(base+'/_gini/metrics',timeout=5))\n"
70
+ " prev=m.get('functions',{}).get(fn,{}).get('invocations',0)\n"
71
+ "except Exception:\n"
72
+ " prev=0\n"
73
+ "data=body.encode() if body else None\n" # send the body whenever one is typed
74
+ "req=urllib.request.Request(base+'/'+fn,data=data,method=method)\n"
75
+ "t=time.time()\n"
76
+ "try:\n"
77
+ " r=urllib.request.urlopen(req,timeout=15);code=r.getcode();out=r.read().decode('utf-8','replace')\n"
78
+ "except urllib.error.HTTPError as e:\n"
79
+ " code=e.code;out=e.read().decode('utf-8','replace')\n"
80
+ "except Exception as e:\n"
81
+ " code=0;out=str(e)\n"
82
+ "print(json.dumps({'code':code,'ms':round((time.time()-t)*1000),'cold':prev==0,'body':out}))\n"
83
+ )
84
+
85
+
86
+ def _photo_data_url(path: str, size: int = 128) -> str:
87
+ """Load an image, centre-crop to a square, downscale to a face-sized thumbnail, and encode it as
88
+ a PNG data-URL. QImage (not QPixmap) so it's CPU-only — safe off the GUI thread and headless.
89
+ Downscaling on the client keeps the DB tiny and means the original never leaves the machine.
90
+ Returns '' if the file isn't a readable image."""
91
+ import base64
92
+
93
+ from PySide6.QtCore import QBuffer, QByteArray, Qt
94
+ from PySide6.QtGui import QImage
95
+ img = QImage(path)
96
+ if img.isNull():
97
+ return ""
98
+ side = min(img.width(), img.height())
99
+ img = img.copy((img.width() - side) // 2, (img.height() - side) // 2, side, side)
100
+ img = img.scaled(size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
101
+ ba = QByteArray() # MUST outlive the buffer — a temporary here crashes PySide
102
+ buf = QBuffer(ba)
103
+ buf.open(QBuffer.WriteOnly)
104
+ img.save(buf, "PNG")
105
+ buf.close()
106
+ return "data:image/png;base64," + base64.b64encode(bytes(ba)).decode()
107
+
108
+
109
+ class MainWindow(QMainWindow):
110
+ def __init__(self, app) -> None:
111
+ super().__init__()
112
+ self.ctx = AppContext()
113
+ self.api = GiniAPI(self.ctx)
114
+ self._load_config() # ~/.gini/config.json defaults
115
+ self.ctx.topology.prefix_overrides = dict(self.ctx.settings.name_prefixes)
116
+ self._apply_env_settings() # env vars override saved config
117
+ from ..agent.tools import build_registry
118
+ self.registry = build_registry(self.api) # shared: in-app loop + present tools
119
+ # gLoader: compiles the drawn topology into a runtime plan and launches it
120
+ from pathlib import Path
121
+ from .. import runtime as _rt
122
+ from ..services import GLoader
123
+ self._gloader = GLoader(Path(_rt.__file__).parent)
124
+ self.ctx.orchestrator = self._gloader.orchestrator # behavioral probes exec through this
125
+ # NOTE: we deliberately do NOT sign in to the course at startup, even with saved credentials.
126
+ # Signing in is an act, not a side effect of launching an app: you might be demoing, or on a
127
+ # shared machine, or simply not want your instructor to see you're online. The User pill
128
+ # starts signed-out and you sign in from it.
129
+ self._remote = None # RemoteClient when connected to a GINI server, else None
130
+ self._running = False
131
+ self._stopping = False
132
+ self._workdir: str | None = None
133
+ self._project_path: str | None = None
134
+ self._project_dir: str | None = None # active project folder (Projects)
135
+ self._experiment: str | None = None # the experiment (topology) open within it
136
+ self._router_programs: dict = {} # device id -> RouterProgram (Router Lab)
137
+ self.ctx.bus.run_state.connect(self._on_run_state)
138
+ self.ctx.bus.runtime_status.connect(self._on_runtime_status)
139
+ self.ctx.bus.device_activated.connect(self._on_device_activated)
140
+ from PySide6.QtCore import QTimer
141
+ self._poll = QTimer(self)
142
+ self._poll.setInterval(3000)
143
+ self._poll.timeout.connect(self._poll_status)
144
+ self._fabric_poll = QTimer(self) # cloud-fabric app metrics
145
+ self._fabric_poll.setInterval(2000)
146
+ self._fabric_poll.timeout.connect(self._poll_fabric)
147
+ self.ctx.bus.fabric_metrics.connect(self._on_fabric_metrics)
148
+ self._k8s_poll = QTimer(self) # kubernetes metrics (kubectl)
149
+ self._k8s_poll.setInterval(3000)
150
+ self._k8s_poll.timeout.connect(self._poll_k8s)
151
+ self.ctx.bus.k8s_metrics.connect(self._on_k8s_metrics)
152
+ self.ctx.bus.llm_reachable.connect(self._on_llm_reachable)
153
+ self.ctx.bus.enrolment_changed.connect(self._on_enrolment)
154
+ self._chat_dock = None
155
+ self._force_new_signin = False # one-shot: force the sign-in dialog (switch user)
156
+ self._beat = QTimer(self) # presence + group progress, while signed in
157
+ self._beat.setInterval(30_000)
158
+ self._beat.timeout.connect(self._heartbeat)
159
+ self._beat.start()
160
+ from .theme.manager import scale_for
161
+ self.theme = ThemeManager(app, self.ctx.settings.theme,
162
+ scale_for(getattr(self.ctx.settings, "text_size", "Normal")))
163
+ self.theme.apply()
164
+
165
+ self.setWindowTitle("gBuilder 6.0 — networks + cloud")
166
+ from .branding import app_icon
167
+ self.setWindowIcon(app_icon()) # window + taskbar/dock icon (the GINI mascot)
168
+ # open to a sensible size, but never larger than the screen (a wide dock must never push
169
+ # the window off-screen)
170
+ screen = app.primaryScreen().availableGeometry() if app.primaryScreen() else None
171
+ w = min(1280, screen.width() - 40) if screen else 1280
172
+ h = min(820, screen.height() - 80) if screen else 820
173
+ self.resize(w, h)
174
+
175
+ self.canvas = CanvasView(self.ctx, self.theme.theme)
176
+ self.setCentralWidget(self.canvas)
177
+ self.canvas.scene_.selectionChanged.connect(self._on_scene_selection)
178
+
179
+ self._make_toolbar()
180
+ self._make_delete_shortcut()
181
+ self._make_menubar()
182
+ self._make_docks()
183
+ self._make_statusbar()
184
+ self._wire_llm()
185
+
186
+ self.theme.themeChanged.connect(self._on_theme_changed)
187
+ self.ctx.bus.topology_changed.connect(self._update_status)
188
+ # Debounce the HEAVY per-change recompute: addressing, lint, and billing each run the
189
+ # compiler, and topology_changed fires once PER device — so loading a project, the
190
+ # agent building a recipe, or a multi-delete would trigger a burst of synchronous
191
+ # compiles on the GUI thread (a momentary freeze). Coalesce them into ONE recompute a
192
+ # short idle after the last change so the UI stays responsive.
193
+ self._recompute_timer = QTimer(self)
194
+ self._recompute_timer.setSingleShot(True)
195
+ self._recompute_timer.setInterval(120)
196
+ self._recompute_timer.timeout.connect(self._do_recompute)
197
+ self.ctx.bus.topology_changed.connect(self._recompute_timer.start)
198
+ self.ctx.bus.device_resized.connect(self._on_device_resized)
199
+ self.ctx.bus.device_changed.connect(self._on_device_changed_live)
200
+ self.ctx.bus.log.connect(self._on_log)
201
+ self.ctx.bus.device_delete_requested.connect(self._delete_device)
202
+ self.ctx.bus.warning_explain_requested.connect(self._on_warning_explain)
203
+ self.ctx.bus.device_logs_requested.connect(self._open_logs)
204
+ self.ctx.bus.device_console_requested.connect(self._open_console)
205
+ self.ctx.bus.function_invoke_requested.connect(self._on_function_invoke)
206
+ self.ctx.bus.function_deploy_requested.connect(self._on_function_deploy)
207
+ self.ctx.bus.rider_ran.connect(self._on_rider_state)
208
+ # xv6 riders run over the console (not docker); register the serial-path hooks
209
+ self._xv6_rider_sessions: dict = {}
210
+ self.ctx.xv6_rider_toggle = self._toggle_xv6_rider
211
+ self.ctx.xv6_rider_running = lambda rid: rid in self._xv6_rider_sessions
212
+ self.ctx.bus.selection_changed.connect(self._on_selection_explain)
213
+ self.ctx.bus.canvas_background_clicked.connect(self._on_canvas_background)
214
+ self.palette.element_selected.connect(self._on_palette_explain)
215
+ self.assistant.status_changed.connect(self.mode_indicator.set_status)
216
+ self.mode_indicator.set_status("Chat mode", False) # initial
217
+ self.mode_indicator.model_clicked.connect(self._open_settings) # Model pill -> Settings
218
+ self.mode_indicator.user_clicked.connect(self._user_menu) # User pill -> the user menu
219
+ # Ask GINI messages live in the right-hand pane only; the Console is for
220
+ # build/run logs, so we deliberately do NOT mirror chat into it.
221
+ self._update_status()
222
+ # NOTE: reopening last session's project is done by the app entry point
223
+ # (restore_last_project), NOT here — so constructing a window in tests is inert.
224
+
225
+ def _load_config(self) -> None:
226
+ """Load persisted defaults from ~/.gini/config.json into Settings (applied as the
227
+ ThemeManager and LLM read Settings during construction)."""
228
+ from ..app.paths import PERSISTED_KEYS, load_config
229
+ cfg = load_config()
230
+ s = self.ctx.settings
231
+ for k in PERSISTED_KEYS:
232
+ if k in cfg:
233
+ setattr(s, k, cfg[k])
234
+
235
+ def _open_settings(self) -> None:
236
+ from ..app.paths import PERSISTED_KEYS, save_config
237
+ from .settings_dialog import SettingsDialog
238
+ dlg = SettingsDialog(self, self.ctx.settings)
239
+ if not dlg.exec():
240
+ return
241
+ v = dlg.values()
242
+ s = self.ctx.settings
243
+ # Apply EVERY value the dialog returned that is a real Settings field. This used to be a
244
+ # hand-maintained whitelist, which meant adding a control to the dialog silently did
245
+ # nothing until you remembered to add its key here too — the edit was read, then dropped,
246
+ # and Settings saved the old value back. `hasattr` still guards against a typo'd key.
247
+ for k, val in v.items():
248
+ if hasattr(s, k):
249
+ setattr(s, k, val)
250
+ self.ctx.topology.prefix_overrides = dict(s.name_prefixes) # apply to current topo
251
+ from .theme.manager import scale_for
252
+ self.theme.set_font_scale(scale_for(v.get("text_size", "Normal"))) # live text-size switch
253
+ self.theme.set_theme(v["theme"]) # live theme switch
254
+ self._wire_llm() # re-create / clear the LLM loop
255
+ if getattr(self.ctx, "teaching_center", None) is not None:
256
+ self._connect_teaching_center() # already signed in → pick up the new details
257
+ # …but saving Settings does NOT sign you in. Signing in stays an explicit act (the User pill).
258
+ self._rebill() # prices may have changed
259
+ save_config({k: getattr(s, k) for k in PERSISTED_KEYS})
260
+ self.ctx.log("Settings saved to ~/.gini/config.json.", "ok")
261
+
262
+ def _connect_teaching_center(self) -> None:
263
+ """Enrol in the course from Settings, and refresh the toolbar's User pill.
264
+
265
+ Everything here is NETWORK — `online()`, the manifest, the profile — so it runs OFF the GUI
266
+ thread. It used to block startup and every Settings save for as long as the course server
267
+ took to answer (or to time out, which is worse). The pill is updated via a bus signal, the
268
+ same way the LLM health probe reports back."""
269
+ tc = self.ctx.connect_teaching_center()
270
+ if tc is None or not tc.signed_in(): # not signed in — a perfectly normal state
271
+ self.ctx.bus.enrolment_changed.emit("", False, 0)
272
+ return
273
+ import threading
274
+ student = self.ctx.settings.tc_student
275
+
276
+ def probe():
277
+ try:
278
+ online = tc.online()
279
+ if not online and tc.session_expired():
280
+ # the server ANSWERED and rejected us. Say "sign in again", not "you're offline"
281
+ self.ctx.log("Teaching Center: your session expired — sign in again from the "
282
+ "user menu.", "info")
283
+ self.ctx.bus.enrolment_changed.emit("", False, 0)
284
+ return
285
+ except Exception: # noqa: BLE001
286
+ online = False
287
+ # OTA: pull any teacher-authored fragments into the local content layer, so assigned
288
+ # experiments that reference them actually load. Version-gated on this end; a pack a newer
289
+ # engine authored is skipped-with-reason, never bricks the client.
290
+ if online:
291
+ try:
292
+ res = tc.pull_content()
293
+ if res["installed"] or res["skipped"] or res.get("removed"):
294
+ note = f"Content synced: {len(res['installed'])} fragment(s)"
295
+ if res.get("removed"):
296
+ note += f", {len(res['removed'])} removed"
297
+ if res["skipped"]:
298
+ note += f", {len(res['skipped'])} skipped (needs a newer gBuilder)"
299
+ self.ctx.log(note, "info")
300
+ except Exception: # noqa: BLE001 — a content pull never blocks sign-in
301
+ pass
302
+ # a TEACHER has no "assignments due" — that's a student notion. Emit -1 as the sentinel;
303
+ # the pill renders it as the teacher role, not a count.
304
+ if tc.is_teacher():
305
+ self.ctx.bus.enrolment_changed.emit(student, online, -1)
306
+ return
307
+ due = 0
308
+ try:
309
+ lessons = tc.available_lessons() # cached when offline — homework doesn't vanish
310
+ prof = tc.checkout_profile()
311
+ done = {lid for lid, rec in (prof.lessons or {}).items() if rec.completed}
312
+ due = sum(1 for m in lessons if m.get("id") not in done)
313
+ if online:
314
+ tc.flush() # push anything queued while offline
315
+ except Exception: # noqa: BLE001
316
+ pass
317
+ self.ctx.bus.enrolment_changed.emit(student, online, due)
318
+ threading.Thread(target=probe, daemon=True).start()
319
+
320
+ def _on_enrolment(self, student: str, online: bool, due: int) -> None:
321
+ self.mode_indicator.set_enrolment(student, online, due)
322
+ if not student:
323
+ return
324
+ if due < 0: # teacher
325
+ self.ctx.log(f"Teaching Center: signed in as {student} (teacher) to "
326
+ f"{self.ctx.settings.tc_course}.", "ok")
327
+ elif online:
328
+ self.ctx.log(f"Teaching Center: signed in as {student} to "
329
+ f"{self.ctx.settings.tc_course} — {due} mission"
330
+ f"{'s' if due != 1 else ''} due.", "ok")
331
+ else:
332
+ self.ctx.log("Teaching Center: offline — using the cached course; "
333
+ "results will sync when it's reachable.", "info")
334
+
335
+ # -- the User menu ------------------------------------------------------- #
336
+ def _sign_in(self) -> None:
337
+ """Sign in to the course — the explicit act.
338
+
339
+ A cached session means we can go straight through. Otherwise ask for a password (and, the
340
+ first time, the enrolment token that proves the account is yours to claim). The password is
341
+ exchanged for a session and never stored."""
342
+ from PySide6.QtWidgets import QMessageBox
343
+ from .signin_dialog import SignInDialog
344
+ from ..agent.teaching_center import InsecureTransport
345
+
346
+ force = self._force_new_signin # one-shot: force the dialog (sign in as someone else)
347
+ self._force_new_signin = False
348
+ s = self.ctx.settings
349
+ if not (s.tc_url and s.tc_course):
350
+ self.ctx.log("Teaching Center: set the course server and course in Settings first.",
351
+ "info")
352
+ self._open_settings()
353
+ return
354
+ if not s.tc_student and not force:
355
+ self._open_settings()
356
+ return
357
+
358
+ tc = self.ctx.connect_teaching_center()
359
+ if tc is None:
360
+ return
361
+ if not self._force_new_signin and tc.signed_in(): # a live session — nothing to ask
362
+ self.ctx.log(f"Teaching Center: resuming your session as {s.tc_student}…", "info")
363
+ self._connect_teaching_center()
364
+ return
365
+
366
+ dlg = SignInDialog(self, s, first_time=bool(s.tc_token))
367
+ if not dlg.exec():
368
+ self.ctx.teaching_center = None
369
+ return
370
+ v = dlg.values()
371
+ s.tc_student = v["student"]
372
+ tc = self.ctx.connect_teaching_center() # rebuild with the (possibly edited) student id
373
+ if tc is None:
374
+ return
375
+ try:
376
+ res = (tc.claim(v["password"], v["enrolment_token"]) if v["claim"]
377
+ else tc.login(v["password"]))
378
+ except InsecureTransport as e:
379
+ self.ctx.teaching_center = None
380
+ QMessageBox.warning(self, "Unencrypted connection", str(e))
381
+ return
382
+ if not res.get("ok"):
383
+ self.ctx.teaching_center = None
384
+ QMessageBox.warning(self, "Sign-in failed",
385
+ res.get("error") or "The course server rejected the sign-in.")
386
+ self.ctx.log(f"Teaching Center: sign-in failed — {res.get('error', '')}", "error")
387
+ return
388
+ if v["claim"]:
389
+ s.tc_token = "" # the enrolment token is spent; don't keep it around
390
+ self._persist_settings()
391
+ self.ctx.log(f"Teaching Center: signed in as {s.tc_student}.", "ok")
392
+ self._connect_teaching_center() # pull the manifest / profile, update the pill
393
+
394
+ def _sign_in_as(self) -> None:
395
+ """Sign in as a DIFFERENT user without editing Settings first — type a username here.
396
+
397
+ Used to switch between accounts on one machine (e.g. a student account and the teacher
398
+ account). Forces the sign-in dialog (bypassing any cached session) so you always get to enter
399
+ the username + password of whoever you want to become."""
400
+ from PySide6.QtWidgets import QInputDialog
401
+ who, ok = QInputDialog.getText(self, "Sign in as another user",
402
+ "Username:", text="")
403
+ if not ok or not who.strip():
404
+ return
405
+ # switching identity: drop the current session and start fresh as the typed username
406
+ tc = getattr(self.ctx, "teaching_center", None)
407
+ if tc is not None:
408
+ import threading
409
+ threading.Thread(target=tc.logout, daemon=True).start()
410
+ self.ctx.teaching_center = None
411
+ self.ctx.settings.tc_student = who.strip()
412
+ self.ctx.settings.tc_token = "" # a different account's enrolment token isn't this one's
413
+ self._force_new_signin = True
414
+ self._sign_in()
415
+
416
+ def _sign_out(self) -> None:
417
+ """Go local: drop the session (server-side too, so a shared machine doesn't stay signed in).
418
+ Your student id stays in Settings; anything unsent stays queued for the next sign-in."""
419
+ tc = getattr(self.ctx, "teaching_center", None)
420
+ if tc is not None:
421
+ import threading
422
+ threading.Thread(target=tc.logout, daemon=True).start() # network — never on the GUI thread
423
+ self.ctx.teaching_center = None
424
+ self.ctx.bus.enrolment_changed.emit("", False, 0)
425
+ self.ctx.log("Teaching Center: signed out — Missions now offers the practice catalog.",
426
+ "info")
427
+
428
+ def _user_menu(self) -> None:
429
+ """The User pill's menu: who you are, what you owe, what you've done."""
430
+ from PySide6.QtWidgets import QMenu
431
+ s = self.ctx.settings
432
+ tc = getattr(self.ctx, "teaching_center", None)
433
+ m = QMenu(self)
434
+
435
+ head = m.addAction(f"Signed in as {s.tc_student} · {s.tc_course}" if tc
436
+ else "Not signed in")
437
+ head.setEnabled(False)
438
+ m.addSeparator()
439
+
440
+ if tc is None:
441
+ self._add_signin_items(m)
442
+ else:
443
+ teacher = tc.is_teacher()
444
+ if not teacher: # 'Due / Completed' is a student view
445
+ self._add_mission_items(m, tc)
446
+ m.addSeparator()
447
+ self._add_teacher_items(m, tc)
448
+ m.addAction("Messages…").triggered.connect(self._open_messages)
449
+ m.addAction("Set my photo…").triggered.connect(self._set_photo)
450
+ if not teacher: # groups + AI-proxy are student notions
451
+ self._add_group_items(m, tc)
452
+ m.addAction("AI may answer for me…").triggered.connect(self._ai_proxy_consent)
453
+ m.addSeparator()
454
+ m.addAction("Sync now").triggered.connect(self._connect_teaching_center)
455
+ m.addAction("Sign in as another user…").triggered.connect(self._sign_in_as)
456
+ m.addAction("Sign out").triggered.connect(self._sign_out)
457
+
458
+ m.addSeparator()
459
+ m.addAction("Settings…").triggered.connect(self._open_settings)
460
+ m.exec(self.mode_indicator.mapToGlobal(
461
+ self.mode_indicator.rect().bottomRight()))
462
+
463
+ def _add_mission_items(self, menu, tc) -> None:
464
+ """Due missions (click to play) and what you've already finished, with the band you earned.
465
+ Read from the CACHED manifest + local profile, so the menu opens instantly and works offline
466
+ — it never blocks on the course server."""
467
+ try:
468
+ lessons = tc.available_lessons()
469
+ prof = tc.checkout_profile()
470
+ recs = prof.lessons or {}
471
+ except Exception: # noqa: BLE001
472
+ menu.addAction("(couldn't read your course — try Sync now)").setEnabled(False)
473
+ return
474
+
475
+ done = {lid for lid, r in recs.items() if r.completed}
476
+ due = [x for x in lessons if x.get("id") not in done]
477
+
478
+ cap = menu.addAction(f"Due — {len(due)}" if due else "Nothing due — you're clear")
479
+ cap.setEnabled(False)
480
+ for x in due:
481
+ a = menu.addAction(" ▸ " + (x.get("title") or x["id"]))
482
+ a.triggered.connect(lambda _=False, lid=x["id"]: self._play_assigned(lid))
483
+
484
+ if done:
485
+ menu.addSeparator()
486
+ cap = menu.addAction(f"Completed — {len(done)}")
487
+ cap.setEnabled(False)
488
+ titles = {x["id"]: (x.get("title") or x["id"]) for x in lessons}
489
+ for lid in sorted(done):
490
+ band = (recs[lid].best_band or "").upper()
491
+ a = menu.addAction(f" ✓ {titles.get(lid, lid)} · {band}")
492
+ a.setEnabled(False) # history, not a launcher
493
+
494
+ def _play_assigned(self, lesson_id: str) -> None:
495
+ if self.assistant.enter_missions():
496
+ self.assistant._start_assigned_mission(lesson_id)
497
+
498
+ # -- groups, messages, AI consent (Phases B–E) ---------------------------- #
499
+ def _add_group_items(self, menu, tc) -> None:
500
+ """Your team, and where they are on the mission. A class with no groups simply has no group
501
+ section — absence, not an error."""
502
+ try:
503
+ g = tc.my_group()
504
+ except Exception: # noqa: BLE001
505
+ return
506
+ if not g.get("group"):
507
+ return
508
+ cap = menu.addAction(f"Group {g['group']}")
509
+ cap.setEnabled(False)
510
+ gid = f"group:{g['group']}"
511
+ menu.addAction(" Open group chat").triggered.connect(
512
+ lambda _=False, c=gid: self.assistant.open_conversation(c))
513
+ for m in g.get("members", []):
514
+ pr = m.get("progress") or {}
515
+ where = (f"Level {pr['level']}" if pr.get("level") else "—")
516
+ dot = "●" if m.get("online") else "○"
517
+ label = f" {dot} {m['name']}" + (" (you)" if m.get("me") else f" · {where}")
518
+ act = menu.addAction(label)
519
+ if m.get("me"):
520
+ act.setEnabled(False)
521
+ else: # click a teammate → open the DM
522
+ peer = m["id"]
523
+ act.triggered.connect(
524
+ lambda _=False, p=peer: self.assistant.open_conversation(
525
+ "dm:" + "|".join(sorted((self.ctx.settings.tc_student, p)))))
526
+
527
+ def _open_messages(self) -> None:
528
+ """Messages live IN the Ask GINI panel now — one conversation surface, GINI and people
529
+ together. Jump to the instructor thread."""
530
+ student = self.ctx.settings.tc_student
531
+ self.assistant.open_conversation(f"teacher:{student}")
532
+
533
+ def _set_photo(self) -> None:
534
+ """Pick an image → downscale to a small square → upload as a data-URL. Downscaling on the
535
+ client keeps the DB tiny (a phone photo is megabytes; the roster needs a thumbnail), and it
536
+ means we never ship the original off the machine."""
537
+ from PySide6.QtWidgets import QFileDialog, QMessageBox
538
+ tc = getattr(self.ctx, "teaching_center", None)
539
+ if tc is None:
540
+ return
541
+ path, _ = QFileDialog.getOpenFileName(
542
+ self, "Choose a profile photo", "", "Images (*.png *.jpg *.jpeg *.gif *.bmp)")
543
+ if not path:
544
+ return
545
+ data_url = _photo_data_url(path)
546
+ if not data_url:
547
+ QMessageBox.warning(self, "Couldn't read that", "That file isn't an image I can read.")
548
+ return
549
+
550
+ import threading
551
+ def work():
552
+ res = tc.set_photo(data_url)
553
+ self.ctx.log("Photo updated — your instructor will see it." if res.get("ok")
554
+ else f"Couldn't set photo: {res.get('error', 'unknown error')}",
555
+ "ok" if res.get("ok") else "error")
556
+ threading.Thread(target=work, daemon=True).start()
557
+
558
+ def _add_signin_items(self, menu) -> None:
559
+ """The signed-out user menu: sign in as the saved user, or type a different username."""
560
+ s = self.ctx.settings
561
+ if s.tc_student:
562
+ menu.addAction(f"Sign in as {s.tc_student}…").triggered.connect(self._sign_in)
563
+ menu.addAction("Sign in as another user…").triggered.connect(self._sign_in_as)
564
+
565
+ def _add_teacher_items(self, menu, tc) -> None:
566
+ """TEACHER MODE — author fragments. Unlocked only when signed in as a teacher; students never
567
+ see it. The TC session's role is the single source of truth."""
568
+ if not tc.is_teacher():
569
+ return
570
+ cap = menu.addAction("Teacher tools")
571
+ cap.setEnabled(False)
572
+ menu.addAction("Fragment Manager…").triggered.connect(self._fragment_manager)
573
+ menu.addAction("Playtest an experiment…").triggered.connect(self._playtest_experiment)
574
+ menu.addSeparator()
575
+
576
+ def _playtest_experiment(self) -> None:
577
+ """Teacher: play a DRAFT experiment before approving it — the approval gate's playtest half.
578
+ Lists all experiments (draft + released); playing one drops into it as a mission so the
579
+ teacher proves the whole composition is winnable against the real engine."""
580
+ from PySide6.QtWidgets import QInputDialog, QMessageBox
581
+ tc = getattr(self.ctx, "teaching_center", None)
582
+ if tc is None or not tc.is_teacher():
583
+ return
584
+ lessons = tc.list_lessons()
585
+ if not lessons:
586
+ QMessageBox.information(self, "Playtest", "No experiments yet — compose one in the "
587
+ "Teaching Center console first.")
588
+ return
589
+ labels = [f"{le.get('title', le['id'])} · {le.get('status', 'released')}" for le in lessons]
590
+ pick, ok = QInputDialog.getItem(self, "Playtest an experiment",
591
+ "Draft experiments are only visible to you until approved:",
592
+ labels, 0, False)
593
+ if not ok:
594
+ return
595
+ lid = lessons[labels.index(pick)]["id"]
596
+ if self.assistant.enter_missions():
597
+ self.assistant._start_assigned_mission(lid)
598
+
599
+ def _fragment_manager(self) -> None:
600
+ """Open the Fragment Manager (teacher mode): list / create / edit / delete fragments, with a
601
+ recording editor that reads objectives off the live canvas.
602
+
603
+ Shown NON-MODALLY (show(), not exec()) — recording needs the canvas to stay interactive, and a
604
+ modal dialog would swallow every drag-and-drop into the board. Kept on `self` so it isn't
605
+ garbage-collected while it floats."""
606
+ from .fragment_manager import FragmentManager
607
+ existing = getattr(self, "_frag_mgr", None)
608
+ if existing is not None:
609
+ existing.raise_(); existing.activateWindow()
610
+ return
611
+ self._frag_mgr = FragmentManager(self, self.ctx, author=self.ctx.settings.tc_student)
612
+ self._frag_mgr.destroyed.connect(lambda *_: setattr(self, "_frag_mgr", None))
613
+ self._frag_mgr.show()
614
+ self._frag_mgr.raise_()
615
+
616
+ def _ai_proxy_consent(self) -> None:
617
+ """'May an AI answer on my behalf when I'm away?' — the student's own call, and only if the
618
+ instructor granted hosting. Either one alone is not consent."""
619
+ from PySide6.QtWidgets import QInputDialog, QMessageBox
620
+ tc = getattr(self.ctx, "teaching_center", None)
621
+ if tc is None:
622
+ return
623
+ choice, ok = QInputDialog.getItem(
624
+ self, "AI may answer for me",
625
+ "When you're away, may an AI answer your groupmates on your behalf?\n"
626
+ "(It is always labelled as an AI. It never speaks for you about anything personal,\n"
627
+ "and it refuses deadlines, exams and grades.)",
628
+ ["No — my messages just wait for me", "Yes — let it answer about coursework"], 0, False)
629
+ if not ok:
630
+ return
631
+ on = choice.startswith("Yes")
632
+ blurb = ""
633
+ if on:
634
+ blurb, _ = QInputDialog.getText(
635
+ self, "Anything it should know?",
636
+ "One line about what you're working on (optional):")
637
+ res = tc.set_ai_proxy(on, blurb or "")
638
+ if not res.get("ok"):
639
+ QMessageBox.information(self, "Not available",
640
+ res.get("error", "Your instructor hasn't enabled this."))
641
+ return
642
+ self.ctx.log(f"AI proxy {'enabled' if on else 'disabled'}.", "ok")
643
+
644
+ def _heartbeat(self) -> None:
645
+ """Tell the course server we're here, and where we are on the current mission — that's what
646
+ makes a group view worth opening. Off the GUI thread; a failed beat is a non-event.
647
+
648
+ Defensive throughout, because this fires on a QTimer: an exception raised here lands in the
649
+ Qt event loop (noisy in the app, and fatal under pytest-qt, where it fails whichever test
650
+ happens to be running). A client that can't beat — no client, a stub, a half-built one — is
651
+ simply skipped."""
652
+ tc = getattr(self.ctx, "teaching_center", None)
653
+ signed_in = getattr(tc, "signed_in", None)
654
+ beat = getattr(tc, "heartbeat", None)
655
+ if not callable(signed_in) or not callable(beat):
656
+ return
657
+ try:
658
+ if not signed_in():
659
+ return
660
+ except Exception: # noqa: BLE001 — an unreachable centre
661
+ return
662
+ progress = {}
663
+ try:
664
+ ctrl = getattr(self.assistant, "_mission_ctrl", None)
665
+ m = getattr(ctrl, "mission", None) if ctrl else None
666
+ if m is not None:
667
+ sc = m.score()
668
+ res = m.last_results or []
669
+ level = next((r.level for r in res if r.status != "met"), 4)
670
+ progress = {"lesson_id": m.lesson.id, "title": m.lesson.title, "level": level,
671
+ "met": sc.met, "total": sc.total, "band": sc.band}
672
+ except Exception: # noqa: BLE001
673
+ progress = {}
674
+
675
+ def _beat() -> None: # swallow in the worker too — a dropped
676
+ try: # beat must never surface as a warning
677
+ beat(progress)
678
+ except Exception: # noqa: BLE001
679
+ pass
680
+ import threading
681
+ threading.Thread(target=_beat, daemon=True).start()
682
+
683
+ def _persist_settings(self) -> None:
684
+ """Save the current Settings to ~/.gini/config.json (used by the Cue Cards tour
685
+ when the user toggles 'show at launch' / voice-over)."""
686
+ from ..app.paths import PERSISTED_KEYS, save_config
687
+ save_config({k: getattr(self.ctx.settings, k) for k in PERSISTED_KEYS})
688
+
689
+ def show_feature_tour(self) -> None:
690
+ from .cue_cards import CueCards
691
+ CueCards(self, self.theme, self.ctx.settings, persist=self._persist_settings).exec()
692
+
693
+ def maybe_start_tour(self) -> None:
694
+ """Open the Cue Cards tour at launch unless the user turned it off (called from
695
+ __main__ after the window is shown, so widget tests never trigger the modal)."""
696
+ if self.ctx.settings.show_help_on_launch:
697
+ self.show_feature_tour()
698
+
699
+ def _apply_env_settings(self) -> None:
700
+ import os
701
+ s = self.ctx.settings
702
+ if os.environ.get("GINI_LLM_URL"):
703
+ s.llm_url = os.environ["GINI_LLM_URL"]
704
+ s.llm_enabled = True
705
+ if os.environ.get("GINI_LLM_MODEL"):
706
+ s.llm_model = os.environ["GINI_LLM_MODEL"]
707
+ if os.environ.get("GINI_LLM_THINK"):
708
+ s.llm_think = os.environ["GINI_LLM_THINK"] not in ("0", "false", "")
709
+ if os.environ.get("GINI_REDUCED_MOTION"):
710
+ s.reduced_motion = True
711
+
712
+ def _ai_context(self) -> str:
713
+ """Live canvas snapshot fed to the assistant each turn (topology + run-state)."""
714
+ digest = self.api.context_digest()
715
+ state = "running on Docker" if self._running else "not running (idle, editable)"
716
+ ctx = f"{digest}\nRuntime: the topology is {state}."
717
+ m = getattr(self.ctx, "mission", None) # Wizard: keep follow-ups goal-aware
718
+ if m is not None:
719
+ ctx += f"\nThe student's current build objective is: \"{m.goal}\"."
720
+ return ctx
721
+
722
+ def _wire_llm(self) -> None:
723
+ s = self.ctx.settings
724
+ if not s.llm_enabled:
725
+ self.assistant.set_loop(None) # clear any existing loop
726
+ self.mode_indicator.set_model("", False) # toolbar Model pill -> "no model"
727
+ self.ctx.log("GINI AI: offline mode (deterministic). Enable a local LLM in "
728
+ "Settings (or set GINI_LLM_URL).", "info")
729
+ return
730
+ try:
731
+ from ..agent.llm import OllamaBackend
732
+ from ..agent.loop import AgentLoop
733
+ backend = OllamaBackend(s.llm_url, s.llm_model, think=s.llm_think,
734
+ num_ctx=getattr(s, "llm_num_ctx", 8192),
735
+ embed_model=getattr(s, "llm_embed_model", "all-minilm"))
736
+ self.assistant.set_loop(AgentLoop(backend, self.registry,
737
+ context_provider=self._ai_context))
738
+ self.mode_indicator.set_model(s.llm_model, True) # optimistic; probe corrects
739
+ # Check reachability OFF the GUI thread — backend.available() does a blocking
740
+ # urlopen (up to 3s), which would freeze the UI on startup / settings-save.
741
+ import threading
742
+ url, model = s.llm_url, s.llm_model
743
+
744
+ def probe():
745
+ try:
746
+ ok = backend.available()
747
+ except Exception:
748
+ ok = False
749
+ self.ctx.bus.llm_reachable.emit(model, ok)
750
+ threading.Thread(target=probe, daemon=True).start()
751
+ except Exception as e: # never let LLM wiring break startup
752
+ self.mode_indicator.set_model("", False)
753
+ self.ctx.log(f"GINI AI: LLM unavailable ({e}); offline mode.", "info")
754
+
755
+ def _on_llm_reachable(self, model: str, ok: bool) -> None:
756
+ s = self.ctx.settings
757
+ self.mode_indicator.set_model(model, ok) # green if up, amber if not
758
+ if ok:
759
+ self.ctx.log(f"GINI AI: connected to {model} at {s.llm_url}.", "ok")
760
+ else:
761
+ self.ctx.log(f"GINI AI: set to {model} at {s.llm_url}, but the server isn't "
762
+ f"responding. Is Ollama running? (run: ollama serve)", "error")
763
+
764
+ # -- toolbar ------------------------------------------------------------ #
765
+ def _make_toolbar(self) -> None:
766
+ tb = QToolBar("Main")
767
+ tb.setMovable(False)
768
+ tb.setFloatable(False)
769
+ self.addToolBar(tb)
770
+ self._tb = tb
771
+ self._actions: dict[str, QAction] = {}
772
+ self._tb_buttons: dict[str, QToolButton] = {}
773
+
774
+ def act(key: str, icon: str, text: str, slot, checkable=False) -> QAction:
775
+ a = QAction(text, self)
776
+ a.setCheckable(checkable)
777
+ a.triggered.connect(slot)
778
+ self._actions[key] = (a, icon)
779
+ return a
780
+
781
+ # build the actions (same wiring as before — checkable, enable/disable, slots)
782
+ # The LEFT tray works INSIDE the current project — the express menu for its experiments.
783
+ # (Project-level operations — switch/create/delete a project — live on the centred
784
+ # navigator, so the two surfaces mean different things instead of duplicating each other.)
785
+ act("new", "new", "New experiment in this project", self._new_experiment)
786
+ act("open", "open", "Open an experiment from this project", self._open_experiment)
787
+ act("save", "save", "Save this experiment", self._save_project)
788
+ act("compile", "compile", "Compile", self._compile)
789
+ act("layout", "layout", "Arrange", self._auto_layout)
790
+ self._connect_act = act("connect", "link", "Connect", self._toggle_connect, checkable=True)
791
+ self._edges_act = act("edges", "elbow",
792
+ "Connector style: bent ↔ straight",
793
+ self._toggle_edge_style, checkable=True)
794
+ self._edges_act.setChecked(self.ctx.settings.connector_style == "orthogonal")
795
+ self._manual_addr_act = act("manualaddr", "pencil",
796
+ "Manual addressing — assign IP addresses by hand",
797
+ self._toggle_manual_addr, checkable=True)
798
+ self._manual_addr_act.setChecked(self.ctx.topology.manual_addressing)
799
+ self._delete_act = act("delete", "trash", "Delete selected device", self._delete_selected)
800
+ self._delete_act.setEnabled(False)
801
+ self._run_act = act("run", "play", "Run", self._run)
802
+ self._stop_act = act("stop", "stop", "Stop", self._stop)
803
+ self._server_act = act("server", "cloud",
804
+ "Backend: run on a remote Kata GINI server (or go local)",
805
+ self._toggle_backend, checkable=True)
806
+ act("zoom_in", "plus", "Zoom in", lambda: self.canvas.zoom_by(1.15))
807
+ act("zoom_out", "minus", "Zoom out", lambda: self.canvas.zoom_by(1 / 1.15))
808
+
809
+ def button(key: str, *, labelled=False, oname="") -> QToolButton:
810
+ a, _ = self._actions[key]
811
+ b = QToolButton(tb)
812
+ b.setDefaultAction(a) # mirrors icon/checkable/enabled/triggered
813
+ b.setAutoRaise(True)
814
+ if labelled:
815
+ b.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
816
+ if oname:
817
+ b.setObjectName(oname)
818
+ self._tb_buttons[key] = b
819
+ return b
820
+
821
+ def tray(keys, *, name="TbGroup") -> QWidget:
822
+ """A rounded segmented cluster of icon buttons."""
823
+ f = QFrame(tb); f.setObjectName(name)
824
+ lay = QHBoxLayout(f); lay.setContentsMargins(3, 3, 3, 3); lay.setSpacing(1)
825
+ for k in keys:
826
+ lay.addWidget(button(k))
827
+ return f
828
+
829
+ from PySide6.QtWidgets import QHBoxLayout, QMenu, QSizePolicy
830
+
831
+ def _cluster(build) -> QWidget:
832
+ """A toolbar cluster in an EXPANDING container. Left and right clusters both
833
+ expand, so they take equal widths — which lands the middle chip at the exact
834
+ centre regardless of how much each side actually holds."""
835
+ w = QWidget()
836
+ lay = QHBoxLayout(w); lay.setContentsMargins(0, 0, 0, 0); lay.setSpacing(0)
837
+ build(lay)
838
+ w.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
839
+ return w
840
+
841
+ # LEFT cluster: File · Tools · Run · Zoom, packed to the left.
842
+ from .run_button import RunButton
843
+ self.run_button = RunButton(self.theme)
844
+ self.run_button.clicked.connect(self._toggle_run)
845
+
846
+ def _build_left(lay) -> None:
847
+ lay.addWidget(tray(("new", "open", "save")))
848
+ lay.addWidget(self._tb_spacer(6))
849
+ lay.addWidget(tray(("compile", "layout", "connect", "edges", "manualaddr", "delete")))
850
+ lay.addWidget(self._tb_spacer(8))
851
+ lay.addWidget(self.run_button) # morphing ▶/■ power button
852
+ lay.addWidget(self._tb_spacer(8))
853
+ lay.addWidget(tray(("zoom_in", "zoom_out")))
854
+ lay.addStretch(1) # push the cluster left
855
+
856
+ # RIGHT cluster: mode/model/activity pills · theme picker, packed to the right.
857
+ from .mode_indicator import ModeIndicator
858
+ self.mode_indicator = ModeIndicator(self.theme)
859
+ self._theme_btn = QToolButton(tb)
860
+ self._theme_btn.setToolTip("Theme")
861
+ self._theme_btn.setAutoRaise(True)
862
+ self._theme_btn.setPopupMode(QToolButton.InstantPopup)
863
+ from .theme.tokens import get_theme
864
+ menu = QMenu(self)
865
+ grp = QActionGroup(self)
866
+ self._theme_actions: dict[str, QAction] = {}
867
+
868
+ def add_theme(name: str) -> None:
869
+ a = QAction(_theme_swatch(get_theme(name)), name, self)
870
+ a.setCheckable(True)
871
+ a.setChecked(name.lower() == self.theme.theme.name.lower())
872
+ a.triggered.connect(lambda _=False, n=name: self.theme.set_theme(n))
873
+ grp.addAction(a); menu.addAction(a)
874
+ self._theme_actions[name] = a
875
+
876
+ for name in ("Light", "Sand", "Blue", "Green"): # light family, lightest first
877
+ add_theme(name)
878
+ menu.addSeparator() # divider between the families
879
+ for name in ("Dark", "GINI Brand", "High Contrast"): # dark family
880
+ add_theme(name)
881
+ self._theme_btn.setMenu(menu)
882
+
883
+ def _build_right(lay) -> None:
884
+ lay.addStretch(1) # push the cluster right
885
+ lay.addWidget(self.mode_indicator)
886
+ lay.addWidget(self._tb_spacer(8))
887
+ lay.addWidget(self._theme_btn)
888
+
889
+ # assemble: [ left (expand) ] [ centred project chip ] [ right (expand) ]
890
+ self._make_nav_button()
891
+ tb.addWidget(_cluster(_build_left))
892
+ tb.addWidget(self._nav_btn)
893
+ tb.addWidget(_cluster(_build_right))
894
+ self._refresh_icons()
895
+
896
+ @staticmethod
897
+ def _tb_spacer(width: int) -> QWidget:
898
+ w = QWidget(); w.setFixedWidth(width)
899
+ return w
900
+
901
+ # -- project navigator -------------------------------------------------- #
902
+ def _make_nav_button(self) -> None:
903
+ from PySide6.QtWidgets import QMenu, QToolButton
904
+ self._nav_btn = QToolButton(self)
905
+ self._nav_btn.setObjectName("NavBtn")
906
+ self._nav_btn.setText("Untitled")
907
+ self._nav_btn.setToolButtonStyle(Qt.ToolButtonTextBesideIcon)
908
+ self._nav_btn.setPopupMode(QToolButton.InstantPopup)
909
+ self._nav_btn.setToolTip("Project — switch, create, save, or set the AI brief")
910
+ menu = QMenu(self)
911
+ menu.aboutToShow.connect(lambda: self._build_nav_menu(menu))
912
+ self._nav_btn.setMenu(menu)
913
+
914
+ def _build_nav_menu(self, menu) -> None:
915
+ """The centred navigator is PROJECT-level: which project am I in, and switch/create/
916
+ delete/list them. Experiments inside the current project are the left tray's job."""
917
+ from ..app.paths import projects_dir
918
+ from ..services import list_projects
919
+ menu.clear()
920
+ header = menu.addAction(f"● {self._project_name()}")
921
+ header.setEnabled(False)
922
+ sub = menu.addAction(f" {len(self._experiments())} experiment(s) · "
923
+ f"now: {self._experiment or '—'}")
924
+ sub.setEnabled(False)
925
+ menu.addSeparator()
926
+ menu.addAction("New project…", self._new_project)
927
+ menu.addAction("Open project…", self._open_project_dialog)
928
+ menu.addSeparator()
929
+ projects = list_projects(projects_dir())[:8]
930
+ if projects:
931
+ r = menu.addAction("Switch project"); r.setEnabled(False)
932
+ for info in projects:
933
+ n = info.get("experiments", 0)
934
+ act = menu.addAction(f" {info['name']}" + (f" ({n})" if n else ""))
935
+ act.setCheckable(True)
936
+ act.setChecked(info["path"] == self._project_dir)
937
+ act.triggered.connect(lambda _=False, p=info["path"]: self._switch_project(p))
938
+ menu.addSeparator()
939
+ menu.addAction("Edit project brief…", self._edit_brief)
940
+ rev = menu.addAction("Reveal project folder", self._reveal_project)
941
+ rev.setEnabled(self._project_dir is not None)
942
+ menu.addSeparator()
943
+ ren = menu.addAction("Rename project…", self._rename_project)
944
+ ren.setEnabled(self._project_dir is not None)
945
+ dele = menu.addAction("Delete project…", self._delete_project)
946
+ dele.setEnabled(self._project_dir is not None)
947
+
948
+ def _set_project_label(self, name: str) -> None:
949
+ if hasattr(self, "_nav_btn"):
950
+ self._nav_btn.setText(name or "Untitled")
951
+
952
+ def _project_name(self) -> str:
953
+ from pathlib import Path
954
+ return Path(self._project_dir).name if self._project_dir else "Untitled"
955
+
956
+ def _experiments(self) -> list[str]:
957
+ from ..services import list_experiments
958
+ if not self._project_dir:
959
+ return []
960
+ return [e["name"] for e in list_experiments(self._project_dir)]
961
+
962
+ def _show_where(self) -> None:
963
+ """The nav chip names the project; the title adds › experiment once there's a choice to
964
+ make. With a single experiment the suffix is noise, so it's left off."""
965
+ proj = self._project_name()
966
+ self._set_project_label(proj)
967
+ exp = self._experiment
968
+ many = len(self._experiments()) > 1
969
+ self.setWindowTitle(f"gBuilder 6.0 — {proj}" + (f" › {exp}" if exp and many else ""))
970
+
971
+ # -- experiments (inside the current project) ---------------------------- #
972
+ def _new_experiment(self) -> None:
973
+ """Add another experiment to this project. The brief and the Ask GINI conversation are
974
+ project-level, so they carry over — that's the whole point of grouping experiments."""
975
+ from PySide6.QtWidgets import QInputDialog
976
+ from ..services import experiment_path
977
+ if self._switch_blocked():
978
+ return
979
+ if not self._project_dir:
980
+ self._save_project_as() # no project yet — make one first
981
+ if not self._project_dir:
982
+ return
983
+ name, ok = QInputDialog.getText(self, "New experiment", "Experiment name:")
984
+ name = (name or "").strip()
985
+ if not ok or not name:
986
+ return
987
+ if experiment_path(self._project_dir, name).exists():
988
+ self.ctx.log(f"“{name}” already exists in this project.", "info")
989
+ return
990
+ self._persist_current_project() # keep the outgoing experiment
991
+ self._router_programs.clear()
992
+ from ..domain.topology import Topology
993
+ self._experiment = name
994
+ self._set_topology(Topology(name))
995
+ self.assistant.note_experiment(name) # the canvas changed — tell the tutor
996
+ self._persist_current_project() # materialise the new file
997
+ self._show_where()
998
+ self.ctx.log(f"New experiment “{name}” in “{self._project_name()}”.", "ok")
999
+
1000
+ def _open_experiment(self) -> None:
1001
+ """Pick another experiment in this project (the left tray's express switcher)."""
1002
+ from PySide6.QtWidgets import QInputDialog
1003
+ if self._switch_blocked():
1004
+ return
1005
+ names = self._experiments()
1006
+ if not names:
1007
+ self.ctx.log("This project has no other experiments yet — press + to add one.", "info")
1008
+ return
1009
+ cur = self._experiment if self._experiment in names else names[0]
1010
+ name, ok = QInputDialog.getItem(self, "Open experiment",
1011
+ f"Experiments in “{self._project_name()}”:",
1012
+ names, names.index(cur), False)
1013
+ if ok and name:
1014
+ self._switch_experiment(name)
1015
+
1016
+ def _switch_experiment(self, name: str) -> None:
1017
+ from ..services import load_experiment
1018
+ if not self._project_dir or name == self._experiment or self._switch_blocked():
1019
+ return
1020
+ self._persist_current_project() # save the one we're leaving
1021
+ try:
1022
+ topo = load_experiment(self._project_dir, name)
1023
+ except Exception as e: # noqa: BLE001
1024
+ self.ctx.log(f"Couldn't open “{name}”: {e}", "error")
1025
+ return
1026
+ self._router_programs.clear()
1027
+ self._experiment = name
1028
+ self._set_topology(topo)
1029
+ self.assistant.note_experiment(name) # conversation continues, canvas changed
1030
+ self._persist_current_project() # remember which one is current
1031
+ self._show_where()
1032
+ self.ctx.log(f"Opened experiment “{name}”.", "ok")
1033
+
1034
+ def _rename_experiment(self) -> None:
1035
+ from PySide6.QtWidgets import QInputDialog
1036
+ from ..services import rename_experiment
1037
+ if not (self._project_dir and self._experiment) or self._switch_blocked():
1038
+ return
1039
+ new, ok = QInputDialog.getText(self, "Rename experiment", "New name:",
1040
+ text=self._experiment)
1041
+ new = (new or "").strip()
1042
+ if not ok or not new or new == self._experiment:
1043
+ return
1044
+ self._persist_current_project()
1045
+ if not rename_experiment(self._project_dir, self._experiment, new):
1046
+ self.ctx.log(f"Couldn't rename to “{new}” (does it already exist?).", "info")
1047
+ return
1048
+ self._experiment = new
1049
+ self.ctx.topology.name = new
1050
+ self._persist_current_project()
1051
+ self._show_where()
1052
+ self.ctx.log(f"Renamed experiment to “{new}”.", "ok")
1053
+
1054
+ def _delete_experiment(self) -> None:
1055
+ from PySide6.QtWidgets import QMessageBox
1056
+ from ..services import delete_experiment
1057
+ if not (self._project_dir and self._experiment) or self._switch_blocked():
1058
+ return
1059
+ names = self._experiments()
1060
+ if len(names) <= 1:
1061
+ self.ctx.log("This is the project's only experiment — delete the project instead.",
1062
+ "info")
1063
+ return
1064
+ if QMessageBox.question(self, "Delete experiment",
1065
+ f"Delete “{self._experiment}” from this project?\n"
1066
+ f"The project's brief and AI conversation are kept."
1067
+ ) != QMessageBox.Yes:
1068
+ return
1069
+ gone = self._experiment
1070
+ delete_experiment(self._project_dir, gone)
1071
+ nxt = next((n for n in self._experiments() if n != gone), None)
1072
+ if nxt:
1073
+ self._experiment = None # force the switch to actually load
1074
+ self._switch_experiment(nxt)
1075
+ self.ctx.log(f"Deleted experiment “{gone}”.", "ok")
1076
+
1077
+ # -- project operations ------------------------------------------------- #
1078
+ def _switch_blocked(self) -> bool:
1079
+ """Switching projects would pull the topology out from under a running lab."""
1080
+ if self._running or getattr(self, "_stopping", False):
1081
+ self.ctx.log("Stop the topology before switching projects.", "info")
1082
+ return True
1083
+ return False
1084
+
1085
+ def _new_project(self) -> None:
1086
+ from pathlib import Path
1087
+ from PySide6.QtWidgets import QInputDialog
1088
+ from ..app.paths import ensure_dirs, projects_dir
1089
+ from ..domain import Topology
1090
+ if self._switch_blocked():
1091
+ return
1092
+ name, ok = QInputDialog.getText(self, "New project", "Project name:")
1093
+ if not ok or not name.strip():
1094
+ return
1095
+ name = name.strip()
1096
+ ensure_dirs()
1097
+ folder = projects_dir() / name
1098
+ if folder.exists():
1099
+ self.ctx.log(f"A project named “{name}” already exists.", "info")
1100
+ return
1101
+ from ..services.project import FIRST_EXPERIMENT
1102
+ self._persist_current_project() # save whatever we were on
1103
+ self._project_dir = str(folder)
1104
+ self._project_path = None
1105
+ self._experiment = FIRST_EXPERIMENT # neutral — never the project's own name
1106
+ self._router_programs.clear()
1107
+ self._set_topology(Topology(name))
1108
+ self.assistant.clear_conversation() # a NEW project = a fresh context (unlike a
1109
+ self._show_where() # new experiment, which keeps the conversation)
1110
+ self._persist_current_project() # materialise the folder on disk
1111
+ self.ctx.log(f"New project “{name}”.", "ok")
1112
+
1113
+ def _rename_project(self) -> None:
1114
+ from pathlib import Path
1115
+ from PySide6.QtWidgets import QInputDialog
1116
+ from ..app.paths import projects_dir, remember_project
1117
+ if self._project_dir is None or self._switch_blocked():
1118
+ return
1119
+ cur = Path(self._project_dir)
1120
+ name, ok = QInputDialog.getText(self, "Rename project", "New name:", text=cur.name)
1121
+ if not ok or not name.strip() or name.strip() == cur.name:
1122
+ return
1123
+ name = name.strip()
1124
+ dest = projects_dir() / name
1125
+ if dest.exists():
1126
+ self.ctx.log(f"A project named “{name}” already exists.", "info")
1127
+ return
1128
+ self._persist_current_project() # flush current work to the old folder first
1129
+ try:
1130
+ cur.rename(dest) # rename the folder on disk
1131
+ except OSError as e:
1132
+ self.ctx.log(f"Couldn't rename project: {e}", "error")
1133
+ return
1134
+ self._project_dir = str(dest)
1135
+ self.ctx.topology.name = name
1136
+ self._set_project_label(name)
1137
+ self.setWindowTitle(f"gBuilder 6.0 — {name}")
1138
+ remember_project(str(dest))
1139
+ self._persist_current_project() # rewrite metadata under the new name
1140
+ self.ctx.log(f"Renamed project to “{name}”.", "ok")
1141
+
1142
+ def _delete_project(self) -> None:
1143
+ import shutil
1144
+ from pathlib import Path
1145
+ from PySide6.QtWidgets import QMessageBox
1146
+ from ..app.paths import projects_dir
1147
+ from ..domain import Topology
1148
+ from ..services import list_projects
1149
+ if self._project_dir is None or self._switch_blocked():
1150
+ return
1151
+ cur = Path(self._project_dir)
1152
+ name = cur.name
1153
+ if QMessageBox.question(
1154
+ self, "Delete project",
1155
+ f"Delete project “{name}” and all its files?\nThis cannot be undone.",
1156
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No) != QMessageBox.Yes:
1157
+ return
1158
+ self._project_dir = None # stop any later save from recreating it
1159
+ try:
1160
+ shutil.rmtree(cur)
1161
+ except OSError as e:
1162
+ self._project_dir = str(cur) # deletion failed -> keep the project active
1163
+ self.ctx.log(f"Couldn't delete project: {e}", "error")
1164
+ return
1165
+ self.ctx.log(f"Deleted project “{name}”.", "ok")
1166
+ others = [p for p in list_projects(projects_dir()) if p["path"] != str(cur)]
1167
+ if others:
1168
+ self._load_project_folder(others[0]["path"]) # open the next project
1169
+ else: # nothing left -> a fresh, unsaved project
1170
+ self._project_path = None
1171
+ self._router_programs.clear()
1172
+ self._set_topology(Topology("Untitled"))
1173
+ self.assistant.clear_conversation()
1174
+ self._set_project_label("Untitled")
1175
+ self.setWindowTitle("gBuilder 6.0")
1176
+
1177
+ def _open_project_dialog(self) -> None:
1178
+ from PySide6.QtWidgets import QFileDialog
1179
+ from ..app.paths import projects_dir
1180
+ from ..services import is_project_dir
1181
+ if self._switch_blocked():
1182
+ return
1183
+ path = QFileDialog.getExistingDirectory(self, "Open project", str(projects_dir()))
1184
+ if not path:
1185
+ return
1186
+ if not is_project_dir(path):
1187
+ self.ctx.log("That folder isn't a GINI project (no topology.gini inside).", "info")
1188
+ return
1189
+ self._switch_project(path)
1190
+
1191
+ def _switch_project(self, path: str) -> None:
1192
+ if path == self._project_dir or self._switch_blocked():
1193
+ return
1194
+ self._persist_current_project() # keep the current project's work + chat
1195
+ self._load_project_folder(path)
1196
+
1197
+ def _load_project_folder(self, path: str) -> None:
1198
+ from ..app.paths import remember_project
1199
+ from ..services import load_project_dir
1200
+ try:
1201
+ data = load_project_dir(path)
1202
+ except Exception as e:
1203
+ self.ctx.log(f"Open failed: {e}", "error")
1204
+ return
1205
+ self._router_programs.clear()
1206
+ self._project_dir = data["path"]
1207
+ self._project_path = None
1208
+ self._experiment = data.get("experiment")
1209
+ self._set_topology(data["topology"])
1210
+ self.assistant.set_brief(data["brief"])
1211
+ self.assistant.load_ai_state(data["ai_state"]) # swap the Ask GINI conversation
1212
+ self._show_where()
1213
+ remember_project(data["path"])
1214
+ n = len(data.get("experiments") or [])
1215
+ self.ctx.log(f"Opened project “{data['name']}” ({n} experiment(s), "
1216
+ f"now “{self._experiment}”).", "ok")
1217
+
1218
+ def _persist_current_project(self) -> None:
1219
+ """Write the active project: the CURRENT experiment's topology, plus the project-level
1220
+ brief and Ask GINI conversation (shared by every experiment in the project)."""
1221
+ if not self._project_dir:
1222
+ return
1223
+ from pathlib import Path
1224
+ from ..app.paths import remember_project
1225
+ from ..services import save_project_dir
1226
+ from ..services.project import FIRST_EXPERIMENT
1227
+ name = Path(self._project_dir).name
1228
+ # Fall back to the neutral first-experiment name, NEVER the project's own. This used to be
1229
+ # `self._experiment or name`, which meant any caller that forgot to set `_experiment` (the
1230
+ # Default project, Save-As) silently produced an experiment named after its project — so
1231
+ # the experiment list looked like it contained the project itself.
1232
+ exp = self._experiment or FIRST_EXPERIMENT
1233
+ self.ctx.topology.name = exp
1234
+ save_project_dir(self._project_dir, self.ctx.topology, name=name,
1235
+ brief=self.assistant.brief(), ai_state=self.assistant.ai_state(),
1236
+ experiment=exp)
1237
+ remember_project(self._project_dir)
1238
+
1239
+ def _save_project(self) -> None:
1240
+ if self._project_dir:
1241
+ self._persist_current_project()
1242
+ self.ctx.log(f"Saved project “{self._nav_btn.text()}”.", "ok")
1243
+ else:
1244
+ self._save_project_as()
1245
+
1246
+ def _save_project_as(self) -> None:
1247
+ from pathlib import Path
1248
+ from PySide6.QtWidgets import QInputDialog
1249
+ from ..app.paths import ensure_dirs, projects_dir
1250
+ default = self.ctx.topology.name or "untitled"
1251
+ name, ok = QInputDialog.getText(self, "Save project as", "Project name:", text=default)
1252
+ if not ok or not name.strip():
1253
+ return
1254
+ from ..services.project import FIRST_EXPERIMENT
1255
+ ensure_dirs()
1256
+ self._project_dir = str(projects_dir() / name.strip())
1257
+ self._project_path = None
1258
+ # never name the first experiment after the project (same rule as _new_project) — it made
1259
+ # the experiment list look like it contained the project, and it collided with the very
1260
+ # next "New experiment…" if the student typed the same name.
1261
+ self._experiment = self._experiment or FIRST_EXPERIMENT
1262
+ self._show_where()
1263
+ self._persist_current_project()
1264
+ self.ctx.log(f"Saved project “{name.strip()}”.", "ok")
1265
+
1266
+ def _edit_brief(self) -> None:
1267
+ from PySide6.QtWidgets import QInputDialog
1268
+ text, ok = QInputDialog.getMultiLineText(
1269
+ self, "Project brief",
1270
+ "A short framing for the AI tutor in this project (guides every answer):",
1271
+ self.assistant.brief())
1272
+ if not ok:
1273
+ return
1274
+ self.assistant.set_brief(text)
1275
+ self._persist_current_project()
1276
+ self.ctx.log("Project brief updated.", "ok")
1277
+
1278
+ def _reveal_project(self) -> None:
1279
+ if not self._project_dir:
1280
+ return
1281
+ from PySide6.QtCore import QUrl
1282
+ from PySide6.QtGui import QDesktopServices
1283
+ QDesktopServices.openUrl(QUrl.fromLocalFile(self._project_dir))
1284
+
1285
+ def restore_last_project(self) -> None:
1286
+ """Reopen the project from the previous session (called by the app entry point).
1287
+ If there's no prior project, land in a persistent 'Default' project so the very
1288
+ first session's work and conversation survive a restart too."""
1289
+ from ..app.paths import load_recents
1290
+ from ..services import is_project_dir
1291
+ last = load_recents().get("last")
1292
+ if last and is_project_dir(last):
1293
+ self._load_project_folder(last)
1294
+ else:
1295
+ self._open_or_create_default()
1296
+ self._start_autosave() # crash-safety on top of save-on-close
1297
+
1298
+ def _open_or_create_default(self) -> None:
1299
+ from ..app.paths import ensure_dirs, projects_dir
1300
+ from ..services import is_project_dir
1301
+ ensure_dirs()
1302
+ d = projects_dir() / "Default"
1303
+ if is_project_dir(d):
1304
+ self._load_project_folder(str(d))
1305
+ return
1306
+ from ..services.project import FIRST_EXPERIMENT
1307
+ self._project_dir = str(d) # adopt the current (empty) canvas as Default
1308
+ self._experiment = FIRST_EXPERIMENT
1309
+ self._show_where()
1310
+ self._persist_current_project() # materialise it so it's there next launch
1311
+ self.ctx.log("Working in the Default project — your topology and Ask GINI "
1312
+ "conversation here are saved and restored across restarts.", "info")
1313
+
1314
+ def _start_autosave(self) -> None:
1315
+ """Persist the active project every so often, so a crash or force-quit loses at
1316
+ most the last few seconds (save-on-close handles the normal path)."""
1317
+ from PySide6.QtCore import QTimer
1318
+ if getattr(self, "_autosave_timer", None) is not None:
1319
+ return
1320
+ self._autosave_timer = QTimer(self)
1321
+ self._autosave_timer.setInterval(30_000)
1322
+ self._autosave_timer.timeout.connect(self._persist_current_project)
1323
+ self._autosave_timer.start()
1324
+
1325
+ def closeEvent(self, e) -> None:
1326
+ self._persist_current_project() # never lose the active project's work / chat
1327
+ # Reap any hardware worker still talking to a board over USB. Quitting while one
1328
+ # runs garbage-collects a live QThread, which Qt turns into an abort — so the app
1329
+ # would "crash on exit" for anyone who closed the window mid-scan.
1330
+ from .worker_host import drain
1331
+ drain()
1332
+ super().closeEvent(e)
1333
+
1334
+ def _refresh_icons(self) -> None:
1335
+ t = self.theme.theme
1336
+ col = t.muted
1337
+ for a, icon_name in self._actions.values():
1338
+ a.setIcon(icons.icon(icon_name, col, 19))
1339
+ # coloured dots on the button say "this changes colours"; keep the menu ticks
1340
+ # in sync with whatever theme is active (e.g. changed from the Settings dialog)
1341
+ self._theme_btn.setIcon(_theme_dots_icon(t))
1342
+ for name, act in getattr(self, "_theme_actions", {}).items():
1343
+ act.setChecked(name.lower() == t.name.lower())
1344
+ if hasattr(self, "_nav_btn"):
1345
+ self._nav_btn.setIcon(icons.icon("open", t.accent, 18))
1346
+ # the circular Run/Stop power button repaints itself in the new theme
1347
+ if hasattr(self, "run_button"):
1348
+ self.run_button.refresh_theme()
1349
+
1350
+ # -- docks -------------------------------------------------------------- #
1351
+ def _make_docks(self) -> None:
1352
+ self.palette = Palette(self.theme)
1353
+ left = QDockWidget("Devices", self)
1354
+ left.setObjectName("dock_palette")
1355
+ left.setWidget(self.palette)
1356
+ left.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
1357
+ self.addDockWidget(Qt.LeftDockWidgetArea, left)
1358
+
1359
+ self.inspector = Inspector(self.ctx, self.api, self.theme)
1360
+ self.inspector.query_fn = self.element_query
1361
+ # Boards report through the relay, not a container — see Inspector._board_live_text.
1362
+ self.inspector.board_status_fn = self._gloader.orchestrator.board_status
1363
+ self.inspector.board_action_fn = self.board_action
1364
+ self.inspector.stats_fn = self._element_stats
1365
+ self.inspector.stats_all_fn = self._element_stats_all
1366
+ insp = QDockWidget("Inspector", self)
1367
+ insp.setObjectName("dock_inspector")
1368
+ insp.setWidget(self.inspector)
1369
+ self.addDockWidget(Qt.RightDockWidgetArea, insp)
1370
+
1371
+ self.assistant = Assistant(self.ctx, self.api, self.theme)
1372
+ asst = QDockWidget("Ask GINI", self)
1373
+ asst.setObjectName("dock_assistant")
1374
+ asst.setWidget(self.assistant)
1375
+ self.addDockWidget(Qt.RightDockWidgetArea, asst)
1376
+ self.tabifyDockWidget(insp, asst)
1377
+ insp.raise_()
1378
+
1379
+ self.console = QPlainTextEdit()
1380
+ self.console.setReadOnly(True)
1381
+ self.console.setObjectName("Console")
1382
+ self.console.setMaximumBlockCount(2000)
1383
+ cons = QDockWidget("Console", self)
1384
+ cons.setObjectName("dock_console")
1385
+ cons.setWidget(self.console)
1386
+ self.addDockWidget(Qt.BottomDockWidgetArea, cons)
1387
+
1388
+ # analytics strip — a short "cloud bill" dashboard stacked under the Console
1389
+ from .dashboard import Dashboard
1390
+ self.dashboard = Dashboard(self.theme)
1391
+ self.dashboard.open_grafana_requested.connect(self._open_grafana)
1392
+ dash = QDockWidget("Dashboard", self)
1393
+ dash.setObjectName("dock_dashboard")
1394
+ dash.setWidget(self.dashboard)
1395
+ self.addDockWidget(Qt.BottomDockWidgetArea, dash)
1396
+ self.splitDockWidget(cons, dash, Qt.Vertical) # dashboard below the console
1397
+ self.resizeDocks([left], [240], Qt.Horizontal)
1398
+ self.resizeDocks([cons, dash], [180, 96], Qt.Vertical)
1399
+ self._rebill()
1400
+
1401
+ def _make_statusbar(self) -> None:
1402
+ self.status_conn = QLabel()
1403
+ self.status_counts = QLabel()
1404
+ self.status_theme = QLabel()
1405
+ sb = self.statusBar()
1406
+ sb.addWidget(self.status_conn)
1407
+ sb.addPermanentWidget(self.status_counts)
1408
+ sb.addPermanentWidget(self.status_theme)
1409
+
1410
+ # -- actions ------------------------------------------------------------ #
1411
+ # -- project persistence ------------------------------------------------ #
1412
+ def _make_menubar(self) -> None:
1413
+ from PySide6.QtGui import QKeySequence
1414
+ mb = self.menuBar()
1415
+ filem = mb.addMenu("&File")
1416
+
1417
+ def add(menu, text, slot, shortcut=None):
1418
+ a = QAction(text, self)
1419
+ if shortcut:
1420
+ a.setShortcut(QKeySequence(shortcut))
1421
+ a.triggered.connect(slot)
1422
+ menu.addAction(a)
1423
+ return a
1424
+
1425
+ # Experiments (inside the current project) — the everyday verbs get the shortcuts.
1426
+ add(filem, "&New Experiment…", self._new_experiment, "Ctrl+N")
1427
+ add(filem, "&Open Experiment…", self._open_experiment, "Ctrl+O")
1428
+ add(filem, "&Save Experiment", self._save_project, "Ctrl+S")
1429
+ add(filem, "&Rename Experiment…", self._rename_experiment)
1430
+ add(filem, "&Delete Experiment…", self._delete_experiment)
1431
+ filem.addSeparator()
1432
+ # Projects (the container: brief + AI conversation + a family of experiments)
1433
+ add(filem, "New &Project…", self._new_project, "Ctrl+Shift+N")
1434
+ add(filem, "Open Pro&ject…", self._open_project_dialog, "Ctrl+Shift+O")
1435
+ add(filem, "Save Project &As…", self._save_project_as, "Ctrl+Shift+S")
1436
+ add(filem, "Edit Project &Brief…", self._edit_brief)
1437
+ filem.addSeparator()
1438
+ add(filem, "&Import topology (.gini)…", self._open) # legacy single-file import
1439
+ add(filem, "&Export PNG…", self._export_png)
1440
+ filem.addSeparator()
1441
+ # NoRole keeps these in the File menu on macOS (Qt otherwise hoists "Settings"
1442
+ # and "Quit" into the application menu, where the book's readers don't expect them)
1443
+ settings_act = add(filem, "&Settings…", self._open_settings, "Ctrl+,")
1444
+ settings_act.setMenuRole(QAction.MenuRole.NoRole)
1445
+ filem.addSeparator()
1446
+ quit_act = add(filem, "&Quit", self.close, "Ctrl+Q")
1447
+ quit_act.setMenuRole(QAction.MenuRole.NoRole)
1448
+
1449
+ # Hardware: real GINI32 boards. Its own menu because setting a board up is an
1450
+ # action a student takes repeatedly, not a preference — and because a first-time
1451
+ # user has to be able to FIND it without being told where to look.
1452
+ # Ordered as a board's life runs, not alphabetically: flash a new one, set it up,
1453
+ # release it when it is someone else's turn, and list what is out there. Flashing
1454
+ # comes FIRST because it is the only step a brand-new board can start with — Set
1455
+ # Up talks to a console that does not exist until firmware is on the board.
1456
+ hwm = mb.addMenu("Hard&ware")
1457
+ flash_act = add(hwm, "&Flash a Board…", self._flash_board)
1458
+ flash_act.setMenuRole(QAction.MenuRole.NoRole)
1459
+ setup_act = add(hwm, "&Set Up a Board…", self._setup_board, "Ctrl+Shift+B")
1460
+ setup_act.setMenuRole(QAction.MenuRole.NoRole)
1461
+ reset_act = add(hwm, "&Reset a Board…", self._reset_board)
1462
+ reset_act.setMenuRole(QAction.MenuRole.NoRole)
1463
+ hwm.addSeparator()
1464
+ add(hwm, "&List Boards…", self._show_boards)
1465
+
1466
+ helpm = mb.addMenu("&Help")
1467
+ tour_act = add(helpm, "&Feature Tour…", self.show_feature_tour)
1468
+ tour_act.setMenuRole(QAction.MenuRole.NoRole)
1469
+
1470
+ # ---------------------------------------------------------------- GINI32 boards
1471
+
1472
+ def _known_board_ids(self) -> list[str]:
1473
+ """Every board id we have reason to believe exists: ones on this canvas, and
1474
+ ones a running relay has actually heard from."""
1475
+ ids = []
1476
+ for node in getattr(self.canvas.scene_, "nodes", {}).values():
1477
+ if node.inst.type_key == "gini32":
1478
+ bid = str((node.inst.properties or {}).get("BoardID", "")).strip()
1479
+ if bid:
1480
+ ids.append(bid)
1481
+ for bid in (getattr(self, "_board_state", None) or {}):
1482
+ if bid not in ids:
1483
+ ids.append(bid)
1484
+ return ids
1485
+
1486
+ def _flash_board(self) -> None:
1487
+ """Put firmware on a board, then offer to set it up in the same sitting.
1488
+
1489
+ Chained deliberately: a freshly flashed board is useless until it has the lab
1490
+ Wi-Fi, and making the student find the next menu item themselves is exactly the
1491
+ kind of gap that turns a five-minute task into a support question.
1492
+ """
1493
+ from .flash_dialog import FlashBoardDialog
1494
+ from PySide6.QtWidgets import QMessageBox
1495
+ dlg = FlashBoardDialog(self)
1496
+ if not dlg.exec():
1497
+ return
1498
+ self.ctx.log("GINI32: board flashed", "ok")
1499
+ if QMessageBox.question(
1500
+ self, "Set the board up now?",
1501
+ "The board has firmware but no lab Wi-Fi yet, so it cannot reach "
1502
+ "gBuilder.\n\nSet it up now?") == QMessageBox.StandardButton.Yes:
1503
+ self._setup_board()
1504
+
1505
+ def _reset_board(self) -> None:
1506
+ """Release a board's pairing over USB — see ui/reset_dialog.py for why USB."""
1507
+ from .reset_dialog import ResetBoardDialog
1508
+ dlg = ResetBoardDialog(self)
1509
+ if dlg.exec():
1510
+ self.ctx.log("GINI32: board released — any gBuilder may claim it now", "ok")
1511
+
1512
+ def _setup_board(self) -> None:
1513
+ from .board_dialog import BoardSetupDialog
1514
+ dlg = BoardSetupDialog(self, self.ctx.settings, self._known_board_ids())
1515
+ if dlg.exec() and getattr(dlg, "applied_id", ""):
1516
+ # The dialog put the lab Wi-Fi on Settings; persist it so the next board
1517
+ # is a single click.
1518
+ from ..app.paths import PERSISTED_KEYS, save_config
1519
+ s = self.ctx.settings
1520
+ # Remember the id so the canvas can offer it later. Without this the
1521
+ # student has to retype it from memory, and a typo yields a board that
1522
+ # is online and healthy yet invisible to the topology.
1523
+ known = [b for b in (getattr(s, "known_boards", None) or [])
1524
+ if b != dlg.applied_id]
1525
+ s.known_boards = ([dlg.applied_id] + known)[:32]
1526
+ save_config({k: getattr(s, k) for k in PERSISTED_KEYS})
1527
+ self.ctx.log(f"GINI32: board '{dlg.applied_id}' set up for Wi-Fi "
1528
+ f"'{s.board_wifi_ssid}'. Use that as the BoardID on the "
1529
+ f"canvas.", "ok")
1530
+
1531
+ def _show_boards(self) -> None:
1532
+ """What the relay currently knows about real boards."""
1533
+ from PySide6.QtWidgets import QMessageBox
1534
+ st = None
1535
+ try:
1536
+ st = self._gloader.orchestrator.board_status()
1537
+ except Exception:
1538
+ st = None
1539
+ if st is None:
1540
+ QMessageBox.information(
1541
+ self, "Boards",
1542
+ "No running lab to ask.\n\nBoard status comes from the gbridge relay, "
1543
+ "which runs while a topology containing a GINI32 element is up. Draw a "
1544
+ "board, press Run, then look here.\n\nTo set a board up over USB, use "
1545
+ "Hardware → Set Up a Board.")
1546
+ return
1547
+ boards = st.get("boards", [])
1548
+ if not boards:
1549
+ QMessageBox.information(self, "Boards",
1550
+ "The lab is running but no GINI32 boards are drawn "
1551
+ "on the canvas.")
1552
+ return
1553
+ lines = []
1554
+ for b in boards:
1555
+ where = b.get("addr") or "not seen yet"
1556
+ state = "online" if b.get("online") else "OFFLINE"
1557
+ lines.append(f"{b['board_id']} — {state} ({where})\n"
1558
+ f" fabric {b.get('ip') or '?'} mode {b.get('mode') or '?'}"
1559
+ f" clients {len(b.get('clients') or [])}")
1560
+ foreign = st.get("foreign") or []
1561
+ if foreign:
1562
+ lines.append("\nHeard, but claimed by another computer:")
1563
+ lines += [f" {f['board_id']} (owner {f['owner']})" for f in foreign]
1564
+ QMessageBox.information(self, "Boards", "\n".join(lines))
1565
+
1566
+ # Legacy single-file `.gini` import. Kept because older labs and shared files use it; the
1567
+ # matching _new/_save/_save_as have been removed (nothing called them since projects became
1568
+ # folders, and their separate `_project_path` tracker only invited confusion).
1569
+ def _open(self) -> None:
1570
+ from ..app.paths import projects_dir
1571
+ from ..services import PROJECT_EXT
1572
+ from PySide6.QtWidgets import QFileDialog
1573
+ path, _ = QFileDialog.getOpenFileName(
1574
+ self, "Open project", str(projects_dir()),
1575
+ f"GINI project (*{PROJECT_EXT});;All files (*)")
1576
+ if path:
1577
+ self._load_from_path(path)
1578
+
1579
+ def _save_to_path(self, path: str) -> None:
1580
+ from pathlib import Path
1581
+ from ..services import save_project
1582
+ self.ctx.topology.name = Path(path).stem
1583
+ save_project(self.ctx.topology, path)
1584
+ self._project_path = path
1585
+ self.setWindowTitle(f"gBuilder 6.0 — {Path(path).name}")
1586
+ self.ctx.log(f"Saved {path}", "ok")
1587
+
1588
+ def _load_from_path(self, path: str) -> None:
1589
+ from pathlib import Path
1590
+ from ..services import load_project
1591
+ try:
1592
+ topo = load_project(path)
1593
+ except Exception as e:
1594
+ self.ctx.log(f"Open failed: {e}", "error")
1595
+ return
1596
+ self._project_path = path
1597
+ self._router_programs.clear()
1598
+ self._set_topology(topo)
1599
+ self.setWindowTitle(f"gBuilder 6.0 — {Path(path).name}")
1600
+ self.ctx.log(f"Opened {path}", "ok")
1601
+
1602
+ def _set_topology(self, topo) -> None:
1603
+ scene = self.canvas.scene_
1604
+ scene.clear()
1605
+ scene.nodes.clear()
1606
+ scene.edges.clear()
1607
+ scene.groups.clear()
1608
+ scene._callouts = []
1609
+ scene._spotlit = []
1610
+ scene._highlit = []
1611
+ self.ctx.topology = topo
1612
+ topo.prefix_overrides = dict(self.ctx.settings.name_prefixes) # apply naming prefs
1613
+ self.ctx.selected_id = None
1614
+ if hasattr(self, "_manual_addr_act"):
1615
+ self._manual_addr_act.setChecked(topo.manual_addressing)
1616
+ for d in topo.devices.values():
1617
+ self.ctx.bus.device_added.emit(d.id)
1618
+ for link in topo.links.values():
1619
+ self.ctx.bus.link_added.emit(link.id)
1620
+ self.ctx.bus.topology_changed.emit() # -> _rebill re-estimates the new topology
1621
+ if hasattr(self, "dashboard"): # fresh experiment -> fresh GINI $ meter
1622
+ self.dashboard.reset()
1623
+ self.ctx.bus.selection_changed.emit(None)
1624
+
1625
+ def _export_png(self) -> None:
1626
+ from PySide6.QtCore import QRectF, QSize, Qt
1627
+ from PySide6.QtGui import QImage, QPainter
1628
+ from PySide6.QtWidgets import QFileDialog
1629
+ path, _ = QFileDialog.getSaveFileName(self, "Export PNG", "topology.png", "PNG (*.png)")
1630
+ if not path:
1631
+ return
1632
+ scene = self.canvas.scene_
1633
+ rect = scene.itemsBoundingRect().adjusted(-40, -40, 40, 40)
1634
+ img = QImage(QSize(max(1, int(rect.width())), max(1, int(rect.height()))),
1635
+ QImage.Format_ARGB32)
1636
+ img.fill(Qt.transparent)
1637
+ p = QPainter(img)
1638
+ p.setRenderHint(QPainter.Antialiasing, True)
1639
+ scene.render(p, QRectF(img.rect()), rect)
1640
+ p.end()
1641
+ img.save(path)
1642
+ self.ctx.log(f"Exported {path}", "ok")
1643
+
1644
+ def _compile(self):
1645
+ cfg = self._gloader.compile(self.ctx.topology)
1646
+ self.ctx.log(
1647
+ f"Compiled “{self.ctx.topology.name}” → {len(cfg.machines)} machines, "
1648
+ f"{len(cfg.switches)} switches, {len(cfg.routers)} routers, "
1649
+ f"{len(cfg.subnets)} subnets.", "ok")
1650
+ from ..services.compiler import validate
1651
+ for it in validate(self.ctx.topology):
1652
+ tag = "·" if it["level"] == "info" else "⚠"
1653
+ self.ctx.log(f" {tag} {it['message']}",
1654
+ "info" if it["level"] == "info" else "error")
1655
+ return cfg
1656
+
1657
+ def _auto_layout(self) -> None:
1658
+ nodes = list(self.canvas.scene_.nodes.values())
1659
+ n = len(nodes)
1660
+ if n == 0:
1661
+ return
1662
+ cols = max(1, math.ceil(math.sqrt(n)))
1663
+ gx, gy = NODE_W + 60, NODE_H + 60
1664
+ for i, node in enumerate(nodes):
1665
+ r, c = divmod(i, cols)
1666
+ node.setPos(c * gx - (cols - 1) * gx / 2, r * gy - 80)
1667
+ self.ctx.log("Arranged topology.", "info")
1668
+
1669
+ def _toggle_connect(self, on: bool) -> None:
1670
+ self.canvas.set_connect_mode(on)
1671
+ self.ctx.log("Connect mode: click two devices to link them." if on
1672
+ else "Connect mode off.", "info")
1673
+
1674
+ def _on_canvas_background(self) -> None:
1675
+ # a click on empty canvas ends connect mode (linking elements keeps it on;
1676
+ # clicking empty space is the natural "done" gesture). trigger() (not setChecked)
1677
+ # so the action's `triggered` handler actually turns the canvas mode off.
1678
+ if self._connect_act.isChecked():
1679
+ self._connect_act.trigger() # -> _toggle_connect(False)
1680
+
1681
+ def _toggle_edge_style(self, bent: bool) -> None:
1682
+ self.ctx.settings.connector_style = "orthogonal" if bent else "straight"
1683
+ self.ctx.bus.edges_restyled.emit()
1684
+ self._persist_settings() # a toolbar toggle is still a preference — remember it
1685
+ self.ctx.log(f"Connectors: {'bent (rounded)' if bent else 'straight'}.", "info")
1686
+
1687
+ def _toggle_manual_addr(self, on: bool) -> None:
1688
+ self.ctx.topology.manual_addressing = on
1689
+ self._recompute_addressing() # re-derive with/without the manual overrides
1690
+ self._revalidate()
1691
+ self.ctx.log(
1692
+ "Manual addressing: on — set IPs in Inspector › Interfaces; blanks auto-fill."
1693
+ if on else "Manual addressing: off — IPs are auto-assigned.", "info")
1694
+
1695
+ # -- remote (GINI server) backend -------------------------------------- #
1696
+ def _toggle_backend(self) -> None:
1697
+ """Toolbar toggle: connect to a remote Kata GINI server, or drop back to local."""
1698
+ if self._remote is not None:
1699
+ self._remote = None
1700
+ self.ctx.settings.backend = "local"
1701
+ self.ctx.log("Backend: local Docker.", "info")
1702
+ self._server_act.setChecked(False)
1703
+ return
1704
+ if self._running:
1705
+ self.ctx.log("Stop the running lab before switching backend.", "info")
1706
+ self._server_act.setChecked(False)
1707
+ return
1708
+ self._connect_server()
1709
+ self._server_act.setChecked(self._remote is not None)
1710
+
1711
+ def _connect_server(self, client=None) -> bool:
1712
+ """Log in to the configured GINI server (host/port/user from Settings; password is
1713
+ prompted, never stored). `client` is injectable for tests."""
1714
+ from PySide6.QtWidgets import QInputDialog, QLineEdit
1715
+ s = self.ctx.settings
1716
+ if client is None and not s.gini_server_host:
1717
+ self.ctx.log("Set the GINI server host in Settings → Backend first.", "info")
1718
+ return False
1719
+ if client is None:
1720
+ pw, ok = QInputDialog.getText(self, "Connect to GINI server",
1721
+ f"Password for {s.gini_server_user}@{s.gini_server_host}:",
1722
+ QLineEdit.Password)
1723
+ if not ok:
1724
+ return False
1725
+ from ..services.remote import RemoteClient
1726
+ client = RemoteClient(f"http://{s.gini_server_host}:{s.gini_server_port}")
1727
+ good, err = client.login(s.gini_server_user, pw)
1728
+ if not good:
1729
+ self.ctx.log(f"Server login failed: {err}", "error")
1730
+ return False
1731
+ self._remote = client
1732
+ s.backend = "gini-server"
1733
+ kata = client.kata_available()
1734
+ self.ctx.log(f"Connected to GINI server{' (Kata available)' if kata else ''}. "
1735
+ "Build a topology and Run — it executes on the server.", "ok")
1736
+ return True
1737
+
1738
+ def _run_remote(self) -> None:
1739
+ if self._running:
1740
+ self.ctx.log("Already running — stop first.", "info")
1741
+ return
1742
+ import threading
1743
+ topo = self.ctx.topology
1744
+ self.ctx.log("Sending topology to the GINI server…", "info")
1745
+ self.run_button.set_state("booting")
1746
+
1747
+ def worker():
1748
+ ok, msg = self._remote.run(topo) # start (returns once accepted)
1749
+ if not ok:
1750
+ self.ctx.bus.run_state.emit(False, msg)
1751
+ return
1752
+ self.ctx.log("Launching on the server (pulling images / booting)…", "info")
1753
+ ok, msg = self._remote.wait_until_running() # poll until up / error
1754
+ self.ctx.bus.run_state.emit(ok, msg)
1755
+ threading.Thread(target=worker, daemon=True).start()
1756
+
1757
+ def _on_remote_run_state(self, ok: bool, msg: str) -> None:
1758
+ if ok:
1759
+ self._running = True
1760
+ self._stopping = False
1761
+ self._set_runtime_status("running")
1762
+ self.run_button.set_state("running") # remote has no container poller
1763
+ self.canvas.scene_.running = True
1764
+ self.inspector.set_live_running(True)
1765
+ self.ctx.log("Topology running on the GINI server.", "ok")
1766
+ from PySide6.QtCore import QTimer
1767
+ QTimer.singleShot(2500, self._poll_remote_metrics)
1768
+ else:
1769
+ self._running = False
1770
+ self.run_button.set_state("error")
1771
+ self.ctx.log(f"Server run failed: {msg}", "error")
1772
+ self._set_runtime_status("idle")
1773
+
1774
+ def _poll_remote_metrics(self) -> None:
1775
+ if not self._running or self._remote is None:
1776
+ return
1777
+ import threading
1778
+
1779
+ def work():
1780
+ m = self._remote.metrics() or {}
1781
+ startup = m.get("startup") or {}
1782
+ if startup:
1783
+ line = ", ".join(f"{s} {ms:.0f} ms" for s, ms in sorted(startup.items()))
1784
+ self.ctx.log("Startup times — " + line, "info")
1785
+ threading.Thread(target=work, daemon=True).start()
1786
+
1787
+ def _toggle_run(self) -> None:
1788
+ """The circular power button was pressed — what it means depends on state."""
1789
+ st = self.run_button.state()
1790
+ if st in ("ready", "error"):
1791
+ self._run()
1792
+ elif st in ("booting", "running"):
1793
+ self._stop()
1794
+ # "stopping": already winding down — ignore
1795
+
1796
+ def _run(self) -> None:
1797
+ import tempfile
1798
+ import threading
1799
+ if self._remote is not None: # remote backend: the server runs it
1800
+ self._run_remote()
1801
+ return
1802
+ if self._running:
1803
+ self.ctx.log("Already running — stop first.", "info")
1804
+ return
1805
+ cfg = self._compile()
1806
+ runnable = (cfg.machines or cfg.routers or cfg.ovs_switches
1807
+ or cfg.controllers or cfg.services or cfg.k8s or cfg.faas)
1808
+ if not runnable:
1809
+ self.ctx.log("Nothing runnable on the canvas yet (add devices + links).", "info")
1810
+ return
1811
+ # Kata Instances need a 'kata' OCI runtime on the backend — fail with a clear
1812
+ # message instead of a raw Docker error if it's not there (e.g. on a Mac).
1813
+ if any(getattr(s, "runtime", "") == "kata" for s in cfg.services) \
1814
+ and not self._gloader.runtime_available("kata"):
1815
+ self.ctx.log("This topology has Kata Instance(s), but the current backend has no "
1816
+ "'kata' runtime. Point GINI at a Kata-enabled Linux backend "
1817
+ "(Settings → Backend) to run VM-isolated workloads.", "info")
1818
+ return
1819
+ self._last_services = list(cfg.services)
1820
+ self._last_machines = list(cfg.machines) # headful Desktops carry a noVNC port here
1821
+ self._last_k8s = list(cfg.k8s)
1822
+ self._last_gbridge = list(getattr(cfg, "gbridge", [])) # real GINI32 boards
1823
+ self._board_state = {} # board_id -> live state, refreshed by the poller
1824
+ self._workdir = tempfile.mkdtemp(prefix="gini-lab-")
1825
+ self.ctx.log(f"Launching {len(cfg.machines)} machines + {len(cfg.routers)} "
1826
+ f"gRouters + {len(cfg.services)} cloud services via Docker…", "info")
1827
+ self.ctx.log(f"Project: {self._workdir} (double-click a device to log in)", "info")
1828
+
1829
+ auto_internet = self.ctx.settings.auto_internet
1830
+ if not auto_internet:
1831
+ self.ctx.log("Faithful mode: hosts have NO default route to the internet. "
1832
+ "Draw + wire an Internet element for egress. (Web consoles "
1833
+ "still open — only outbound internet is cut.)", "info")
1834
+
1835
+ self.run_button.set_state("booting") # ring fills as containers come up
1836
+
1837
+ def worker(workdir=self._workdir, ai=auto_internet, lid=self._laptop_id()):
1838
+ ok, msg = self._gloader.up(cfg, workdir, auto_internet=ai, laptop_id=lid)
1839
+ self.ctx.bus.run_state.emit(ok, msg)
1840
+ threading.Thread(target=worker, daemon=True).start()
1841
+
1842
+ def _on_run_state(self, ok: bool, msg: str) -> None:
1843
+ if self._remote is not None: # remote backend has its own (lighter) handling
1844
+ self._on_remote_run_state(ok, msg)
1845
+ return
1846
+ if ok:
1847
+ self._running = True
1848
+ self._stopping = False
1849
+ self._set_runtime_status("running")
1850
+ self._poll.start() # reconcile with real container state
1851
+ self.ctx.log("Topology running on Docker.", "ok")
1852
+ # GINI32: say out loud whether real boards can find this lab, and on which
1853
+ # address. Discovery failing silently is indistinguishable from a board
1854
+ # being broken, and it sends people debugging the wrong end of the link.
1855
+ if getattr(self, "_last_gbridge", None):
1856
+ where = getattr(self._gloader.orchestrator, "advertising", None)
1857
+ boards = ", ".join(b.board_id for b in self._last_gbridge)
1858
+ if where:
1859
+ self.ctx.log(f"GINI32: announcing this lab at {where} — boards "
1860
+ f"expected: {boards}", "ok")
1861
+ self.ctx.log("GINI32: a board must be on the SAME Wi-Fi as this Mac; "
1862
+ "if it never finds the lab, use `set server "
1863
+ f"{where.split(':')[0]}` on the board.", "info")
1864
+ else:
1865
+ why = getattr(self._gloader.orchestrator, "advertise_error", "")
1866
+ self.ctx.log("GINI32: could NOT announce on the network — boards will "
1867
+ "not discover this lab. Set the address by hand on the "
1868
+ "board: `set server <this Mac's IP>`."
1869
+ + (f" ({why})" if why else ""), "warn")
1870
+ self._wire_xv6_providers() # attach live GDB bridges to any xv6 kernels
1871
+ self._populate_overlay_hosts() # names resolve over gini0, not the Docker bridge
1872
+ grafana = None
1873
+ for s in getattr(self, "_last_services", []): # surface web consoles
1874
+ for p in s.ports:
1875
+ if p.get("web"):
1876
+ url = f"http://localhost:{p['host']}{p.get('path', '')}"
1877
+ self.ctx.log(f"{s.name} ({p['label']}): {url}", "ok")
1878
+ if getattr(s, "type_key", None) == "dashboard":
1879
+ grafana = url
1880
+ # start the GINI $ meter billing the launched topology
1881
+ from ..domain.pricing import bill
1882
+ rate = bill(self.ctx.topology, self.ctx.settings.prices)["rate_per_hr"]
1883
+ self.dashboard.set_grafana_url(grafana)
1884
+ self.dashboard.start(rate)
1885
+ self.inspector.set_live_running(True) # enable the Live metrics plots
1886
+ self.canvas.scene_.running = True # enable console/logs/login actions
1887
+ self._fabric_poll.start() # poll cloud-fabric app metrics
1888
+ from PySide6.QtCore import QTimer
1889
+ QTimer.singleShot(6000, self._drive_loadgens) # let Fortio boot, then load
1890
+ QTimer.singleShot(2000, self._log_startup_times) # VM-vs-container startup signal
1891
+ if getattr(self, "_last_k8s", None):
1892
+ self._apply_k8s() # wait for k3s, kubectl apply
1893
+ self._k8s_poll.start() # poll deployment metrics
1894
+ else:
1895
+ self.run_button.set_state("error")
1896
+ self.ctx.log(f"Run failed: {msg}", "error")
1897
+ self._update_status()
1898
+
1899
+ def _stop(self) -> None:
1900
+ import threading
1901
+ if not self._running:
1902
+ self.ctx.log("Not running.", "info")
1903
+ return
1904
+ self._stopping = True
1905
+ self._set_runtime_status("stopping") # yellow while containers wind down
1906
+ self.run_button.set_state("stopping")
1907
+ self.ctx.log("Stopping…", "info")
1908
+ self._update_status()
1909
+
1910
+ def worker():
1911
+ self.ctx.stop_all_riders() # kill rider processes while the stack is still up
1912
+ ok, msg = self._remote.stop() if self._remote is not None else self._gloader.down()
1913
+ if not ok:
1914
+ self.ctx.log(f"Stop issue: {msg}", "error")
1915
+ self.ctx.bus.runtime_status.emit({}) # force a final reconcile -> idle
1916
+ threading.Thread(target=worker, daemon=True).start()
1917
+
1918
+ # -- real status reconciliation ----------------------------------------- #
1919
+ def _poll_status(self) -> None:
1920
+ import threading
1921
+ if not self._workdir:
1922
+ return
1923
+ wd = self._workdir
1924
+
1925
+ def worker():
1926
+ self.ctx.bus.runtime_status.emit(self._gloader.status(wd))
1927
+ threading.Thread(target=worker, daemon=True).start()
1928
+
1929
+ def _on_runtime_status(self, states) -> None:
1930
+ from ..services.compiler import _role, _svc
1931
+ any_up = any(v == "running" for v in (states or {}).values())
1932
+
1933
+ # finished stopping, or everything died externally
1934
+ if not any_up:
1935
+ if self._running or self._stopping:
1936
+ self._running = False
1937
+ self._stopping = False
1938
+ self._poll.stop()
1939
+ self._set_runtime_status("idle")
1940
+ self.dashboard.stop() # freeze the session's GINI $ bill
1941
+ self.dashboard.set_grafana_url(None)
1942
+ # Devices on a board's radio are facts about a RUNNING lab. With the
1943
+ # lab down we no longer know anything, so stop claiming we do.
1944
+ self.canvas.clear_live_clients()
1945
+ self._board_state = {}
1946
+ self._fabric_poll.stop()
1947
+ self._k8s_poll.stop()
1948
+ self.dashboard.set_fabric({})
1949
+ self.inspector.set_live_running(False) # stop the Live metrics polling
1950
+ self.canvas.scene_.running = False # grey out console/logs/login actions
1951
+ self.inspector.set_fabric_snapshot({})
1952
+ self.inspector.set_k8s_snapshot({})
1953
+ self.run_button.set_state("ready")
1954
+ self.ctx.log("All containers stopped.", "info")
1955
+ self._update_status()
1956
+ return
1957
+ if self._stopping:
1958
+ return # keep the yellow 'stopping' chips until containers are actually gone
1959
+
1960
+ # feed the boot ring real progress, and morph ▶→■ once everything is up
1961
+ up = sum(1 for v in states.values() if v == "running")
1962
+ total = len(states)
1963
+ self.run_button.set_progress(up, total)
1964
+ self.run_button.set_state("running" if total and up >= total else "booting")
1965
+
1966
+ fabric = states.get("fabric")
1967
+ for node in self.canvas.scene_.nodes.values():
1968
+ role = _role(node.inst.type_key)
1969
+ if role == "machine":
1970
+ st = states.get(_svc(node.inst.name))
1971
+ node.set_status("running" if st == "running"
1972
+ else "error" if st else "idle")
1973
+ elif role in ("router", "ovs", "controller", "service", "compute", "k8scluster",
1974
+ "xv6", "oszoo"):
1975
+ st = states.get(_svc(node.inst.name)) # each its own container
1976
+ # OS Zoo card tracks its emulator: the container stays up while QEMU/DOSBox/
1977
+ # Basilisk runs, so "running" here means the guest is actually up.
1978
+ node.set_status("running" if st == "running"
1979
+ else "error" if st else "idle")
1980
+ elif role in ("k8sworkload", "hpa", "k8snode"): # live inside the cluster
1981
+ node.set_status("running" if fabric is not None or any(
1982
+ v == "running" for v in states.values()) else "idle")
1983
+ elif role == "function": # functions live in the shared faas runtime
1984
+ f = states.get("faas")
1985
+ node.set_status("running" if f == "running"
1986
+ else "error" if f else "idle")
1987
+ elif role == "switch": # switches live in the fabric container
1988
+ node.set_status("running" if fabric == "running"
1989
+ else "error" if fabric else "idle")
1990
+ elif role == "peripheral": # terminal / disk: mirror the wired xv6 Machine
1991
+ xid = self._xv6_for_peripheral(node.inst.id)
1992
+ st = states.get(_svc(self.ctx.topology.devices[xid].name)) if xid else None
1993
+ node.set_status("running" if st == "running" else "idle")
1994
+ elif role == "rider": # Source/Sink: 'ready' when its donor is up
1995
+ donor = self.ctx.topology.donor_of(node.inst.id)
1996
+ if donor is None:
1997
+ node.set_status("idle")
1998
+ else:
1999
+ dst = fabric if _role(donor.type_key) == "switch" \
2000
+ else states.get(_svc(donor.name))
2001
+ node.set_status("ready" if dst == "running"
2002
+ else "error" if dst else "idle")
2003
+ elif role == "gini32":
2004
+ # A board has no container: it is real hardware, so the only thing that
2005
+ # means "connected" is that it actually checked in. Distinguish "the lab
2006
+ # is up and we are waiting for hardware" from "nothing is running" —
2007
+ # they look identical otherwise, and only one of them is a problem.
2008
+ bid = str((node.inst.properties or {}).get("BoardID", "")).strip()
2009
+ st = (getattr(self, "_board_state", None) or {}).get(bid)
2010
+ if st and st.get("online"):
2011
+ node.set_status("running") # -> chip reads "connected"
2012
+ elif not bid:
2013
+ node.set_status("error") # -> "no board": nothing can attach
2014
+ else:
2015
+ node.set_status("searching")
2016
+
2017
+ self._poll_boards()
2018
+
2019
+ def _laptop_id(self) -> str:
2020
+ """This install's identity to a board, minted once and then stable.
2021
+
2022
+ A board records the id of the laptop that claimed it and ignores every other
2023
+ laptop — which is what stops thirty students in one room from taking each
2024
+ other's hardware. It must therefore survive restarts.
2025
+ """
2026
+ lid = getattr(self.ctx.settings, "laptop_id", "") or ""
2027
+ if not lid:
2028
+ import uuid
2029
+ lid = f"gb-{uuid.uuid4().hex[:12]}"
2030
+ self.ctx.settings.laptop_id = lid
2031
+ self._persist_settings()
2032
+ return lid
2033
+
2034
+ def _persist_settings(self) -> None:
2035
+ from ..app.paths import PERSISTED_KEYS, save_config
2036
+ save_config({k: getattr(self.ctx.settings, k) for k in PERSISTED_KEYS})
2037
+
2038
+ def board_action(self, action: str, board: str) -> bool:
2039
+ """Claim / release / blink a board, and remember what we own."""
2040
+ ok = self._gloader.orchestrator.board_action(action, board)
2041
+ if not ok:
2042
+ return False
2043
+ claimed = dict(getattr(self.ctx.settings, "claimed_boards", None) or {})
2044
+ if action == "claim":
2045
+ claimed[board] = {"claimed_at": time.time()}
2046
+ self.ctx.log(f"GINI32: claimed {board} — it now ignores other laptops", "ok")
2047
+ elif action == "release":
2048
+ claimed.pop(board, None)
2049
+ self.ctx.log(f"GINI32: released {board} — anyone may claim it now", "info")
2050
+ else:
2051
+ self.ctx.log(f"GINI32: {board} should be blinking — look at the bench", "info")
2052
+ if action in ("claim", "release"):
2053
+ self.ctx.settings.claimed_boards = claimed
2054
+ self._persist_settings()
2055
+ return True
2056
+
2057
+ @staticmethod
2058
+ def _ap_gateway(subnet: str) -> str:
2059
+ """The board's own address on its hotspot: the .1 of the physical subnet.
2060
+
2061
+ The firmware takes .1 and DHCPs the rest (gb_ap_configure), so this is derived
2062
+ rather than reported — one fact, one place. Returns "" for anything unparsable,
2063
+ because a wrong address on the canvas is worse than none.
2064
+ """
2065
+ head = (subnet or "").split("/")[0].strip()
2066
+ parts = head.split(".")
2067
+ if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
2068
+ return ""
2069
+ return ".".join(parts[:3] + ["1"])
2070
+
2071
+ def _poll_boards(self) -> None:
2072
+ """Refresh real GINI32 board state, and the devices attached to their radios.
2073
+
2074
+ Devices are OBSERVED, not drawn: a phone joining a board's hotspot is a fact
2075
+ about the physical world, so it appears as an ephemeral node and disappears
2076
+ when it leaves. It is never written into the saved topology.
2077
+ """
2078
+ if not getattr(self, "_last_gbridge", None):
2079
+ return
2080
+ st = self._gloader.orchestrator.board_status()
2081
+ if st is None:
2082
+ return
2083
+ boards = {b["board_id"]: b for b in st.get("boards", [])}
2084
+ was_online = {k: bool(v.get("online"))
2085
+ for k, v in (getattr(self, "_board_state", None) or {}).items()}
2086
+ self._board_state = boards
2087
+
2088
+ # element id -> the devices its board currently carries
2089
+ live: dict[str, list[dict]] = {}
2090
+ for node in self.canvas.scene_.nodes.values():
2091
+ if node.inst.type_key != "gini32":
2092
+ continue
2093
+ bid = str((node.inst.properties or {}).get("BoardID", "")).strip()
2094
+ b = boards.get(bid)
2095
+ if not b:
2096
+ node.set_board_addr("")
2097
+ continue
2098
+ # Channel is reported by the hardware, never set here (APSTA forces it).
2099
+ ch = b.get("channel") or 0
2100
+ shown = f"{ch} (from '{b.get('uplink') or '?'}')" if ch else ""
2101
+ if (node.inst.properties or {}).get("Channel") != shown:
2102
+ node.inst.properties["Channel"] = shown
2103
+ if b.get("online"):
2104
+ live[node.inst.id] = list(b.get("clients") or [])
2105
+ # The hotspot's own address, so the node says WHERE the board is and not
2106
+ # merely that it exists — every other element on the canvas shows its
2107
+ # address, and a board was the one thing you had to go and look up.
2108
+ # Cleared below the moment it goes quiet: a stale address on a board that
2109
+ # is not there is worse than no address, because it looks reachable.
2110
+ node.set_board_addr(self._ap_gateway(b.get("physical_subnet") or ""))
2111
+ else:
2112
+ node.set_board_addr("")
2113
+
2114
+ # Tell the Inspector, which otherwise keeps showing a board that has gone away —
2115
+ # it reads board state once, when the panel is built.
2116
+ now_online = {k: bool(v.get("online")) for k, v in boards.items()}
2117
+ if now_online != was_online:
2118
+ self.ctx.bus.boards_changed.emit()
2119
+
2120
+ changed = self.canvas.set_live_clients(live)
2121
+ if changed:
2122
+ for gone in changed.get("left", []):
2123
+ self.ctx.log(f"GINI32: device {gone} left the radio", "info")
2124
+ for came in changed.get("joined", []):
2125
+ self.ctx.log(f"GINI32: device {came} joined the radio", "ok")
2126
+
2127
+ def _set_runtime_status(self, status: str) -> None:
2128
+ from ..services.compiler import _role
2129
+ for node in self.canvas.scene_.nodes.values():
2130
+ if _role(node.inst.type_key) in ("machine", "switch", "router", "ovs",
2131
+ "controller", "service", "compute", "function",
2132
+ "k8scluster", "k8sworkload", "hpa", "k8snode",
2133
+ "xv6", "oszoo", "peripheral"):
2134
+ node.set_status(status)
2135
+
2136
+ def _do_recompute(self) -> None:
2137
+ # one coalesced pass after a burst of topology changes (debounced) — keeps the three
2138
+ # compiler-backed refreshes off the per-change hot path.
2139
+ self._recompute_addressing()
2140
+ self._revalidate()
2141
+ self._rebill()
2142
+
2143
+ def _populate_overlay_hosts(self) -> None:
2144
+ """Write peer name→overlay-IP lines into each machine's /etc/hosts, so name resolution
2145
+ (getent/DNS Probe) and ping/reach ride the DRAWN gini0 network instead of Docker's bridge.
2146
+ Off the GUI thread — it's a docker exec per machine. Containers are fresh each run, so no
2147
+ accumulation. This is the 'small Phase-2.x': it makes reachability follow the topology."""
2148
+ import shlex
2149
+ import subprocess
2150
+ import threading
2151
+ from ..services.compiler import _role, _svc, overlay_hosts
2152
+ orch = getattr(self.ctx, "orchestrator", None)
2153
+ if orch is None:
2154
+ return
2155
+ hosts = overlay_hosts(getattr(self.ctx, "addressing", {}) or {})
2156
+ if len(hosts) < 2:
2157
+ return
2158
+ dc = list(getattr(orch, "_dc", ["docker", "compose"]))
2159
+ wd = getattr(orch, "workdir", None)
2160
+ devs = [d for d in self.ctx.topology.devices.values()
2161
+ if _role(d.type_key) in ("machine", "router", "compute")]
2162
+
2163
+ # include SELF, and PREPEND — Docker puts the container's own name→bridge line at the TOP of
2164
+ # /etc/hosts, so a getent/ping for the machine's own name would hit the bridge unless our
2165
+ # overlay line comes first. `> /etc/hosts` truncates-in-place (works on the bind mount; `mv`
2166
+ # wouldn't). Docker's original lines stay below, so nothing that needs the bridge id breaks.
2167
+ block = "\n".join(f"{ip}\t{nm}" for nm, ip in hosts.items())
2168
+
2169
+ def work():
2170
+ for dev in devs:
2171
+ cmd = [*dc, "exec", "-T", _svc(dev.name), "sh", "-lc",
2172
+ "{ printf '%%s\\n' %s; cat /etc/hosts; } > /tmp/gini_hosts && "
2173
+ "cat /tmp/gini_hosts > /etc/hosts" % shlex.quote(block)]
2174
+ try:
2175
+ subprocess.run(cmd, cwd=str(wd) if wd else None,
2176
+ capture_output=True, timeout=8)
2177
+ except Exception: # noqa: BLE001 — best-effort; a slow box just misses
2178
+ pass
2179
+ threading.Thread(target=work, daemon=True).start()
2180
+
2181
+ def _recompute_addressing(self) -> None:
2182
+ from ..services.compiler import address_map
2183
+ try:
2184
+ self.ctx.addressing = address_map(self.ctx.topology)
2185
+ except Exception:
2186
+ self.ctx.addressing = {}
2187
+ self.ctx.bus.addressing_changed.emit()
2188
+
2189
+ def _rebill(self) -> None:
2190
+ """Refresh the dashboard's projected GINI $/hr from the current canvas. While a
2191
+ lab runs the meter holds the launched rate, so we only re-estimate when idle."""
2192
+ if not hasattr(self, "dashboard") or self._running:
2193
+ return
2194
+ from ..domain.pricing import bill
2195
+ self.dashboard.set_estimate(bill(self.ctx.topology, self.ctx.settings.prices))
2196
+
2197
+ def _on_device_changed_live(self, device_id: str) -> None:
2198
+ """A property changed while running — re-drive a Load Generator if its QPS/target
2199
+ changed (the live throttle)."""
2200
+ if not self._running:
2201
+ return
2202
+ d = self.ctx.topology.devices.get(device_id)
2203
+ if d is None:
2204
+ return
2205
+ from ..services.compiler import _role, _svc
2206
+ if d.type_key == "load_generator":
2207
+ self._drive_loadgen(device_id)
2208
+ elif _role(d.type_key) == "k8sworkload": # Replicas slider -> kubectl scale
2209
+ self._k8s_live(d, scale=True)
2210
+ elif _role(d.type_key) == "hpa": # Target CPU slider -> patch HPA
2211
+ self._k8s_live(d, scale=False)
2212
+
2213
+ def _k8s_live(self, d, *, scale: bool) -> None:
2214
+ import threading
2215
+ from ..services.compiler import _role, _svc
2216
+ cluster = self._k8s_cluster_svc(d.id)
2217
+ if not cluster:
2218
+ return
2219
+ if scale:
2220
+ dep, n = _svc(d.name), d.properties.get("Replicas", "2")
2221
+
2222
+ def work_scale():
2223
+ ok, msg = self._gloader.k8s_scale(cluster, dep, n)
2224
+ self.ctx.log(f"{d.name}: scaled to {n} replicas." if ok
2225
+ else f"{d.name}: scale failed ({msg})", "ok" if ok else "info")
2226
+ threading.Thread(target=work_scale, daemon=True).start()
2227
+ else:
2228
+ # the HPA name == the connected Pod's deployment name
2229
+ hpa = None
2230
+ for l in self.ctx.topology.links.values():
2231
+ other = (l.target_id if l.source_id == d.id else
2232
+ l.source_id if l.target_id == d.id else None)
2233
+ od = self.ctx.topology.devices.get(other) if other else None
2234
+ if od and _role(od.type_key) == "k8sworkload":
2235
+ hpa = _svc(od.name); break
2236
+ if not hpa:
2237
+ return
2238
+ tgt = d.properties.get("TargetCPU", "60")
2239
+
2240
+ def work_patch():
2241
+ ok, msg = self._gloader.k8s_set_hpa(cluster, hpa, target=tgt,
2242
+ mn=d.properties.get("Min"),
2243
+ mx=d.properties.get("Max"))
2244
+ self.ctx.log(f"{d.name}: HPA target {tgt}% applied." if ok
2245
+ else f"{d.name}: HPA patch failed ({msg})", "ok" if ok else "info")
2246
+ threading.Thread(target=work_patch, daemon=True).start()
2247
+
2248
+ def _loadgen_target(self, did: str) -> str | None:
2249
+ """The full URL a Load Generator should hit, from what it's wired to:
2250
+ * a Function -> http://faas:8000/<fn> (direct invoke)
2251
+ * an API Gateway -> http://<gw>/<fn> (through the front door)
2252
+ * proxy/LB/web/service -> http://<name>/ (the existing HTTP path)
2253
+ Returns None if it isn't connected to anything drivable."""
2254
+ from ..services.compiler import _role, _svc
2255
+ topo = self.ctx.topology
2256
+ for l in topo.links.values():
2257
+ other = (l.target_id if l.source_id == did else
2258
+ l.source_id if l.target_id == did else None)
2259
+ if not other or other not in topo.devices:
2260
+ continue
2261
+ d = topo.devices[other]
2262
+ tk = d.type_key
2263
+ if tk == "function":
2264
+ return f"http://faas:8000/{_svc(d.name)}"
2265
+ if tk == "api_gateway":
2266
+ fn = self._gateway_function(other)
2267
+ return f"http://{_svc(d.name)}/{fn}" if fn else None
2268
+ if tk in ("proxy", "load_balancer", "web_app") or \
2269
+ _role(tk) in ("service", "compute"):
2270
+ return f"http://{_svc(d.name)}/"
2271
+ return None
2272
+
2273
+ def _gateway_function(self, gw_did: str) -> str | None:
2274
+ """A function service name routed by this API Gateway (the first one connected)."""
2275
+ from ..services.compiler import _svc
2276
+ topo = self.ctx.topology
2277
+ for l in topo.links.values():
2278
+ other = (l.target_id if l.source_id == gw_did else
2279
+ l.source_id if l.target_id == gw_did else None)
2280
+ if other and other in topo.devices and topo.devices[other].type_key == "function":
2281
+ return _svc(topo.devices[other].name)
2282
+ return None
2283
+
2284
+ def _loadgen_hostport(self, name: str) -> int | None:
2285
+ from ..services.compiler import _svc
2286
+ for s in getattr(self, "_last_services", []):
2287
+ if _svc(s.name) == _svc(name):
2288
+ for p in s.ports:
2289
+ if p.get("web"):
2290
+ return p["host"]
2291
+ return None
2292
+
2293
+ def _drive_loadgen(self, did: str) -> None:
2294
+ """Drive a single Load Generator at its QPS against its connected target."""
2295
+ if not self._running:
2296
+ return
2297
+ import threading
2298
+ d = self.ctx.topology.devices.get(did)
2299
+ if d is None or d.type_key != "load_generator":
2300
+ return
2301
+ hp = self._loadgen_hostport(d.name)
2302
+ url = self._loadgen_target(did)
2303
+ if not hp or not url:
2304
+ if self._running and d.type_key == "load_generator":
2305
+ self.ctx.log(f"{d.name}: connect it to a Function, API Gateway, Web App "
2306
+ f"or Proxy to send load.", "info")
2307
+ return
2308
+ qps = d.properties.get("QPS", "100")
2309
+ conns = d.properties.get("Connections", "8")
2310
+ name = d.name
2311
+ try:
2312
+ off = float(qps) <= 0 # Fortio qps=0 means UNLIMITED — treat 0 as "off"
2313
+ except ValueError:
2314
+ off = False
2315
+
2316
+ def work():
2317
+ if off:
2318
+ self._gloader.stop_load(hp)
2319
+ self.ctx.log(f"{name}: load paused (rate 0).", "info")
2320
+ return
2321
+ ok, msg = self._gloader.drive_load(hp, url, qps, conns)
2322
+ self.ctx.log(f"{name}: {msg}" if ok else f"{name}: load failed ({msg})",
2323
+ "ok" if ok else "info")
2324
+ threading.Thread(target=work, daemon=True).start()
2325
+
2326
+ def _drive_loadgens(self) -> None:
2327
+ for d in list(self.ctx.topology.devices.values()):
2328
+ if d.type_key == "load_generator":
2329
+ self._drive_loadgen(d.id)
2330
+
2331
+ def _poll_k8s(self) -> None:
2332
+ if not self._running or not getattr(self, "_last_k8s", None):
2333
+ return
2334
+ import threading
2335
+ clusters = list(self._last_k8s)
2336
+
2337
+ def work():
2338
+ merged = {"deployments": {}, "pods": 0}
2339
+ for k in clusters:
2340
+ m = self._gloader.k8s_metrics(k.svc)
2341
+ merged["deployments"].update(m.get("deployments", {}))
2342
+ merged["pods"] += m.get("pods", 0)
2343
+ self.ctx.bus.k8s_metrics.emit(merged)
2344
+ threading.Thread(target=work, daemon=True).start()
2345
+
2346
+ def _on_k8s_metrics(self, snap) -> None:
2347
+ self.inspector.set_k8s_snapshot(snap)
2348
+
2349
+ def _k8s_cluster_svc(self, did: str) -> str | None:
2350
+ """The k3s cluster service a K8s element belongs to (its connected cluster, or the
2351
+ cluster of its connected Pod)."""
2352
+ from ..services.compiler import _role, _svc
2353
+ topo = self.ctx.topology
2354
+ seen, frontier = set(), [did]
2355
+ while frontier: # 2-hop walk: element -> [pod] -> cluster
2356
+ cur = frontier.pop()
2357
+ for l in topo.links.values():
2358
+ other = (l.target_id if l.source_id == cur else
2359
+ l.source_id if l.target_id == cur else None)
2360
+ od = topo.devices.get(other) if other else None
2361
+ if not od or other in seen:
2362
+ continue
2363
+ seen.add(other)
2364
+ if _role(od.type_key) == "k8scluster":
2365
+ return _svc(od.name)
2366
+ if _role(od.type_key) == "k8sworkload":
2367
+ frontier.append(other)
2368
+ return self._last_k8s[0].svc if getattr(self, "_last_k8s", None) else None
2369
+
2370
+ def _apply_k8s(self) -> None:
2371
+ """Once running, wait for each k3s cluster to be Ready and apply its manifests,
2372
+ then report the pods that scheduled."""
2373
+ import threading
2374
+ clusters = list(getattr(self, "_last_k8s", []))
2375
+
2376
+ def work():
2377
+ for k in clusters:
2378
+ self.ctx.log(f"{k.name}: starting k3s cluster (this takes ~20-30s)…", "info")
2379
+ ok, msg = self._gloader.k8s_apply(k.svc)
2380
+ if not ok:
2381
+ self.ctx.log(f"{k.name}: kubectl apply failed — {msg}", "error")
2382
+ continue
2383
+ pods = self._gloader.k8s_pods(k.svc)
2384
+ self.ctx.log(f"{k.name}: applied {len(k.deployments)} deployment(s); "
2385
+ f"{len(pods)} pod(s) scheduling.", "ok")
2386
+ threading.Thread(target=work, daemon=True).start()
2387
+
2388
+ def _poll_fabric(self) -> None:
2389
+ if not self._running:
2390
+ return
2391
+ import threading
2392
+
2393
+ def work():
2394
+ snap = self._gloader.fabric_metrics()
2395
+ if snap:
2396
+ self.ctx.bus.fabric_metrics.emit(snap)
2397
+ threading.Thread(target=work, daemon=True).start()
2398
+
2399
+ def _on_fabric_metrics(self, snap) -> None:
2400
+ self.dashboard.set_fabric(snap.get("totals", {}))
2401
+ self.inspector.set_fabric_snapshot(snap)
2402
+
2403
+ def _element_stats(self, device_name: str):
2404
+ """Live CPU%/memory sample for the Inspector's metrics plots (None if not running)."""
2405
+ if not self._running:
2406
+ return None
2407
+ from ..services.compiler import _svc
2408
+ return self._gloader.stats(_svc(device_name))
2409
+
2410
+ def _element_stats_all(self):
2411
+ """CPU/mem/net for every running container — keeps per-element Live history."""
2412
+ return self._gloader.stats_all() if self._running else {}
2413
+
2414
+ def _on_device_resized(self, device_id: str) -> None:
2415
+ """An element's size tier changed. If the lab is running, apply the new CPU cap
2416
+ to its container live (vertical scaling) via `docker update` — no restart."""
2417
+ if not self._running:
2418
+ return
2419
+ import threading
2420
+ from ..domain import pricing
2421
+ from ..services.compiler import _svc
2422
+ d = self.ctx.topology.devices.get(device_id)
2423
+ if d is None or not pricing.resizable(d.type_key):
2424
+ return
2425
+ lvl = pricing.size_level(getattr(d, "size", 1))
2426
+ lab, cpus, _mem, _mult = pricing.size_tier(lvl)
2427
+ svc, dname = _svc(d.name), d.name
2428
+
2429
+ def worker():
2430
+ ok, msg = self._gloader.update_cpus(svc, cpus)
2431
+ if ok:
2432
+ self.ctx.log(f"Scaled {dname} to {lab} ({cpus:g} vCPU) live — no restart.",
2433
+ "ok")
2434
+ else:
2435
+ self.ctx.log(f"{dname}: couldn't apply size live ({msg}). "
2436
+ f"It takes effect on the next Run.", "info")
2437
+ threading.Thread(target=worker, daemon=True).start()
2438
+
2439
+ def _open_grafana(self) -> None:
2440
+ from PySide6.QtCore import QUrl
2441
+ from PySide6.QtGui import QDesktopServices
2442
+ url = self.dashboard.grafana_url()
2443
+ if not url:
2444
+ self.ctx.log("No Grafana running — add a Dashboards element and Run.", "info")
2445
+ return
2446
+ QDesktopServices.openUrl(QUrl(url))
2447
+ self.ctx.log(f"Opening Grafana: {url}", "ok")
2448
+
2449
+ def _on_palette_explain(self, type_key: str) -> None:
2450
+ """In explain mode, clicking a palette element explains that element TYPE."""
2451
+ a = getattr(self, "assistant", None)
2452
+ if a is not None and getattr(a, "explain_mode", False):
2453
+ a.explain_element_type(type_key)
2454
+
2455
+ def _on_selection_explain(self, device_id) -> None:
2456
+ """In explain mode, selecting a device explains it on the canvas; clicking
2457
+ empty space exits. Lets the student move the explanation around freely."""
2458
+ a = getattr(self, "assistant", None)
2459
+ if a is None or not getattr(a, "explain_mode", False):
2460
+ return
2461
+ if device_id is not None: # selecting a device explains it; empty space
2462
+ dev = self.ctx.topology.devices.get(device_id) # is ignored (mode stays on,
2463
+ if dev: # exit via the Explain toggle)
2464
+ a.explain_selected(dev.name)
2465
+
2466
+ def _on_warning_explain(self, device_id: str) -> None:
2467
+ dev = self.ctx.topology.devices.get(device_id)
2468
+ if dev:
2469
+ self.ctx.select(device_id) # spotlight + select the flagged node
2470
+ self.assistant.explain_warning(dev.name)
2471
+
2472
+ def _revalidate(self) -> None:
2473
+ """Run the advisory lint and surface per-device warnings on the canvas."""
2474
+ from ..services.compiler import validate
2475
+ try:
2476
+ issues = validate(self.ctx.topology)
2477
+ except Exception:
2478
+ issues = []
2479
+ warnings: dict[str, list] = {}
2480
+ for it in issues:
2481
+ if it["level"] == "warn" and it["device"]:
2482
+ warnings.setdefault(it["device"], []).append(it["message"])
2483
+ self.ctx.warnings = warnings
2484
+ self.ctx.bus.warnings_changed.emit()
2485
+
2486
+ def element_query(self, device_name: str, command: str) -> str:
2487
+ """Run a one-shot console command against a network element (needs Docker up)."""
2488
+ if not self._workdir:
2489
+ return "(not running)"
2490
+ import subprocess
2491
+ from ..services.compiler import _role, _svc
2492
+ svc = _svc(device_name)
2493
+ dev = next((d for d in self.ctx.topology.devices.values()
2494
+ if d.name == device_name), None)
2495
+ # routers AND the OVS (gRouter in OpenFlow mode) speak via the gRouter control
2496
+ # socket — so the OVS console can run `openflow ...` to dump its flow table.
2497
+ is_router = dev is not None and _role(dev.type_key) in ("router", "ovs")
2498
+ try:
2499
+ if is_router:
2500
+ # the real C gRouter: run one CLI command over its control socket
2501
+ cmd = ["docker", "compose", "exec", "-T", svc, "python3",
2502
+ "/build/grouter-build/grconsole.py", f"/run/{svc}.ctl",
2503
+ "--once", command]
2504
+ else:
2505
+ cmd = ["docker", "compose", "exec", "-T", "fabric",
2506
+ "python", "-m", "dataplane.console", svc, command]
2507
+ r = subprocess.run(cmd, cwd=self._workdir, capture_output=True,
2508
+ text=True, timeout=15)
2509
+ return (r.stdout or r.stderr or "").strip() or "(no output)"
2510
+ except Exception as e:
2511
+ return f"(query failed: {e})"
2512
+
2513
+ def _log_startup_times(self) -> None:
2514
+ """Log per-element startup times to the Console — the VM-vs-container headline
2515
+ (a Kata Instance boots a guest kernel, so it starts much slower than a container)."""
2516
+ if not self._running:
2517
+ return
2518
+ import threading
2519
+
2520
+ def work():
2521
+ times = self._gloader.startup_times()
2522
+ if times:
2523
+ line = ", ".join(f"{svc} {ms:.0f} ms" for svc, ms in sorted(times.items()))
2524
+ self.ctx.log("Startup times — " + line, "info")
2525
+ threading.Thread(target=work, daemon=True).start()
2526
+
2527
+ def _on_function_deploy(self) -> None:
2528
+ """AWS-style 'Deploy': push the current function code to the running runtime by
2529
+ recreating only the faas container (the rest of the lab keeps running)."""
2530
+ import threading
2531
+ if not self._running or not self._workdir:
2532
+ self.ctx.log("Run the topology first, then Deploy.", "info")
2533
+ return
2534
+ cfg = self._compile()
2535
+ if not cfg.faas:
2536
+ self.ctx.log("No Functions on the canvas to deploy.", "info")
2537
+ return
2538
+ self.ctx.log("Deploying functions — recreating the faas runtime…", "info")
2539
+
2540
+ def worker(c=cfg, ai=self.ctx.settings.auto_internet):
2541
+ ok, msg = self._gloader.redeploy_faas(c, auto_internet=ai)
2542
+ self.ctx.log("Functions deployed — the runtime restarted with your latest code."
2543
+ if ok else f"Deploy failed: {msg}", "ok" if ok else "error")
2544
+
2545
+ threading.Thread(target=worker, daemon=True).start()
2546
+
2547
+ def _on_function_invoke(self, device_id: str, method: str, body: str) -> None:
2548
+ """Invoke a Function from the inspector's Test panel: call it inside the faas
2549
+ runtime, capture status/duration/cold, and send the result back to the inspector."""
2550
+ import json
2551
+ import subprocess
2552
+ import threading
2553
+ from ..services.compiler import _svc
2554
+ dev = self.ctx.topology.devices.get(device_id)
2555
+ if dev is None:
2556
+ return
2557
+ if not self._running or not self._workdir:
2558
+ self.ctx.bus.function_invoke_result.emit(
2559
+ device_id, "Start the topology first (Run), then Invoke.")
2560
+ return
2561
+ fn = _svc(dev.name)
2562
+
2563
+ def work():
2564
+ cmd = ["docker", "compose", "exec", "-T",
2565
+ "-e", f"GINI_FN={fn}", "-e", f"GINI_METHOD={method}",
2566
+ "-e", f"GINI_BODY={body}", "faas", "python", "-c", _FAAS_INVOKE]
2567
+ try:
2568
+ r = subprocess.run(cmd, cwd=self._workdir, capture_output=True,
2569
+ text=True, timeout=30)
2570
+ line = (r.stdout or "").strip().splitlines()[-1] if r.stdout.strip() else ""
2571
+ d = json.loads(line) if line else None
2572
+ if d:
2573
+ warm = "cold start" if d.get("cold") else "warm"
2574
+ text = f"HTTP {d['code']} · {d['ms']} ms · {warm}\n\n{d['body']}"
2575
+ else:
2576
+ text = "(no response)\n" + (r.stderr or "").strip()
2577
+ except Exception as e:
2578
+ text = f"Invoke failed: {e}"
2579
+ self.ctx.bus.function_invoke_result.emit(device_id, text)
2580
+
2581
+ threading.Thread(target=work, daemon=True).start()
2582
+
2583
+ # -- double-click: routers open the Router Lab, others open a terminal -- #
2584
+ def _on_device_activated(self, device_id: str) -> None:
2585
+ from ..services.compiler import _role, _svc
2586
+ dev = self.ctx.topology.devices.get(device_id)
2587
+ if dev is None:
2588
+ return
2589
+ if _role(dev.type_key) in ("router", "ovs"):
2590
+ self._open_router_lab(device_id) # OVS opens the lab in SDN dashboard mode
2591
+ return
2592
+ if dev.type_key == "xv6": # xv6 Machine -> the OS workbench
2593
+ self._open_machine_lab(device_id)
2594
+ return
2595
+ if _role(dev.type_key) == "oszoo": # OS Zoo -> the historical OS in an embedded screen
2596
+ self._open_zoo_lab(device_id)
2597
+ return
2598
+ if dev.type_key == "desktop": # headful Machine -> its graphical desktop over noVNC
2599
+ self._open_desktop_console(device_id)
2600
+ return
2601
+ if dev.type_key in ("terminal", "storage_volume"):
2602
+ self._open_peripheral(device_id) # Terminal / disk view on its xv6
2603
+ return
2604
+ if getattr(dev.type, "rider", False): # a Source/Sink -> toggle it on/off on its donor
2605
+ self._toggle_rider(device_id)
2606
+ return
2607
+ # a running service with a web dashboard -> open it (Grafana, MinIO, …)
2608
+ if self._running and _role(dev.type_key) == "service":
2609
+ svc = _svc(dev.name)
2610
+ has_web = any(_svc(s.name) == svc and any(p.get("web") for p in s.ports)
2611
+ for s in getattr(self, "_last_services", []))
2612
+ if has_web:
2613
+ self._open_console(device_id)
2614
+ return
2615
+ self._open_terminal(device_id)
2616
+
2617
+ def _toggle_rider(self, device_id: str) -> None:
2618
+ """Double-click a Source/Sink → start it (runs continuously, output streams to its Live tab)
2619
+ or stop it. Off the GUI thread since the start/kill exec can block briefly."""
2620
+ import threading
2621
+ dev = self.ctx.topology.devices.get(device_id)
2622
+ if dev is None:
2623
+ return
2624
+ threading.Thread(target=lambda: self.ctx.toggle_rider(device_id), daemon=True).start()
2625
+
2626
+ def _toggle_xv6_rider(self, rider_id: str) -> dict:
2627
+ """Start/stop an xv6 rider (Shell Probe / Workload) over the machine's console. Called by
2628
+ ctx.toggle_rider for qemu-serial riders, so double-click and the inspector both reach here."""
2629
+ import threading
2630
+ dev = self.ctx.topology.devices.get(rider_id)
2631
+ if dev is None:
2632
+ return {"ok": False}
2633
+ if rider_id in self._xv6_rider_sessions: # running -> stop
2634
+ self._xv6_rider_sessions[rider_id]["stop"] = True
2635
+ return {"ok": True, "running": False}
2636
+ donor = self.ctx.topology.donor_of(rider_id)
2637
+ provider = getattr(self, "_xv6_providers", {}).get(donor.id) if donor else None
2638
+ if provider is None:
2639
+ self.ctx.log(f"{dev.name}: run the xv6 Machine first (press Run).", "info")
2640
+ self.ctx.bus.rider_ran.emit(rider_id, {"ok": False, "running": False})
2641
+ return {"ok": False, "error": "xv6 not running"}
2642
+ from ..domain import riders as R
2643
+ try:
2644
+ cmd = R.xv6_command(dev.type_key, dev.properties)
2645
+ except R.RiderError as e:
2646
+ self.ctx.log(f"{dev.name}: {e}", "info")
2647
+ self.ctx.bus.rider_ran.emit(rider_id, {"ok": False, "running": False})
2648
+ return {"ok": False, "error": str(e)}
2649
+ sess = self._xv6_rider_sessions[rider_id] = {"stop": False}
2650
+ self.ctx.log(f"{dev.name} → xv6: {cmd}", "info")
2651
+ threading.Thread(target=self._xv6_reader, args=(rider_id, provider, cmd, sess),
2652
+ daemon=True).start()
2653
+ return {"ok": True, "running": True}
2654
+
2655
+ def _xv6_reader(self, rider_id: str, provider, cmd: str, sess: dict) -> None:
2656
+ """Type the command into the xv6 console and stream the new output back as rider snapshots
2657
+ (reusing the same rider_ran path the inspector Live tab renders)."""
2658
+ import time
2659
+ lines: list[str] = []
2660
+
2661
+ def emit(running: bool) -> None:
2662
+ raw = "\n".join(lines[-300:])
2663
+ snap = {"ok": True, "running": running, "raw": raw,
2664
+ "measurement": {"ok": bool(raw), "lines": len(lines)},
2665
+ "summary": f"{len(lines)} lines"}
2666
+ self.ctx.rider_results[rider_id] = snap
2667
+ self.ctx.bus.rider_ran.emit(rider_id, snap)
2668
+
2669
+ try:
2670
+ _, cur = provider.console_since(0) # start after the current console tail
2671
+ provider.send_input(cmd + "\n")
2672
+ emit(True)
2673
+ deadline = time.time() + 12 # stream a while, or until stopped
2674
+ while not sess.get("stop") and time.time() < deadline:
2675
+ try:
2676
+ text, cur = provider.console_since(cur)
2677
+ except Exception: # noqa: BLE001 — bridge dropped
2678
+ break
2679
+ if text:
2680
+ lines.extend(text.split("\n"))
2681
+ emit(True)
2682
+ time.sleep(0.3)
2683
+ except Exception as e: # noqa: BLE001
2684
+ self.ctx.log(f"xv6 rider error: {e}", "info")
2685
+ self._xv6_rider_sessions.pop(rider_id, None)
2686
+ emit(False)
2687
+
2688
+ def _on_rider_state(self, rider_id: str, snap) -> None:
2689
+ """Reflect a rider's live state on its node chip: green 'running' while it streams, back to
2690
+ 'ready' (donor up) or 'idle' (topology down) when it stops."""
2691
+ node = self.canvas.scene_.nodes.get(rider_id)
2692
+ if node is None:
2693
+ return
2694
+ if snap and snap.get("running"):
2695
+ node.set_status("running")
2696
+ else:
2697
+ node.set_status("ready" if self._running else "idle")
2698
+
2699
+ def _open_router_lab(self, device_id: str) -> None:
2700
+ from ..domain.router_modules import RouterProgram
2701
+ from ..services.compiler import _role
2702
+ from .router_lab import RouterLab
2703
+ dev = self.ctx.topology.devices[device_id]
2704
+ program = self._router_programs.setdefault(device_id, RouterProgram())
2705
+ # role-specialized face of the one gRouter engine: OVS -> SDN dashboard, Firewall ->
2706
+ # rules-first, plain Router -> full pipeline.
2707
+ face = ("ovs" if dev.type_key == "ovs"
2708
+ else "firewall" if dev.type_key == "firewall" else "router")
2709
+ sdn = face == "ovs"
2710
+ # When running, the Router Lab drives the REAL C gRouter's pipeline via `gpipe`
2711
+ # over its control socket (gr_rctl); offline it uses the local trace.
2712
+ cf = ((lambda c, n=dev.name: self.element_query(n, "gpipe " + c))
2713
+ if self._running else None)
2714
+ # raw CLI query used by the live panels: `openflow …` for an OVS, `route`/`arp`
2715
+ # for a router. Same control socket as the console.
2716
+ qf = ((lambda c, n=dev.name: self.element_query(n, c)) if self._running else None)
2717
+ self._router_lab = RouterLab(
2718
+ self, self.theme, dev, program,
2719
+ on_console=lambda: self._open_terminal(device_id),
2720
+ command_fn=cf, sdn=sdn, query_fn=qf, face=face)
2721
+ self._router_lab.show()
2722
+ self._router_lab.raise_()
2723
+
2724
+ def _wire_xv6_providers(self) -> None:
2725
+ """After a run, build a live Xv6Bridge for each xv6 service from its published gdb (1234)
2726
+ and serial (4444) host ports, and reset any open MachineState so the Lab/agent read live
2727
+ state. Safe no-op if the bridge deps (gdb/qemu container) aren't reachable."""
2728
+ providers = getattr(self, "_xv6_providers", {})
2729
+ for s in getattr(self, "_last_services", []):
2730
+ if getattr(s, "type_key", None) != "xv6":
2731
+ continue
2732
+ agent = next((p["host"] for p in s.ports if p.get("label") == "agent"), None)
2733
+ if agent is None:
2734
+ continue
2735
+ did = next((d.id for d in self.ctx.topology.devices.values()
2736
+ if d.name == s.name and d.type_key == "xv6"), None)
2737
+ if did is None:
2738
+ continue
2739
+ try:
2740
+ from ..runtime.xv6_bridge import connect
2741
+ q = int((self.ctx.topology.devices[did].properties or {}).get("Timeslice", "1"))
2742
+ bridge = connect(agent, quantum=q) # HTTP to the in-container agent
2743
+ providers[did] = bridge
2744
+ except Exception as e:
2745
+ self.ctx.log(f"xv6 live bridge unavailable for {s.name}: {e}", "info")
2746
+ continue
2747
+ # Attach the live plane to any already-open state so its Real toggle now works (and a
2748
+ # Lab already in Real mode goes live immediately). Never force a demo user to Real.
2749
+ ms = self.ctx.machine_states.get(did)
2750
+ if ms is not None and hasattr(ms, "attach_real"):
2751
+ ms.attach_real(bridge, vm=getattr(bridge, "vm", None),
2752
+ fs=getattr(bridge, "fs", None))
2753
+ self._xv6_providers = providers
2754
+
2755
+ def _machine_state_for(self, device_id: str):
2756
+ """Get (or lazily create) the shared MachineState for an xv6 Machine — the bridge the
2757
+ Lab renders from and the Ask GINI agent reads from. When running, a live GDB-backed
2758
+ provider is used if one has been registered (Mac-side); otherwise the offline demo feed.
2759
+ Kept on ctx so the assistant can find it without the Lab dialog being open."""
2760
+ from ..domain.machine_state import MachineState
2761
+ from ..domain.xv6 import DemoScheduler
2762
+ states = self.ctx.machine_states
2763
+ ms = states.get(device_id)
2764
+ if ms is None:
2765
+ bridge = None
2766
+ if self._running:
2767
+ bridge = getattr(self, "_xv6_providers", {}).get(device_id)
2768
+ # Default DATA MODE (a user choice thereafter, never auto-switched): Real when a live
2769
+ # bridge exists at open, Demo otherwise. The bridge is the Real plane; the demo feed
2770
+ # is built lazily by MachineState when the user toggles to Demo.
2771
+ if bridge is not None:
2772
+ ms = MachineState(bridge, device_id=device_id, mode="real",
2773
+ vm=getattr(bridge, "vm", None), fs=getattr(bridge, "fs", None))
2774
+ else:
2775
+ dev = self.ctx.topology.devices[device_id]
2776
+ demo = DemoScheduler(
2777
+ timeslice=int((dev.properties or {}).get("Timeslice", "1") or "1"))
2778
+ ms = MachineState(demo, device_id=device_id, mode="demo")
2779
+ # new teachable kernel events -> notify the assistant (proactive Coach)
2780
+ ms.on_event = lambda s, did=device_id: self.ctx.bus.machine_events.emit(did)
2781
+ states[device_id] = ms
2782
+ return ms
2783
+
2784
+ def _xv6_for_peripheral(self, device_id: str) -> str | None:
2785
+ """The xv6 Machine a peripheral is wired to (grammar guarantees at most one), or None."""
2786
+ for l in self.ctx.topology.links.values():
2787
+ other = (l.target_id if l.source_id == device_id else
2788
+ l.source_id if l.target_id == device_id else None)
2789
+ if other is None:
2790
+ continue
2791
+ od = self.ctx.topology.devices.get(other)
2792
+ if od is not None and od.type_key == "xv6":
2793
+ return other
2794
+ return None
2795
+
2796
+ def _open_peripheral(self, device_id: str) -> None:
2797
+ """Open a peripheral's view bound to the xv6 Machine it's wired to. Terminal = the shell
2798
+ console, Storage Volume = the disk's file-system face. Needs a wired xv6 (and, for the
2799
+ Terminal, a running one)."""
2800
+ dev = self.ctx.topology.devices[device_id]
2801
+ xv6_id = self._xv6_for_peripheral(device_id)
2802
+ if xv6_id is None:
2803
+ self.ctx.log(f"Wire {dev.name} to an xv6 Machine first "
2804
+ "(long-press the Machine to see where peripherals attach).", "info")
2805
+ return
2806
+ xv6 = self.ctx.topology.devices[xv6_id]
2807
+ ms = self._machine_state_for(xv6_id)
2808
+ if dev.type_key == "storage_volume":
2809
+ # the disk is the file system — reuse the Storage face against this Machine's FS reader
2810
+ from .storage_lab import StorageLab
2811
+ self._storage = StorageLab(self, self.theme, device=xv6, provider=ms.fs)
2812
+ self._storage.show(); self._storage.raise_()
2813
+ return
2814
+ if not getattr(ms, "live", False) or not hasattr(ms.provider, "console"):
2815
+ self.ctx.log(f"Start the topology (Run) so {xv6.name} is booted, then open "
2816
+ f"{dev.name}.", "info")
2817
+ return
2818
+ from .peripherals import TerminalView
2819
+ self._peripheral = TerminalView(self, self.theme, ms.provider, dev)
2820
+ self._peripheral.show(); self._peripheral.raise_()
2821
+
2822
+ def _open_machine_lab(self, device_id: str) -> None:
2823
+ """Open the Machine Lab on an xv6 Machine — the OS workbench (scheduler face).
2824
+
2825
+ Renders from the shared MachineState (the bridge). When running, that state is fed by a
2826
+ live provider reading the kernel over QEMU's GDB stub (Mac-side); offline it uses a
2827
+ deterministic demo feed so the lab is always explorable."""
2828
+ from .machine_lab import MachineLab
2829
+ dev = self.ctx.topology.devices[device_id]
2830
+ ms = self._machine_state_for(device_id)
2831
+ try:
2832
+ self._machine_lab = MachineLab(
2833
+ self, self.theme, dev, state=ms, live=getattr(ms, "live", False),
2834
+ on_console=lambda: self._open_terminal(device_id),
2835
+ on_log=lambda lvl, msg: self.ctx.bus.log.emit(lvl, msg)) # mirror to GINI Console
2836
+ except Exception as e:
2837
+ import traceback
2838
+ tb = traceback.format_exc()
2839
+ self.ctx.bus.log.emit("error", f"Machine Lab failed to open: {e}\n{tb}")
2840
+ try:
2841
+ from PySide6.QtWidgets import QMessageBox
2842
+ box = QMessageBox(self)
2843
+ box.setWindowTitle("Machine Lab failed to open")
2844
+ box.setIcon(QMessageBox.Warning)
2845
+ box.setText(f"The Machine Lab could not open:\n{e}")
2846
+ box.setDetailedText(tb) # expandable, selectable traceback to copy
2847
+ box.exec()
2848
+ except Exception:
2849
+ pass
2850
+ return
2851
+ self._machine_lab.show()
2852
+ self._machine_lab.raise_()
2853
+
2854
+ def _open_desktop_console(self, device_id: str) -> None:
2855
+ """Open a headful Desktop machine's graphical screen (X served over noVNC) embedded in a
2856
+ window — the same viewer the OS Zoo uses. Falls back to the system browser if QtWebEngine
2857
+ isn't available. Needs the topology running (the container publishes the noVNC port)."""
2858
+ dev = self.ctx.topology.devices.get(device_id)
2859
+ if dev is None:
2860
+ return
2861
+ if not self._running:
2862
+ self.ctx.log("Start the topology first (Run), then open the Desktop screen.", "info")
2863
+ return
2864
+ from ..services.compiler import _svc
2865
+ svc = _svc(dev.name)
2866
+ port = next((getattr(m, "novnc_port", 0) for m in getattr(self, "_last_machines", [])
2867
+ if _svc(m.name) == svc and getattr(m, "novnc_port", 0)), 0)
2868
+ if not port:
2869
+ self.ctx.log(f"{dev.name}: no desktop console — is it running as a Desktop (gui) host?",
2870
+ "info")
2871
+ return
2872
+ url = f"http://localhost:{port}/vnc.html?autoconnect=1&resize=remote"
2873
+ try:
2874
+ from .zoo_lab import ZooLab
2875
+ except Exception as e: # QtWebEngine missing -> browser fallback
2876
+ from PySide6.QtCore import QUrl
2877
+ from PySide6.QtGui import QDesktopServices
2878
+ QDesktopServices.openUrl(QUrl(url))
2879
+ self.ctx.log(f"Opening {dev.name} in your browser (embedded view needs "
2880
+ f"PySide6-Addons for QtWebEngine: {e}): {url}", "info")
2881
+ return
2882
+ self._desktop_lab = ZooLab(self, self.theme, dev, url)
2883
+ self._desktop_lab.show()
2884
+ self._desktop_lab.raise_()
2885
+ self.ctx.log(f"Opening {dev.name} desktop: {url}", "ok")
2886
+
2887
+ def _open_zoo_lab(self, device_id: str) -> None:
2888
+ """Open the Zoo Lab on an OS Zoo element — the historical OS running under emulation,
2889
+ its screen embedded over noVNC. Needs the topology running (the container serves the
2890
+ framebuffer). If QtWebEngine isn't available, fall back to the system browser so the
2891
+ feature still works with no extra dependency."""
2892
+ dev = self.ctx.topology.devices.get(device_id)
2893
+ if dev is None:
2894
+ return
2895
+ if not self._running:
2896
+ self.ctx.log("Start the topology first (Run), then open the OS Zoo screen.", "info")
2897
+ return
2898
+ from ..services.compiler import _svc
2899
+ svc = _svc(dev.name)
2900
+ screen = None
2901
+ for s in getattr(self, "_last_services", []):
2902
+ if _svc(s.name) == svc:
2903
+ screen = next((p for p in s.ports
2904
+ if p.get("web") and p.get("label") == "screen"), None)
2905
+ break
2906
+ if screen is None:
2907
+ self.ctx.log(f"{dev.name}: no OS Zoo screen port — rebuild the image (gini-oszoo).",
2908
+ "info")
2909
+ return
2910
+ url = f"http://localhost:{screen['host']}{screen.get('path', '')}"
2911
+ try:
2912
+ from .zoo_lab import ZooLab
2913
+ except Exception as e: # QtWebEngine missing -> browser fallback
2914
+ from PySide6.QtCore import QUrl
2915
+ from PySide6.QtGui import QDesktopServices
2916
+ QDesktopServices.openUrl(QUrl(url))
2917
+ self.ctx.log(f"Opening {dev.name} in your browser (embedded view needs "
2918
+ f"PySide6-Addons for QtWebEngine: {e}): {url}", "info")
2919
+ return
2920
+ self._zoo_lab = ZooLab(self, self.theme, dev, url)
2921
+ self._zoo_lab.show()
2922
+ self._zoo_lab.raise_()
2923
+ self.ctx.log(f"Opening {dev.name} (OS Zoo): {url}", "ok")
2924
+
2925
+ def _open_console(self, device_id: str) -> None:
2926
+ """Open a service's web dashboard (Grafana, MinIO, RabbitMQ, …) in the browser.
2927
+ These ship full web UIs — the browser is their native interface — so we just
2928
+ launch the published console URL rather than rebuilding it natively."""
2929
+ from PySide6.QtCore import QUrl
2930
+ from PySide6.QtGui import QDesktopServices
2931
+ dev = self.ctx.topology.devices.get(device_id)
2932
+ if dev is None:
2933
+ return
2934
+ if not self._running:
2935
+ self.ctx.log("Start the topology first (Run), then open the console.", "info")
2936
+ return
2937
+ from ..services.compiler import _svc
2938
+ svc = _svc(dev.name)
2939
+ web = None
2940
+ for s in getattr(self, "_last_services", []):
2941
+ if _svc(s.name) == svc:
2942
+ web = next((p for p in s.ports if p.get("web")), None)
2943
+ break
2944
+ if web is None:
2945
+ self.ctx.log(f"{dev.name} has no web console — try “Log in” or “View logs”.",
2946
+ "info")
2947
+ return
2948
+ url = f"http://localhost:{web['host']}{web.get('path', '')}"
2949
+ QDesktopServices.openUrl(QUrl(url))
2950
+ self.ctx.log(f"Opening {dev.name} console: {url}", "ok")
2951
+
2952
+ def _open_logs(self, device_id: str) -> None:
2953
+ """Open a terminal tailing this element's container logs — handy for watching
2954
+ the OpenFlow handshake (POX '[…] connected') and gRouter connect attempts."""
2955
+ from ..services import open_terminal
2956
+ from ..services.compiler import _role, _svc
2957
+ dev = self.ctx.topology.devices.get(device_id)
2958
+ if dev is None:
2959
+ return
2960
+ if not self._running or not self._workdir:
2961
+ self.ctx.log("Start the topology first (Run), then right-click → View logs.",
2962
+ "info")
2963
+ return
2964
+ role = _role(dev.type_key)
2965
+ if role == "switch": # plain switches live in the fabric container
2966
+ log_cmd = "docker compose logs --tail=200 -f fabric"
2967
+ elif role == "k8sworkload": # a Pod has no container — tail it via kubectl
2968
+ cluster = self._k8s_cluster_svc(device_id)
2969
+ if not cluster:
2970
+ self.ctx.log(f"{dev.name} isn't connected to a K8s Cluster yet.", "info")
2971
+ return
2972
+ log_cmd = f"docker compose exec {cluster} kubectl logs deploy/{_svc(dev.name)} --tail=200 -f"
2973
+ elif role == "hpa":
2974
+ self.ctx.log(f"{dev.name} (autoscaler) has no logs — double-click it for status.",
2975
+ "info")
2976
+ return
2977
+ elif role == "function": # functions share the one `faas` runtime container
2978
+ log_cmd = "docker compose logs --tail=200 -f faas"
2979
+ else:
2980
+ log_cmd = f"docker compose logs --tail=200 -f {_svc(dev.name)}"
2981
+ ok, msg = open_terminal(f"GINI {dev.name} logs", self._workdir, log_cmd)
2982
+ self.ctx.log(f"Opening logs for {dev.name}…" if ok
2983
+ else f"Could not open logs: {msg}", "info" if ok else "error")
2984
+
2985
+ # -- log into a device -------------------------------------------------- #
2986
+ def _open_terminal(self, device_id: str) -> None:
2987
+ from ..services import open_terminal
2988
+ from ..services.compiler import _role, _svc
2989
+ dev = self.ctx.topology.devices.get(device_id)
2990
+ if dev is None:
2991
+ return
2992
+ role = _role(dev.type_key)
2993
+ if role == "group":
2994
+ self.ctx.log(f"{dev.name} is a grouping — nothing to log into.", "info")
2995
+ return
2996
+ if not self._running or not self._workdir:
2997
+ self.ctx.log("Start the topology first (Run), then double-click to log in.",
2998
+ "info")
2999
+ return
3000
+ svc = _svc(dev.name)
3001
+ if role == "xv6": # the real xv6 console is QEMU's serial (published TCP)
3002
+ port = None
3003
+ for ss in getattr(self, "_last_services", []):
3004
+ if _svc(ss.name) == svc:
3005
+ port = next((p["host"] for p in ss.ports if p.get("label") == "serial"), None)
3006
+ break
3007
+ if port is None:
3008
+ self.ctx.log(f"{dev.name}: serial console not available yet.", "info")
3009
+ return
3010
+ cmd = f"nc localhost {port}" # xv6 shell; Ctrl-P prints the process table
3011
+ kind = "xv6 serial console"
3012
+ elif role == "machine":
3013
+ cmd = f"docker compose exec {svc} sh"
3014
+ kind = "shell"
3015
+ elif role in ("router", "ovs"): # real C gRouter CLI over its control socket
3016
+ cmd = (f"docker compose exec {svc} python3 "
3017
+ f"/build/grouter-build/grconsole.py /run/{svc}.ctl")
3018
+ kind = "OpenFlow switch console" if role == "ovs" else "router console"
3019
+ elif role == "controller": # POX container — a shell to inspect it / tail logs
3020
+ cmd = f"docker compose exec {svc} sh"
3021
+ kind = "controller shell"
3022
+ elif role in ("service", "compute"): # cloud service / compute container — shell
3023
+ cmd = f"docker compose exec {svc} sh"
3024
+ kind = "service shell" if role == "service" else "instance shell"
3025
+ elif role in ("k8scluster", "k8snode"): # the real k3s container — kubectl lives here
3026
+ cmd = f"docker compose exec {svc} sh"
3027
+ kind = "Kubernetes shell (run kubectl here)"
3028
+ elif role == "k8sworkload": # a Pod = a Deployment; exec into one of its pods via the cluster
3029
+ cluster = self._k8s_cluster_svc(device_id)
3030
+ if not cluster:
3031
+ self.ctx.log(f"{dev.name} isn't connected to a K8s Cluster yet.", "info")
3032
+ return
3033
+ cmd = f"docker compose exec {cluster} kubectl exec -it deploy/{svc} -- sh"
3034
+ kind = "pod shell"
3035
+ elif role == "hpa": # the Pod Autoscaler (HPA) — show its live status
3036
+ cluster = self._k8s_cluster_svc(device_id)
3037
+ hpa = next((_svc(self.ctx.topology.devices[o].name)
3038
+ for l in self.ctx.topology.links.values()
3039
+ for o in (l.target_id if l.source_id == device_id else
3040
+ l.source_id if l.target_id == device_id else None,)
3041
+ if o and _role(self.ctx.topology.devices[o].type_key) == "k8sworkload"), None)
3042
+ if not cluster or not hpa:
3043
+ self.ctx.log(f"{dev.name} isn't attached to a Pod yet.", "info")
3044
+ return
3045
+ cmd = f"docker compose exec {cluster} kubectl describe hpa {hpa}"
3046
+ kind = "autoscaler status"
3047
+ elif role == "function": # a handler in the shared `faas` runtime, not its own container
3048
+ cmd = "docker compose exec faas sh"
3049
+ kind = "faas runtime shell"
3050
+ self.ctx.log(
3051
+ f"{dev.name} runs in the shared faas runtime. Invoke it from this shell with:\n"
3052
+ f" python -c \"import urllib.request as u; "
3053
+ f"print(u.urlopen('http://localhost:8000/{svc}').read().decode())\"", "info")
3054
+ else: # plain switch — attach to that element's console in the fabric container
3055
+ cmd = f"docker compose exec fabric python -m dataplane.console {svc}"
3056
+ kind = "console"
3057
+ ok, msg = open_terminal(f"GINI {dev.name} {kind}", self._workdir, cmd)
3058
+ self.ctx.log(f"Opening {kind} for {dev.name}…" if ok
3059
+ else f"Could not open terminal: {msg}", "info" if ok else "error")
3060
+
3061
+ # -- reactions ---------------------------------------------------------- #
3062
+ def _on_scene_selection(self) -> None:
3063
+ # single source of truth for selection -> inspector (avoids the click race)
3064
+ from .canvas import GroupItem, NodeItem
3065
+ try:
3066
+ selected = self.canvas.scene_.selectedItems()
3067
+ except RuntimeError:
3068
+ return # scene torn down (window closing)
3069
+ nodes = [i for i in selected if isinstance(i, (NodeItem, GroupItem))]
3070
+ if nodes:
3071
+ self.ctx.select(nodes[0].inst.id)
3072
+ else:
3073
+ self.ctx.select(None)
3074
+ self.ctx.bus.present_clear.emit() # clicking empty space dismisses the tutor
3075
+ self._update_delete_enabled()
3076
+
3077
+ # -- delete devices ----------------------------------------------------- #
3078
+ def _make_delete_shortcut(self) -> None:
3079
+ from PySide6.QtCore import Qt
3080
+ from PySide6.QtGui import QKeySequence
3081
+ a = QAction("Delete device", self)
3082
+ a.setShortcuts([QKeySequence(QKeySequence.Delete), QKeySequence(Qt.Key_Backspace)])
3083
+ a.setShortcutContext(Qt.WidgetWithChildrenShortcut) # only when the canvas has focus
3084
+ a.triggered.connect(self._delete_selected)
3085
+ self.canvas.addAction(a)
3086
+
3087
+ def _update_delete_enabled(self) -> None:
3088
+ from .canvas import GroupItem, NodeItem
3089
+ has_sel = any(isinstance(i, (NodeItem, GroupItem)) # boxes (VPC/Subnet/Region) too
3090
+ for i in self.canvas.scene_.selectedItems())
3091
+ busy = self._running or getattr(self, "_stopping", False)
3092
+ self._delete_act.setEnabled(has_sel and not busy)
3093
+
3094
+ def _delete_selected(self) -> None:
3095
+ # delete selected CARDS *and* BOXES — GroupItems (VPC/Subnet/Region) were excluded, so a
3096
+ # selected VPC/Subnet left Delete + the trash button doing nothing.
3097
+ from .canvas import GroupItem, NodeItem
3098
+ ids = [i.inst.id for i in self.canvas.scene_.selectedItems()
3099
+ if isinstance(i, (NodeItem, GroupItem))]
3100
+ self._remove_devices(ids)
3101
+
3102
+ def _delete_device(self, device_id: str) -> None:
3103
+ self._remove_devices([device_id])
3104
+
3105
+ def _remove_devices(self, ids: list[str]) -> None:
3106
+ if not ids:
3107
+ return
3108
+ if self._running or getattr(self, "_stopping", False):
3109
+ self.ctx.log("Stop the topology before removing devices "
3110
+ "(running elements can't be deleted).", "info")
3111
+ return
3112
+ names = []
3113
+ for did in ids:
3114
+ dev = self.ctx.topology.devices.get(did)
3115
+ if dev:
3116
+ names.append(dev.name)
3117
+ self.ctx.remove_device(did) # model + canvas (device_removed) + links
3118
+ if names:
3119
+ self.ctx.select(None)
3120
+ self._recompute_addressing() # IPs shift when a device leaves
3121
+ self._update_status()
3122
+ self._update_delete_enabled()
3123
+ self.ctx.log(f"Removed {', '.join(names)}.", "info")
3124
+
3125
+ def _on_theme_changed(self, name: str) -> None:
3126
+ # persist on EVERY theme switch (the toolbar palette menu used to change the theme
3127
+ # live but never save it, so it reverted on restart)
3128
+ self.ctx.settings.theme = name
3129
+ self._persist_settings()
3130
+ self.canvas.scene_.set_theme(self.theme.theme)
3131
+ self._refresh_icons()
3132
+ # the pill widget paints its own font — nudge it to re-measure/repaint at the new text size
3133
+ if getattr(self, "mode_indicator", None) is not None:
3134
+ self.mode_indicator.updateGeometry(); self.mode_indicator.update()
3135
+ self._update_status()
3136
+
3137
+ def _on_log(self, level: str, message: str) -> None:
3138
+ tag = {"ok": "✓", "error": "✕", "chat": "›"}.get(level, "•")
3139
+ self.console.appendPlainText(f"{tag} {message}")
3140
+
3141
+ def _update_status(self) -> None:
3142
+ s = self.api.summary()
3143
+ running = getattr(self, "_running", False)
3144
+ busy = running or getattr(self, "_stopping", False)
3145
+ self.status_conn.setText(" ● running" if running else " ● idle")
3146
+ self.status_counts.setText(f"{s['devices']} devices · {s['links']} links ")
3147
+ self.status_theme.setText(f"{self.theme.theme.name} ")
3148
+ if hasattr(self, "_delete_act"):
3149
+ self._update_delete_enabled() # disabled while running
3150
+ if hasattr(self, "_nav_btn"): # can't swap the project out from under a run
3151
+ self._nav_btn.setEnabled(not busy)
3152
+ self._nav_btn.setToolTip("Stop the topology before switching projects" if busy
3153
+ else "Project — switch, create, save, or set the AI brief")