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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- 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")
|