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