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/cue_cards.py ADDED
@@ -0,0 +1,214 @@
1
+ """Cue Cards — a launch-time feature tour for gBuilder.
2
+
3
+ A simple card deck: each card shows a real screenshot of the feature (curated portions of
4
+ the actual UI, generated by scripts/gen_cue_shots.py into assets/cue/<kind>.png), a title,
5
+ and a short explanation. Navigate with the ‹ / › arrows or the keyboard. "Don't show at
6
+ launch" + the Settings Help tab control whether it auto-opens; reopen from Help → Feature
7
+ Tour.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+
14
+ from PySide6.QtCore import QRectF, Qt
15
+ from PySide6.QtGui import QColor, QFont, QPainter, QPixmap
16
+ from PySide6.QtWidgets import (
17
+ QCheckBox, QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
18
+ )
19
+ from .theme.manager import sp as _sp
20
+
21
+ _CUE_DIR = Path(__file__).resolve().parent / "assets" / "cue"
22
+
23
+
24
+ def _theme_slug(name: str) -> str:
25
+ return re.sub(r"[^a-z0-9]", "", (name or "dark").lower()) or "dark"
26
+
27
+
28
+ def _cue_candidates(cue_dir: Path, slug: str, dark: bool, kind: str) -> list[Path]:
29
+ """Screenshot search order: the running theme's own shots first, then its
30
+ light/dark *family* (so Sand/Blue/Green reuse the Light set), then dark, then
31
+ a legacy flat file."""
32
+ family = "dark" if dark else "light"
33
+ return [cue_dir / slug / f"{kind}.png",
34
+ cue_dir / family / f"{kind}.png",
35
+ cue_dir / "dark" / f"{kind}.png",
36
+ cue_dir / f"{kind}.png"]
37
+
38
+
39
+ # Each card: art kind (-> assets/cue/<kind>.png), title, body. Order = tour order.
40
+ FEATURE_CARDS = [
41
+ ("welcome", "Welcome to gBuilder",
42
+ "Design real networks and cloud systems by drawing them, then run them as live Docker "
43
+ "containers. Take the tour with the ‹ / › arrows (or your keyboard)."),
44
+ ("canvas", "Build by drawing",
45
+ "Drag elements from the palette onto the canvas and connect them with a right-drag. The "
46
+ "diagram you draw is the system — GINI compiles it into a real runtime when you Run."),
47
+ ("wizard", "X-ray & the AI Wizard",
48
+ "Unsure what connects to what? Long-press an element to see its valid neighbours as "
49
+ "ghosts, or state a goal and the Wizard walks you through building it, one tap at a time."),
50
+ ("run", "Run on real Docker",
51
+ "Press Run and your topology launches as real containers — a C router, switches, "
52
+ "Kubernetes, databases and more. Badges turn green; double-click an element to log in."),
53
+ ("cost", "The GINI $ cost meter",
54
+ "A live 'cloud bill' ticks up while the lab runs, split by category, so pay-as-you-go is "
55
+ "tangible. Edit the per-hour rates under Settings → Pricing."),
56
+ ("metrics", "Live metrics",
57
+ "Select a running element and open the Live tab to watch its CPU, memory, throughput and "
58
+ "latency in real time — the same telemetry that feeds the dashboard."),
59
+ ("cloud", "Cloud services & VPCs",
60
+ "Drop managed services — databases, caches, queues, object storage — as real containers, "
61
+ "and group them in a VPC box. Each VPC becomes its own isolated network."),
62
+ ("serverless", "Serverless functions",
63
+ "Add a Function and write a Python handler in the inspector. Invoke it there to see the "
64
+ "response, and Deploy code changes without restarting the rest of the lab."),
65
+ ("router", "Program the router",
66
+ "Open the Router Lab on any router to build its packet-processing pipeline and run real "
67
+ "CLI commands against the live gRouter."),
68
+ ("ai", "Ask GINI",
69
+ "Ask in plain English. GINI builds, explains warnings, traces packet paths and guides you "
70
+ "with the Wizard — and it still helps even with no local LLM connected."),
71
+ ("settings", "Make it yours",
72
+ "Settings (Ctrl+,) is organized into tabs — Appearance, Networking, GINI AI, Naming and "
73
+ "Pricing. Reopen this tour any time from Help → Feature Tour."),
74
+ ]
75
+
76
+
77
+ def _qcolor(spec) -> QColor:
78
+ if isinstance(spec, QColor):
79
+ return spec
80
+ if isinstance(spec, str) and spec.startswith("rgba("):
81
+ n = [int(float(x)) for x in spec[5:-1].split(",")]
82
+ return QColor(n[0], n[1], n[2], n[3] if len(n) > 3 else 255)
83
+ return QColor(spec)
84
+
85
+
86
+ class CardArt(QWidget):
87
+ """Shows the current card's screenshot, scaled to fit (keeping aspect), centered."""
88
+
89
+ def __init__(self, theme) -> None:
90
+ super().__init__()
91
+ self.theme = theme
92
+ self._pix: QPixmap | None = None
93
+ self._kind = "welcome"
94
+ self.setMinimumHeight(320)
95
+
96
+ def set_kind(self, kind: str) -> None:
97
+ self._kind = kind
98
+ # prefer the screenshot for the running theme; fall back within the light/dark
99
+ # family (so Sand/Blue/Green reuse the Light shots), then dark, then legacy flat
100
+ slug = _theme_slug(self.theme.theme.name)
101
+ pix = QPixmap()
102
+ for cand in _cue_candidates(_CUE_DIR, slug, self.theme.theme.dark, kind):
103
+ if cand.exists():
104
+ pix = QPixmap(str(cand))
105
+ if not pix.isNull():
106
+ break
107
+ self._pix = pix if not pix.isNull() else None
108
+ self.update()
109
+
110
+ def paintEvent(self, _e) -> None:
111
+ t = self.theme.theme
112
+ p = QPainter(self)
113
+ p.setRenderHint(QPainter.SmoothPixmapTransform, True)
114
+ p.setRenderHint(QPainter.Antialiasing, True)
115
+ r = QRectF(self.rect())
116
+ p.fillRect(r, _qcolor(getattr(t, "panel", t.bg)))
117
+ if self._pix is not None and not self._pix.isNull():
118
+ # fit the full-resolution pixmap into the available area keeping aspect, and let
119
+ # the painter scale it straight to device pixels — pre-scaling to logical size
120
+ # first would throw away resolution and blur on Retina/HiDPI displays.
121
+ avail = r.adjusted(12, 12, -12, -12)
122
+ pw, ph = self._pix.width(), self._pix.height()
123
+ s = min(avail.width() / pw, avail.height() / ph)
124
+ tw, th = pw * s, ph * s
125
+ target = QRectF(r.center().x() - tw / 2, r.center().y() - th / 2, tw, th)
126
+ p.drawPixmap(target, self._pix, QRectF(0, 0, pw, ph))
127
+ else: # graceful fallback if an image is missing
128
+ p.setPen(_qcolor(getattr(t, "muted", t.text)))
129
+ f = QFont(); f.setPointSize(_sp(13)); p.setFont(f)
130
+ p.drawText(r, Qt.AlignCenter, self._kind)
131
+ p.end()
132
+
133
+
134
+ class CueCards(QDialog):
135
+ """The card-deck dialog. `persist` is called after the user changes the launch pref."""
136
+
137
+ def __init__(self, parent, theme, settings, cards=FEATURE_CARDS, persist=None) -> None:
138
+ super().__init__(parent)
139
+ self.setWindowTitle("gBuilder — Feature Tour")
140
+ self.setModal(True)
141
+ self.setMinimumSize(760, 660)
142
+ self.theme = theme
143
+ self.settings = settings
144
+ self._cards = list(cards)
145
+ self._persist = persist or (lambda: None)
146
+ self._i = 0
147
+
148
+ t = theme.theme
149
+ self.setStyleSheet(
150
+ f"QDialog {{ background: {_color(t.bg)}; }}"
151
+ f"QLabel {{ color: {_color(t.text)}; }}"
152
+ f"QLabel#Body {{ color: {_color(getattr(t, 'muted', t.text))}; font-size: 15px; }}")
153
+
154
+ lay = QVBoxLayout(self)
155
+ lay.setContentsMargins(20, 20, 20, 16)
156
+ lay.setSpacing(12)
157
+ self.art = CardArt(theme)
158
+ lay.addWidget(self.art, 1)
159
+ self.title = QLabel()
160
+ tf = QFont(); tf.setBold(True); tf.setPointSize(_sp(21))
161
+ self.title.setFont(tf)
162
+ lay.addWidget(self.title)
163
+ self.body = QLabel(); self.body.setObjectName("Body"); self.body.setWordWrap(True)
164
+ self.body.setMinimumHeight(74)
165
+ lay.addWidget(self.body)
166
+
167
+ footer = QHBoxLayout()
168
+ self.dont = QCheckBox("Don't show at launch")
169
+ self.dont.setChecked(not settings.show_help_on_launch)
170
+ self.dont.toggled.connect(self._on_dont)
171
+ footer.addWidget(self.dont)
172
+ footer.addStretch(1)
173
+ self.back = QPushButton("‹ Back"); self.back.clicked.connect(lambda: self._go(-1))
174
+ self.count = QLabel(); self.count.setObjectName("Body")
175
+ self.next = QPushButton("Next ›"); self.next.clicked.connect(lambda: self._go(1))
176
+ done = QPushButton("Done"); done.setObjectName("Accent"); done.clicked.connect(self.accept)
177
+ for w in (self.back, self.count, self.next, done):
178
+ footer.addWidget(w)
179
+ lay.addLayout(footer)
180
+
181
+ self._show_card()
182
+
183
+ # -- navigation --------------------------------------------------------- #
184
+ def _go(self, delta: int) -> None:
185
+ self._i = max(0, min(len(self._cards) - 1, self._i + delta))
186
+ self._show_card()
187
+
188
+ def _show_card(self) -> None:
189
+ kind, title, body = self._cards[self._i]
190
+ self.art.set_kind(kind)
191
+ self.title.setText(title)
192
+ self.body.setText(body)
193
+ self.count.setText(f"{self._i + 1} / {len(self._cards)}")
194
+ self.back.setEnabled(self._i > 0)
195
+ self.next.setEnabled(self._i < len(self._cards) - 1)
196
+
197
+ def keyPressEvent(self, e) -> None:
198
+ if e.key() in (Qt.Key_Right, Qt.Key_Space):
199
+ self._go(1)
200
+ elif e.key() == Qt.Key_Left:
201
+ self._go(-1)
202
+ elif e.key() == Qt.Key_Escape:
203
+ self.accept()
204
+ else:
205
+ super().keyPressEvent(e)
206
+
207
+ # -- preference --------------------------------------------------------- #
208
+ def _on_dont(self, checked: bool) -> None:
209
+ self.settings.show_help_on_launch = not checked
210
+ self._persist()
211
+
212
+
213
+ def _color(spec) -> str:
214
+ return _qcolor(spec).name()
gini/ui/dashboard.py ADDED
@@ -0,0 +1,222 @@
1
+ """The analytics strip below the Console — a live GINI $ "cloud bill".
2
+
3
+ Every billable element you place is a rented resource (see domain/pricing.py). While a
4
+ lab runs, this panel accrues `rate/hr × elapsed` in GINI $ so students *feel* cloud
5
+ pay-as-you-go. It also shows a per-category cost breakdown and resource counts. Real
6
+ performance telemetry (latency/throughput) is deliberately left to Grafana — the
7
+ "Open Grafana" button jumps to the running dashboard when one is on the canvas.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import time
12
+
13
+ from PySide6.QtCore import Qt, QTimer, Signal
14
+ from PySide6.QtWidgets import (
15
+ QFrame, QHBoxLayout, QLabel, QPushButton, QVBoxLayout, QWidget,
16
+ )
17
+
18
+ from ..domain.pricing import CATEGORY_ORDER
19
+
20
+ # category -> theme accent-token key (for the little coloured cost chips)
21
+ _CAT_ACCENT = {"Compute": "purple", "Serverless": "pink", "Kubernetes": "cyan",
22
+ "Networking": "blue", "Services": "teal", "Observability": "amber"}
23
+
24
+
25
+ class Dashboard(QWidget):
26
+ """Compact, short cost/analytics panel. Driven by the main window:
27
+ `set_estimate()` on every topology change, `start()`/`stop()` on run/stop."""
28
+
29
+ open_grafana_requested = Signal()
30
+
31
+ def __init__(self, theme, parent=None) -> None:
32
+ super().__init__(parent)
33
+ self.theme = theme # ThemeManager
34
+ self._rate = 0.0 # GINI $/hr currently billed
35
+ self._count = 0
36
+ self._by_cat: dict[str, dict] = {}
37
+ self._accrued = 0.0 # GINI $ accrued this session
38
+ self._running = False
39
+ self._start: float | None = None
40
+ self._grafana_url: str | None = None
41
+ self.setMaximumHeight(104)
42
+
43
+ root = QHBoxLayout(self)
44
+ root.setContentsMargins(14, 8, 14, 8)
45
+ root.setSpacing(18)
46
+
47
+ self.total_lbl = self._value("GINI $ 0.00")
48
+ self.total_lbl.setObjectName("DashTotal")
49
+ root.addLayout(self._metric(self.total_lbl, "session cost"))
50
+ self.rate_lbl = self._value("0.00")
51
+ root.addLayout(self._metric(self.rate_lbl, "GINI $ / hr"))
52
+ self.elapsed_lbl = self._value("—")
53
+ root.addLayout(self._metric(self.elapsed_lbl, "elapsed"))
54
+ self.count_lbl = self._value("0")
55
+ root.addLayout(self._metric(self.count_lbl, "resources"))
56
+ self.rps_lbl = self._value("—")
57
+ root.addLayout(self._metric(self.rps_lbl, "req/s · lab"))
58
+
59
+ sep = QFrame(); sep.setFrameShape(QFrame.VLine); sep.setObjectName("DashSep")
60
+ root.addWidget(sep)
61
+
62
+ # cost breakdown by category (coloured chips)
63
+ bd = QVBoxLayout(); bd.setSpacing(2)
64
+ bd.addWidget(self._caption("COST BREAKDOWN"))
65
+ self.cats_lbl = QLabel("—")
66
+ self.cats_lbl.setTextFormat(Qt.RichText)
67
+ self.cats_lbl.setObjectName("DashChips")
68
+ bd.addWidget(self.cats_lbl)
69
+ root.addLayout(bd)
70
+
71
+ root.addStretch(1)
72
+
73
+ right = QVBoxLayout(); right.setSpacing(4); right.setAlignment(Qt.AlignVCenter)
74
+ self.bill_lbl = self._caption("● not running — projected rate")
75
+ self.bill_lbl.setObjectName("DashState")
76
+ right.addWidget(self.bill_lbl, alignment=Qt.AlignRight)
77
+ self.grafana_btn = QPushButton("Open Grafana")
78
+ self.grafana_btn.setObjectName("DashGrafana")
79
+ self.grafana_btn.setCursor(Qt.PointingHandCursor)
80
+ self.grafana_btn.setEnabled(False)
81
+ self.grafana_btn.setToolTip("Latency & throughput live in Grafana — add a "
82
+ "Dashboards element to your topology.")
83
+ self.grafana_btn.clicked.connect(self.open_grafana_requested.emit)
84
+ right.addWidget(self.grafana_btn, alignment=Qt.AlignRight)
85
+ root.addLayout(right)
86
+
87
+ self._tick = QTimer(self)
88
+ self._tick.setInterval(1000)
89
+ self._tick.timeout.connect(self._on_tick)
90
+
91
+ if hasattr(theme, "themeChanged"):
92
+ theme.themeChanged.connect(self._restyle)
93
+ self._restyle()
94
+ self._render()
95
+
96
+ # -- small builders ----------------------------------------------------- #
97
+ def _value(self, text: str) -> QLabel:
98
+ lbl = QLabel(text); lbl.setObjectName("DashValue")
99
+ return lbl
100
+
101
+ def _caption(self, text: str) -> QLabel:
102
+ lbl = QLabel(text); lbl.setObjectName("DashCaption")
103
+ return lbl
104
+
105
+ def _metric(self, value: QLabel, caption: str) -> QVBoxLayout:
106
+ box = QVBoxLayout(); box.setSpacing(1)
107
+ box.addWidget(value)
108
+ box.addWidget(self._caption(caption))
109
+ return box
110
+
111
+ # -- public API (called by the main window) ----------------------------- #
112
+ def set_estimate(self, bill: dict) -> None:
113
+ """Update the projected rate/breakdown from the current canvas (idle preview)."""
114
+ self._rate = float(bill.get("rate_per_hr", 0.0))
115
+ self._count = int(bill.get("count", 0))
116
+ self._by_cat = dict(bill.get("by_category", {}))
117
+ self._render()
118
+
119
+ def reset(self) -> None:
120
+ """Zero the session meter — used when a different project is loaded so the sticker
121
+ doesn't carry the previous experiment's accrued bill."""
122
+ self._accrued = 0.0
123
+ self._running = False
124
+ self._start = None
125
+ self._tick.stop()
126
+ self._render()
127
+
128
+ def start(self, rate_per_hr: float) -> None:
129
+ """Begin billing: reset the meter and accrue at `rate_per_hr`."""
130
+ self._rate = float(rate_per_hr)
131
+ self._accrued = 0.0
132
+ self._running = True
133
+ self._start = time.monotonic()
134
+ if not self._tick.isActive():
135
+ self._tick.start()
136
+ self._render()
137
+
138
+ def stop(self) -> None:
139
+ """Freeze the meter at the final session cost (kept on screen until next run)."""
140
+ if self._running and self._start is not None:
141
+ self._accrued = self._rate * (time.monotonic() - self._start) / 3600.0
142
+ self._running = False
143
+ self._tick.stop()
144
+ self._render()
145
+
146
+ def set_grafana_url(self, url: str | None) -> None:
147
+ self._grafana_url = url
148
+ self.grafana_btn.setEnabled(bool(url))
149
+
150
+ def set_fabric(self, totals: dict) -> None:
151
+ """Lab-wide app metrics from the cloud fabric (request rate, services up)."""
152
+ if totals and totals.get("services_total"):
153
+ rps = totals.get("rps", 0.0)
154
+ up, tot = totals.get("services_up", 0), totals.get("services_total", 0)
155
+ self.rps_lbl.setText(f"{rps:,.0f}")
156
+ self.rps_lbl.setToolTip(f"{up}/{tot} services up")
157
+ else:
158
+ self.rps_lbl.setText("—")
159
+
160
+ def grafana_url(self) -> str | None:
161
+ return self._grafana_url
162
+
163
+ # -- internals ---------------------------------------------------------- #
164
+ def _on_tick(self) -> None:
165
+ if self._running and self._start is not None:
166
+ self._accrued = self._rate * (time.monotonic() - self._start) / 3600.0
167
+ self._render()
168
+
169
+ @staticmethod
170
+ def _fmt_elapsed(secs: float) -> str:
171
+ s = int(secs)
172
+ h, rem = divmod(s, 3600)
173
+ m, s = divmod(rem, 60)
174
+ return f"{h:d}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
175
+
176
+ def _render(self) -> None:
177
+ self.total_lbl.setText(f"GINI $ {self._accrued:,.2f}")
178
+ self.rate_lbl.setText(f"{self._rate:,.2f}")
179
+ self.count_lbl.setText(str(self._count))
180
+ if self._running and self._start is not None:
181
+ self.elapsed_lbl.setText(self._fmt_elapsed(time.monotonic() - self._start))
182
+ self.bill_lbl.setText("● billing — accruing live")
183
+ else:
184
+ self.elapsed_lbl.setText("—")
185
+ self.bill_lbl.setText("○ not running — projected rate")
186
+
187
+ t = self.theme.theme
188
+ chips = []
189
+ for cat in CATEGORY_ORDER:
190
+ d = self._by_cat.get(cat)
191
+ if not d:
192
+ continue
193
+ col = t.accent_for(_CAT_ACCENT.get(cat, "blue"))
194
+ chips.append(
195
+ f'<span style="color:{col};font-weight:600">{cat}</span> '
196
+ f'<span style="color:{t.text}">{d["rate"]:,.0f}</span>'
197
+ f'<span style="color:{t.faint}">/hr·{d["count"]}</span>')
198
+ self.cats_lbl.setText(
199
+ ' &nbsp;&nbsp; '.join(chips) if chips
200
+ else '<span style="color:%s">no billable resources yet</span>' % t.faint)
201
+
202
+ def _restyle(self) -> None:
203
+ t = self.theme.theme
204
+ from .theme.manager import sp
205
+ self.setStyleSheet(f"""
206
+ QLabel#DashValue {{ color: {t.text}; font-size: {sp(17)}px; font-weight: 700; }}
207
+ QLabel#DashTotal {{ color: {t.accent}; font-size: {sp(18)}px; font-weight: 800; }}
208
+ QLabel#DashCaption {{ color: {t.faint}; font-size: {sp(9)}px;
209
+ letter-spacing: 0.4px; text-transform: uppercase; }}
210
+ QLabel#DashChips {{ font-size: {sp(12)}px; }}
211
+ QLabel#DashState {{ color: {t.muted}; font-size: {sp(10)}px; }}
212
+ QFrame#DashSep {{ color: {t.line2}; }}
213
+ QPushButton#DashGrafana {{
214
+ background: {t.accent_soft}; color: {t.accent2};
215
+ border: 1px solid {t.accent2}; border-radius: 6px;
216
+ padding: 4px 12px; font-weight: 600; }}
217
+ QPushButton#DashGrafana:disabled {{
218
+ background: transparent; color: {t.faint};
219
+ border: 1px solid {t.line2}; }}
220
+ QPushButton#DashGrafana:hover:enabled {{ background: {t.accent2}; color: white; }}
221
+ """)
222
+ self._render()