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