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/router_lab.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""Router Lab — the visual module-graph editor for one gRouter.
|
|
2
|
+
|
|
3
|
+
Open it by double-clicking a router. You compose the data plane by adding inline
|
|
4
|
+
modules onto the locked base pipeline (parse → route → rewrite), reorder/remove them,
|
|
5
|
+
toggle the SDN mode (OpenFlow = flow-table front door), and step a test packet through
|
|
6
|
+
to see the verdict at each stage. Today it drives a local trace; later it binds to the
|
|
7
|
+
real gRouter's module graph over the control protocol.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import Qt, QTimer, Signal
|
|
12
|
+
from PySide6.QtWidgets import (
|
|
13
|
+
QAbstractItemView, QDialog, QDoubleSpinBox, QFrame, QHBoxLayout, QHeaderView, QLabel,
|
|
14
|
+
QLineEdit, QPlainTextEdit, QPushButton, QScrollArea, QTableWidget, QTableWidgetItem,
|
|
15
|
+
QTabWidget, QVBoxLayout, QWidget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from ..domain.router_modules import BASE, CUSTOM, INLINE, MODULE_BY_KEY, RouterProgram
|
|
19
|
+
from .theme import ThemeManager, icons
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RouterLab(QDialog):
|
|
23
|
+
flows_ready = Signal(list) # parsed FlowEntry rows (from a worker thread)
|
|
24
|
+
tablestats_ready = Signal(object) # OpenFlow table-level stats dict
|
|
25
|
+
routes_ready = Signal(list) # parsed RouteEntry rows (from a worker thread)
|
|
26
|
+
chain_ready = Signal(str) # live `gpipe list` output (the deployed service chain)
|
|
27
|
+
delay_ready = Signal(str) # live `delay show` output (link-delay status)
|
|
28
|
+
|
|
29
|
+
def __init__(self, parent, theme: ThemeManager, device, program: RouterProgram,
|
|
30
|
+
on_console=None, command_fn=None, sdn=False, query_fn=None,
|
|
31
|
+
face=None) -> None:
|
|
32
|
+
super().__init__(parent)
|
|
33
|
+
self.theme = theme
|
|
34
|
+
self.device = device
|
|
35
|
+
self.program = program
|
|
36
|
+
self.on_console = on_console
|
|
37
|
+
self.command_fn = command_fn # set when running: sends `gpipe …` to the real router
|
|
38
|
+
self.query_fn = query_fn # set when running: runs a raw CLI cmd (openflow…/route/arp)
|
|
39
|
+
# role-specialized FACE of the one gRouter engine: 'router' (full pipeline), 'firewall'
|
|
40
|
+
# (rules-first, pipeline under Advanced), 'ovs' (SDN flow-table dashboard).
|
|
41
|
+
self.face = face or ("ovs" if sdn else "router")
|
|
42
|
+
self.sdn = (self.face == "ovs")
|
|
43
|
+
self._trace: list[str] = []
|
|
44
|
+
self._step_idx = -1
|
|
45
|
+
self._stage_widgets: list[QFrame] = []
|
|
46
|
+
self.flows_ready.connect(self._on_flows)
|
|
47
|
+
self.tablestats_ready.connect(self._on_table_stats)
|
|
48
|
+
self.routes_ready.connect(self._on_routes)
|
|
49
|
+
self.chain_ready.connect(self._on_chain)
|
|
50
|
+
self.delay_ready.connect(self._set_delay_status)
|
|
51
|
+
if self.sdn:
|
|
52
|
+
program.set_mode("openflow") # an OVS is an OpenFlow switch by definition
|
|
53
|
+
from ..domain.flowlog import FlowLog
|
|
54
|
+
self._flow_log = FlowLog() # accumulates rule install/expire events over time
|
|
55
|
+
|
|
56
|
+
t = theme.theme
|
|
57
|
+
_faces = {"ovs": ("OVS Switch (SDN)", "controller"),
|
|
58
|
+
"firewall": ("Firewall", "firewall"),
|
|
59
|
+
"router": ("Router Lab", "router")}
|
|
60
|
+
kind, icon_key = _faces.get(self.face, _faces["router"])
|
|
61
|
+
self.setWindowTitle(f"{kind} — {device.name}")
|
|
62
|
+
self.resize(880, 720 if self.face != "router" else 560)
|
|
63
|
+
self.setStyleSheet(f"QDialog{{background:{t.bg};}}")
|
|
64
|
+
|
|
65
|
+
root = QVBoxLayout(self)
|
|
66
|
+
|
|
67
|
+
# header -------------------------------------------------------------
|
|
68
|
+
head = QHBoxLayout()
|
|
69
|
+
ic = QLabel(); ic.setPixmap(icons.render_pixmap(icon_key, t.accent_for("blue"), 24))
|
|
70
|
+
title = QLabel(f" {kind} — {device.name}")
|
|
71
|
+
title.setStyleSheet("font-size:15px; font-weight:600;")
|
|
72
|
+
head.addWidget(ic); head.addWidget(title); head.addStretch(1)
|
|
73
|
+
# The datapath mode is a PROPERTY of the element, not a user toggle: a Router/Firewall is a
|
|
74
|
+
# legacy L3 forwarder; an OVS is an OpenFlow (SDN) switch. Show it as a static label rather
|
|
75
|
+
# than offering an OpenFlow toggle a plain router can't actually be in.
|
|
76
|
+
head.addWidget(QLabel("mode:"))
|
|
77
|
+
mode_text = "OpenFlow · SDN" if self.face == "ovs" else "Legacy L3"
|
|
78
|
+
mode_lbl = QLabel(mode_text)
|
|
79
|
+
mode_lbl.setStyleSheet(
|
|
80
|
+
f"font-weight:600; color:{t.accent_for('teal' if self.face == 'ovs' else 'blue')};")
|
|
81
|
+
head.addWidget(mode_lbl)
|
|
82
|
+
if on_console:
|
|
83
|
+
con = QPushButton(" Console")
|
|
84
|
+
con.setIcon(icons.icon("link", t.muted, 14))
|
|
85
|
+
con.clicked.connect(lambda: on_console())
|
|
86
|
+
head.addSpacing(12); head.addWidget(con)
|
|
87
|
+
root.addLayout(head)
|
|
88
|
+
|
|
89
|
+
# the module-pipeline editor (palette + pipeline) as ONE widget, so a face can
|
|
90
|
+
# hide/collapse it.
|
|
91
|
+
body_w = QWidget()
|
|
92
|
+
body = QHBoxLayout(body_w); body.setContentsMargins(0, 0, 0, 0)
|
|
93
|
+
body.addWidget(self._build_palette(), 0)
|
|
94
|
+
body.addWidget(self._build_pipeline(), 1)
|
|
95
|
+
|
|
96
|
+
# footer (packet step debugger) as a widget too
|
|
97
|
+
foot_w = QWidget()
|
|
98
|
+
foot = QHBoxLayout(foot_w); foot.setContentsMargins(0, 0, 0, 0)
|
|
99
|
+
inject = QPushButton(" Inject packet"); inject.setObjectName("Accent")
|
|
100
|
+
inject.setIcon(icons.icon("play", "#ffffff", 14)); inject.clicked.connect(self._inject)
|
|
101
|
+
step = QPushButton(" Step"); step.clicked.connect(self._step)
|
|
102
|
+
reset = QPushButton(" Reset"); reset.clicked.connect(self._reset)
|
|
103
|
+
self.trace_lbl = QLabel("Inject a test packet, then Step through the pipeline.")
|
|
104
|
+
self.trace_lbl.setObjectName("Muted"); self.trace_lbl.setWordWrap(True)
|
|
105
|
+
foot.addWidget(inject); foot.addWidget(step); foot.addWidget(reset)
|
|
106
|
+
foot.addSpacing(12); foot.addWidget(self.trace_lbl, 1)
|
|
107
|
+
|
|
108
|
+
# assemble per face --------------------------------------------------
|
|
109
|
+
if self.face == "ovs":
|
|
110
|
+
root.addWidget(self._build_flow_table())
|
|
111
|
+
root.addWidget(foot_w)
|
|
112
|
+
elif self.face == "firewall":
|
|
113
|
+
# firewall leads with its RULES; the full gRouter pipeline is collapsed under
|
|
114
|
+
# "Advanced" — a firewall IS a gRouter, so it's one click away, not removed.
|
|
115
|
+
root.addWidget(self._build_firewall_panel())
|
|
116
|
+
self._adv_box = QWidget()
|
|
117
|
+
adv = QVBoxLayout(self._adv_box); adv.setContentsMargins(0, 0, 0, 0)
|
|
118
|
+
adv.addWidget(body_w); adv.addWidget(self._build_sfc_row())
|
|
119
|
+
adv.addWidget(self._build_delay_panel()); adv.addWidget(foot_w)
|
|
120
|
+
self._adv_box.setVisible(False)
|
|
121
|
+
root.addWidget(self._advanced_toggle())
|
|
122
|
+
root.addWidget(self._adv_box, 1)
|
|
123
|
+
root.addWidget(self._build_route_table())
|
|
124
|
+
else: # router — the full pipeline is the point
|
|
125
|
+
root.addWidget(body_w, 1)
|
|
126
|
+
root.addWidget(self._build_sfc_row())
|
|
127
|
+
root.addWidget(self._build_delay_panel())
|
|
128
|
+
root.addWidget(self._build_route_table())
|
|
129
|
+
root.addWidget(foot_w)
|
|
130
|
+
|
|
131
|
+
self._rebuild()
|
|
132
|
+
|
|
133
|
+
# poll the live table (flows for an OVS, routes otherwise) while open
|
|
134
|
+
refresh = self._refresh_flows if self.sdn else self._refresh_routes
|
|
135
|
+
self._live_timer = QTimer(self)
|
|
136
|
+
self._live_timer.timeout.connect(refresh)
|
|
137
|
+
if query_fn is not None:
|
|
138
|
+
self._live_timer.start(2500)
|
|
139
|
+
refresh()
|
|
140
|
+
|
|
141
|
+
# palette ---------------------------------------------------------------
|
|
142
|
+
def _build_palette(self) -> QWidget:
|
|
143
|
+
t = self.theme.theme
|
|
144
|
+
w = QWidget(); w.setObjectName("Sidebar")
|
|
145
|
+
lay = QVBoxLayout(w); lay.setContentsMargins(10, 10, 10, 10); lay.setSpacing(5)
|
|
146
|
+
|
|
147
|
+
def header(text: str) -> QLabel:
|
|
148
|
+
label = QLabel(text); label.setObjectName("PanelHead"); return label
|
|
149
|
+
|
|
150
|
+
def pal_btn(mt, locked: bool) -> QPushButton:
|
|
151
|
+
# An inline VNF is either a REAL gRouter data-plane function (deploys via the chain) or
|
|
152
|
+
# an illustrative stub shown for learning. Mark previews honestly rather than pretending.
|
|
153
|
+
preview = (not locked) and not mt.real
|
|
154
|
+
suffix = " · preview" if preview else ""
|
|
155
|
+
b = QPushButton(f" {mt.label}{suffix}")
|
|
156
|
+
b.setIcon(icons.icon(mt.icon, t.accent_for(mt.accent), 18))
|
|
157
|
+
b.setStyleSheet("text-align:left;" + (f"color:{t.faint};" if preview else ""))
|
|
158
|
+
if locked:
|
|
159
|
+
b.setToolTip(f"{mt.description}\n\nAlways in the pipeline — the router's fixed base.")
|
|
160
|
+
b.setEnabled(False)
|
|
161
|
+
elif preview:
|
|
162
|
+
b.setToolTip(f"{mt.description}\n\nIllustrative — shown in the pipeline to learn the "
|
|
163
|
+
"shape of a VNF; not yet a deployable data-plane function.")
|
|
164
|
+
b.clicked.connect(lambda _=False, k=mt.key: self._add(k))
|
|
165
|
+
else:
|
|
166
|
+
b.setToolTip(f"{mt.description}\n\nReal native function — deploys into the running "
|
|
167
|
+
"gRouter via the service chain.")
|
|
168
|
+
b.clicked.connect(lambda _=False, k=mt.key: self._add(k))
|
|
169
|
+
return b
|
|
170
|
+
|
|
171
|
+
lay.addWidget(header("Base · required"))
|
|
172
|
+
for mt in BASE:
|
|
173
|
+
lay.addWidget(pal_btn(mt, locked=True))
|
|
174
|
+
# Inline VNFs = the gRouter's in-datapath service functions. Two flavours: built-in NATIVE
|
|
175
|
+
# functions, and ones YOU write (Lua script or a native module).
|
|
176
|
+
lay.addWidget(header("Inline VNFs · native (built-in)"))
|
|
177
|
+
for mt in INLINE:
|
|
178
|
+
lay.addWidget(pal_btn(mt, locked=False))
|
|
179
|
+
lay.addWidget(header("Inline VNFs · you write (Lua / native)"))
|
|
180
|
+
for mt in CUSTOM:
|
|
181
|
+
lay.addWidget(pal_btn(mt, locked=False))
|
|
182
|
+
lay.addStretch(1)
|
|
183
|
+
# keep the palette from stretching the window tall on small screens: scroll it
|
|
184
|
+
sc = QScrollArea(); sc.setWidgetResizable(True); sc.setWidget(w)
|
|
185
|
+
sc.setObjectName("Sidebar"); sc.setFrameShape(QFrame.NoFrame)
|
|
186
|
+
sc.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
|
187
|
+
sc.setFixedWidth(216)
|
|
188
|
+
return sc
|
|
189
|
+
|
|
190
|
+
def _build_pipeline(self) -> QScrollArea:
|
|
191
|
+
self.pipe_host = QWidget()
|
|
192
|
+
self.pipe_layout = QVBoxLayout(self.pipe_host)
|
|
193
|
+
self.pipe_layout.setContentsMargins(16, 12, 16, 12)
|
|
194
|
+
self.pipe_layout.setSpacing(5)
|
|
195
|
+
self.pipe_layout.setAlignment(Qt.AlignTop | Qt.AlignHCenter)
|
|
196
|
+
sc = QScrollArea(); sc.setWidgetResizable(True); sc.setWidget(self.pipe_host)
|
|
197
|
+
return sc
|
|
198
|
+
|
|
199
|
+
# pipeline render -------------------------------------------------------
|
|
200
|
+
def _rebuild(self) -> None:
|
|
201
|
+
while self.pipe_layout.count():
|
|
202
|
+
item = self.pipe_layout.takeAt(0)
|
|
203
|
+
w = item.widget()
|
|
204
|
+
if w:
|
|
205
|
+
w.setParent(None) # remove from view immediately (not just deleteLater)
|
|
206
|
+
w.deleteLater()
|
|
207
|
+
self._stage_widgets = []
|
|
208
|
+
stages = self.program.stages()
|
|
209
|
+
for i, st in enumerate(stages):
|
|
210
|
+
row = self._stage_row(st)
|
|
211
|
+
self.pipe_layout.addWidget(row, 0, Qt.AlignHCenter)
|
|
212
|
+
self._stage_widgets.append(row)
|
|
213
|
+
if i < len(stages) - 1:
|
|
214
|
+
arrow = QLabel("▼"); arrow.setObjectName("Faint")
|
|
215
|
+
self.pipe_layout.addWidget(arrow, 0, Qt.AlignHCenter)
|
|
216
|
+
|
|
217
|
+
# Friendly label for each editable parameter, keyed by (module type, param key).
|
|
218
|
+
_PARAM_LABEL = {
|
|
219
|
+
("acl", "deny"): "deny CIDR",
|
|
220
|
+
("nat", "ip"): "source IP",
|
|
221
|
+
("block", "ip"): "target IP",
|
|
222
|
+
("rate", "spec"): "pps / burst",
|
|
223
|
+
("classify", "spec"): "match cidr:dscp",
|
|
224
|
+
("tap", "path"): "pcap path",
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def _set_param(self, inst, key: str, text: str) -> None:
|
|
228
|
+
"""Live-edit a dropped VNF's parameter; the deploy path and offline trace read it back."""
|
|
229
|
+
inst.params[key] = text.strip()
|
|
230
|
+
|
|
231
|
+
def _stage_row(self, st) -> QFrame:
|
|
232
|
+
t = self.theme.theme
|
|
233
|
+
accent = t.accent_for(st.accent)
|
|
234
|
+
f = QFrame(); f.setObjectName("Card"); f.setFixedWidth(380)
|
|
235
|
+
f._accent = accent
|
|
236
|
+
f.setStyleSheet(f"QFrame#Card{{background:{t.panel2};border:1px solid {t.line};"
|
|
237
|
+
f"border-radius:10px;}}")
|
|
238
|
+
outer = QVBoxLayout(f); outer.setContentsMargins(11, 8, 8, 8); outer.setSpacing(6)
|
|
239
|
+
|
|
240
|
+
top = QHBoxLayout(); top.setSpacing(8)
|
|
241
|
+
iname = {"ingress": "chevron_right", "egress": "chevron_right",
|
|
242
|
+
"mode": "controller"}.get(st.kind)
|
|
243
|
+
if iname is None:
|
|
244
|
+
iname = MODULE_BY_KEY[st.key].icon if st.key in MODULE_BY_KEY else "dot"
|
|
245
|
+
icon = QLabel(); icon.setPixmap(icons.render_pixmap(iname, accent, 16))
|
|
246
|
+
name = QLabel(st.label); name.setStyleSheet("font-weight:500;")
|
|
247
|
+
tag = QLabel(st.kind if not st.locked else f"{st.kind} · locked")
|
|
248
|
+
tag.setObjectName("Faint")
|
|
249
|
+
top.addWidget(icon); top.addWidget(name); top.addStretch(1); top.addWidget(tag)
|
|
250
|
+
if st.kind == "inline" and st.index is not None:
|
|
251
|
+
for sym, fn in (("▲", lambda i=st.index: self._move(i, -1)),
|
|
252
|
+
("▼", lambda i=st.index: self._move(i, 1)),
|
|
253
|
+
("✕", lambda i=st.index: self._remove(i))):
|
|
254
|
+
btn = QPushButton(sym); btn.setFixedWidth(26)
|
|
255
|
+
btn.clicked.connect(lambda _=False, fn=fn: fn())
|
|
256
|
+
top.addWidget(btn)
|
|
257
|
+
outer.addLayout(top)
|
|
258
|
+
|
|
259
|
+
# Editable parameters for an inline VNF: one field per param, written straight into
|
|
260
|
+
# inst.params (deploy_commands() and the offline trace read them back). Editing here
|
|
261
|
+
# is why dropping an ACL and then typing a CIDR actually re-points the filter.
|
|
262
|
+
if st.kind == "inline" and st.index is not None:
|
|
263
|
+
inst = self.program.inline[st.index]
|
|
264
|
+
if inst.params:
|
|
265
|
+
pr = QHBoxLayout(); pr.setSpacing(6); pr.setContentsMargins(24, 0, 0, 0)
|
|
266
|
+
for key in inst.params:
|
|
267
|
+
lbl = QLabel(self._PARAM_LABEL.get((inst.type_key, key), key) + ":")
|
|
268
|
+
lbl.setObjectName("Faint")
|
|
269
|
+
edit = QLineEdit(str(inst.params[key])); edit.setMinimumWidth(150)
|
|
270
|
+
edit.textChanged.connect(
|
|
271
|
+
lambda text, i=inst, k=key: self._set_param(i, k, text))
|
|
272
|
+
pr.addWidget(lbl); pr.addWidget(edit)
|
|
273
|
+
pr.addStretch(1)
|
|
274
|
+
outer.addLayout(pr)
|
|
275
|
+
return f
|
|
276
|
+
|
|
277
|
+
def _highlight(self, idx: int) -> None:
|
|
278
|
+
t = self.theme.theme
|
|
279
|
+
for i, w in enumerate(self._stage_widgets):
|
|
280
|
+
if i == idx:
|
|
281
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};"
|
|
282
|
+
f"border:2px solid {w._accent};border-radius:10px;}}")
|
|
283
|
+
else:
|
|
284
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};"
|
|
285
|
+
f"border:1px solid {t.line};border-radius:10px;}}")
|
|
286
|
+
|
|
287
|
+
# actions ---------------------------------------------------------------
|
|
288
|
+
def _add(self, key: str) -> None:
|
|
289
|
+
self.program.add(key); self._reset(); self._rebuild()
|
|
290
|
+
|
|
291
|
+
def _remove(self, i: int) -> None:
|
|
292
|
+
self.program.remove(i); self._reset(); self._rebuild()
|
|
293
|
+
|
|
294
|
+
def _move(self, i: int, d: int) -> None:
|
|
295
|
+
self.program.move(i, d); self._reset(); self._rebuild()
|
|
296
|
+
|
|
297
|
+
def _inject(self, dst: str = "10.0.2.10") -> None:
|
|
298
|
+
# Walk a test packet through the composed pipeline using the offline model: the
|
|
299
|
+
# current stage lights up and reports its verdict, Step advances one stage, Reset
|
|
300
|
+
# clears. This is the dependable teaching path and works whether or not a topology
|
|
301
|
+
# is running; the live router is programmed separately via Deploy chain.
|
|
302
|
+
self._trace = self.program.trace(dst)
|
|
303
|
+
self._step_idx = 0
|
|
304
|
+
self._show_step()
|
|
305
|
+
|
|
306
|
+
# SDN flow table (OVS) --------------------------------------------------
|
|
307
|
+
def _table(self, cols, stretch_col, min_h=150) -> QTableWidget:
|
|
308
|
+
tbl = QTableWidget(0, len(cols))
|
|
309
|
+
tbl.setHorizontalHeaderLabels(cols)
|
|
310
|
+
tbl.verticalHeader().setVisible(False)
|
|
311
|
+
tbl.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
312
|
+
tbl.setSelectionMode(QAbstractItemView.NoSelection)
|
|
313
|
+
tbl.setMinimumHeight(min_h)
|
|
314
|
+
hh = tbl.horizontalHeader()
|
|
315
|
+
for i in range(len(cols)):
|
|
316
|
+
hh.setSectionResizeMode(i, QHeaderView.Stretch if i == stretch_col
|
|
317
|
+
else QHeaderView.ResizeToContents)
|
|
318
|
+
return tbl
|
|
319
|
+
|
|
320
|
+
def _build_flow_table(self) -> QWidget:
|
|
321
|
+
t = self.theme.theme
|
|
322
|
+
w = QFrame(); w.setObjectName("Card")
|
|
323
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};border:1px solid {t.line};"
|
|
324
|
+
f"border-radius:10px;}}")
|
|
325
|
+
lay = QVBoxLayout(w); lay.setContentsMargins(12, 10, 12, 12); lay.setSpacing(6)
|
|
326
|
+
head = QHBoxLayout()
|
|
327
|
+
title = QLabel("SDN · OpenFlow rules installed by the controller")
|
|
328
|
+
title.setStyleSheet("font-size:13px; font-weight:600;")
|
|
329
|
+
self.flow_status = QLabel("…"); self.flow_status.setObjectName("Muted")
|
|
330
|
+
refresh = QPushButton(" Refresh"); refresh.setIcon(icons.icon("play", t.muted, 13))
|
|
331
|
+
refresh.clicked.connect(self._refresh_flows)
|
|
332
|
+
head.addWidget(title); head.addStretch(1)
|
|
333
|
+
head.addWidget(self.flow_status); head.addSpacing(10); head.addWidget(refresh)
|
|
334
|
+
lay.addLayout(head)
|
|
335
|
+
|
|
336
|
+
# table-level counters — proof the controller is programming the datapath
|
|
337
|
+
self.flow_stats = QLabel(""); self.flow_stats.setObjectName("Faint")
|
|
338
|
+
self.flow_stats.setToolTip("'looked up' = packets the switch checked against the "
|
|
339
|
+
"table; 'matched' = packets a rule handled on the datapath "
|
|
340
|
+
"without asking the controller.")
|
|
341
|
+
lay.addWidget(self.flow_stats)
|
|
342
|
+
|
|
343
|
+
# Tab 1: the live snapshot of currently-installed rules.
|
|
344
|
+
self.flow_table = self._table(["#", "Match", "Actions", "Pkts", "Bytes", "Prio", "Age"],
|
|
345
|
+
stretch_col=1)
|
|
346
|
+
# Tab 2: the event log — every rule as it is installed / expires over time. This is
|
|
347
|
+
# the FULL picture (the live table only ever shows what's active this instant).
|
|
348
|
+
self.flow_events = self._table(["Time", "Event", "Match", "Action", "Pkts"],
|
|
349
|
+
stretch_col=2)
|
|
350
|
+
tabs = QTabWidget()
|
|
351
|
+
tabs.addTab(self.flow_table, "Installed rules (live)")
|
|
352
|
+
tabs.addTab(self.flow_events, "Rule events (log)")
|
|
353
|
+
lay.addWidget(tabs)
|
|
354
|
+
return w
|
|
355
|
+
|
|
356
|
+
def _refresh_flows(self) -> None:
|
|
357
|
+
if self.query_fn is None:
|
|
358
|
+
self._set_flow_status("not running — press Run, then reopen to see live flows")
|
|
359
|
+
return
|
|
360
|
+
import threading
|
|
361
|
+
self._set_flow_status("reading flow table…")
|
|
362
|
+
qf = self.query_fn
|
|
363
|
+
|
|
364
|
+
def work():
|
|
365
|
+
from ..domain.flowtable import flows, parse_table_stats
|
|
366
|
+
rows, stats = [], {}
|
|
367
|
+
try:
|
|
368
|
+
entry = qf("openflow entry all")
|
|
369
|
+
rows = flows(entry, qf("openflow stats entry all"))
|
|
370
|
+
stats = parse_table_stats(qf("openflow stats table"))
|
|
371
|
+
except Exception:
|
|
372
|
+
pass
|
|
373
|
+
self.flows_ready.emit(rows)
|
|
374
|
+
self.tablestats_ready.emit(stats)
|
|
375
|
+
threading.Thread(target=work, daemon=True).start()
|
|
376
|
+
|
|
377
|
+
def _on_flows(self, rows: list) -> None:
|
|
378
|
+
self.flow_table.setRowCount(len(rows))
|
|
379
|
+
for r, f in enumerate(rows):
|
|
380
|
+
age = "" if f.duration is None else f"{f.duration}s"
|
|
381
|
+
prio = "" if f.priority is None else str(f.priority)
|
|
382
|
+
cells = [str(f.index), f.match_summary(), f.action_summary(),
|
|
383
|
+
"" if f.packets is None else str(f.packets),
|
|
384
|
+
"" if f.bytes is None else str(f.bytes), prio, age]
|
|
385
|
+
for c, val in enumerate(cells):
|
|
386
|
+
self.flow_table.setItem(r, c, QTableWidgetItem(val))
|
|
387
|
+
n = len(rows)
|
|
388
|
+
self._set_flow_status(f"{n} installed rule{'s' if n != 1 else ''}" if n
|
|
389
|
+
else "no installed rules yet")
|
|
390
|
+
# fold this snapshot into the running event log and repaint the log tab
|
|
391
|
+
self._flow_log.update(rows)
|
|
392
|
+
self._render_flow_events()
|
|
393
|
+
|
|
394
|
+
def _render_flow_events(self) -> None:
|
|
395
|
+
events = self._flow_log.recent()
|
|
396
|
+
self.flow_events.setRowCount(len(events))
|
|
397
|
+
for r, e in enumerate(events):
|
|
398
|
+
mark = "+ installed" if e.kind == "installed" else "- expired"
|
|
399
|
+
cells = [e.when, mark, e.match, e.action,
|
|
400
|
+
"" if e.packets is None else str(e.packets)]
|
|
401
|
+
for c, val in enumerate(cells):
|
|
402
|
+
self.flow_events.setItem(r, c, QTableWidgetItem(val))
|
|
403
|
+
|
|
404
|
+
def _on_table_stats(self, stats) -> None:
|
|
405
|
+
if not hasattr(self, "flow_stats"):
|
|
406
|
+
return
|
|
407
|
+
parts = []
|
|
408
|
+
if stats:
|
|
409
|
+
if "active" in stats:
|
|
410
|
+
parts.append(f"{stats['active']} active")
|
|
411
|
+
if "lookups" in stats:
|
|
412
|
+
parts.append(f"{stats['lookups']} looked up")
|
|
413
|
+
if "matched" in stats:
|
|
414
|
+
parts.append(f"{stats['matched']} matched by a rule")
|
|
415
|
+
self.flow_stats.setText(("Datapath: " + " · ".join(parts)) if parts else "")
|
|
416
|
+
|
|
417
|
+
def _set_flow_status(self, text: str) -> None:
|
|
418
|
+
if hasattr(self, "flow_status"):
|
|
419
|
+
self.flow_status.setText(text)
|
|
420
|
+
|
|
421
|
+
# Routing table (regular router) ----------------------------------------
|
|
422
|
+
def _build_route_table(self) -> QWidget:
|
|
423
|
+
t = self.theme.theme
|
|
424
|
+
w = QFrame(); w.setObjectName("Card")
|
|
425
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};border:1px solid {t.line};"
|
|
426
|
+
f"border-radius:10px;}}")
|
|
427
|
+
lay = QVBoxLayout(w); lay.setContentsMargins(12, 8, 12, 10); lay.setSpacing(6)
|
|
428
|
+
|
|
429
|
+
cols = ["Network", "Netmask", "Next hop", "Interface"]
|
|
430
|
+
self.route_table = QTableWidget(0, len(cols))
|
|
431
|
+
self.route_table.setHorizontalHeaderLabels(cols)
|
|
432
|
+
self.route_table.verticalHeader().setVisible(False)
|
|
433
|
+
self.route_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
|
434
|
+
self.route_table.setSelectionMode(QAbstractItemView.NoSelection)
|
|
435
|
+
self.route_table.setMinimumHeight(130)
|
|
436
|
+
hh = self.route_table.horizontalHeader()
|
|
437
|
+
for i in range(len(cols)):
|
|
438
|
+
hh.setSectionResizeMode(i, QHeaderView.Stretch if i in (0, 2)
|
|
439
|
+
else QHeaderView.ResizeToContents)
|
|
440
|
+
|
|
441
|
+
head = QHBoxLayout()
|
|
442
|
+
title = self._chevron("Routing table", self.route_table, expanded=True)
|
|
443
|
+
self.route_status = QLabel("…"); self.route_status.setObjectName("Muted")
|
|
444
|
+
refresh = QPushButton(" Refresh"); refresh.setIcon(icons.icon("play", t.muted, 13))
|
|
445
|
+
refresh.clicked.connect(self._refresh_routes)
|
|
446
|
+
head.addWidget(title); head.addStretch(1)
|
|
447
|
+
head.addWidget(self.route_status); head.addSpacing(10); head.addWidget(refresh)
|
|
448
|
+
lay.addLayout(head)
|
|
449
|
+
lay.addWidget(self.route_table)
|
|
450
|
+
return w
|
|
451
|
+
|
|
452
|
+
def _refresh_routes(self) -> None:
|
|
453
|
+
if self.query_fn is None:
|
|
454
|
+
self._set_route_status("not running — press Run to see the live route table")
|
|
455
|
+
return
|
|
456
|
+
import threading
|
|
457
|
+
self._set_route_status("reading route table…")
|
|
458
|
+
qf = self.query_fn
|
|
459
|
+
|
|
460
|
+
def work():
|
|
461
|
+
from ..domain.routetable import parse_routes
|
|
462
|
+
rows, chain = [], ""
|
|
463
|
+
try:
|
|
464
|
+
rows = parse_routes(qf("route show"))
|
|
465
|
+
chain = qf("gpipe list") # the live deployed service chain
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
468
|
+
self.routes_ready.emit(rows)
|
|
469
|
+
self.chain_ready.emit(chain)
|
|
470
|
+
threading.Thread(target=work, daemon=True).start()
|
|
471
|
+
|
|
472
|
+
def _on_routes(self, rows: list) -> None:
|
|
473
|
+
self.route_table.setRowCount(len(rows))
|
|
474
|
+
for r, e in enumerate(rows):
|
|
475
|
+
cells = [e.network, e.netmask, e.nexthop_str(), e.iface]
|
|
476
|
+
for c, val in enumerate(cells):
|
|
477
|
+
self.route_table.setItem(r, c, QTableWidgetItem(val))
|
|
478
|
+
n = len(rows)
|
|
479
|
+
self._set_route_status(f"{n} route{'s' if n != 1 else ''}" if n else "no routes")
|
|
480
|
+
|
|
481
|
+
def _set_route_status(self, text: str) -> None:
|
|
482
|
+
if hasattr(self, "route_status"):
|
|
483
|
+
self.route_status.setText(text)
|
|
484
|
+
|
|
485
|
+
# Firewall face ---------------------------------------------------------
|
|
486
|
+
def _build_firewall_panel(self) -> QWidget:
|
|
487
|
+
t = self.theme.theme
|
|
488
|
+
w = QFrame(); w.setObjectName("Card")
|
|
489
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};border:1px solid {t.line};"
|
|
490
|
+
f"border-radius:10px;}}")
|
|
491
|
+
lay = QVBoxLayout(w); lay.setContentsMargins(12, 10, 12, 12); lay.setSpacing(6)
|
|
492
|
+
head = QHBoxLayout()
|
|
493
|
+
title = QLabel("Firewall rules")
|
|
494
|
+
title.setStyleSheet("font-size:13px; font-weight:600;")
|
|
495
|
+
self.fw_status = QLabel(""); self.fw_status.setObjectName("Muted")
|
|
496
|
+
deploy = QPushButton(" Deploy rules"); deploy.setObjectName("Accent")
|
|
497
|
+
deploy.setIcon(icons.icon("send", "#ffffff", 13))
|
|
498
|
+
deploy.clicked.connect(self._deploy_firewall)
|
|
499
|
+
head.addWidget(title); head.addStretch(1)
|
|
500
|
+
head.addWidget(self.fw_status); head.addSpacing(10); head.addWidget(deploy)
|
|
501
|
+
lay.addLayout(head)
|
|
502
|
+
hint = QLabel("One rule per line — e.g. deny 10.0.3.0/24 (drops traffic to that "
|
|
503
|
+
"network). These become the router's ACL.")
|
|
504
|
+
hint.setObjectName("Faint"); hint.setWordWrap(True)
|
|
505
|
+
lay.addWidget(hint)
|
|
506
|
+
self.fw_rules = QPlainTextEdit()
|
|
507
|
+
self.fw_rules.setPlaceholderText("deny 10.0.3.0/24")
|
|
508
|
+
self.fw_rules.setPlainText((getattr(self.device, "properties", {}) or {}).get("Rules", ""))
|
|
509
|
+
self.fw_rules.setMaximumHeight(110)
|
|
510
|
+
lay.addWidget(self.fw_rules)
|
|
511
|
+
self.fw_deployed = QLabel("Deployed: (press Run, then Deploy rules)")
|
|
512
|
+
self.fw_deployed.setObjectName("Faint")
|
|
513
|
+
lay.addWidget(self.fw_deployed)
|
|
514
|
+
return w
|
|
515
|
+
|
|
516
|
+
def _advanced_toggle(self) -> QWidget:
|
|
517
|
+
btn = QPushButton(" ▸ Advanced — full gRouter pipeline")
|
|
518
|
+
btn.setCheckable(True); btn.setObjectName("Faint")
|
|
519
|
+
btn.setStyleSheet("text-align:left;")
|
|
520
|
+
|
|
521
|
+
def toggle(on):
|
|
522
|
+
self._adv_box.setVisible(on)
|
|
523
|
+
btn.setText((" ▾ Advanced — full gRouter pipeline") if on
|
|
524
|
+
else " ▸ Advanced — full gRouter pipeline")
|
|
525
|
+
btn.toggled.connect(toggle)
|
|
526
|
+
return btn
|
|
527
|
+
|
|
528
|
+
def _deploy_firewall(self) -> None:
|
|
529
|
+
from ..domain.firewall import deploy_commands
|
|
530
|
+
props = getattr(self.device, "properties", None)
|
|
531
|
+
if props is not None: # persist the typed rules onto the element
|
|
532
|
+
props["Rules"] = self.fw_rules.toPlainText()
|
|
533
|
+
if self.command_fn is None:
|
|
534
|
+
self._set_fw_status("not running — press Run to deploy the rules")
|
|
535
|
+
return
|
|
536
|
+
import threading
|
|
537
|
+
self._set_fw_status("deploying…")
|
|
538
|
+
cf, qf = self.command_fn, self.query_fn
|
|
539
|
+
cmds = deploy_commands(self.fw_rules.toPlainText())
|
|
540
|
+
|
|
541
|
+
def work():
|
|
542
|
+
listing = ""
|
|
543
|
+
try:
|
|
544
|
+
for c in cmds:
|
|
545
|
+
cf(c) # cf sends `gpipe <c>`
|
|
546
|
+
listing = qf("gpipe list") if qf is not None else cf("list")
|
|
547
|
+
except Exception as e:
|
|
548
|
+
listing = f"(deploy failed: {e})"
|
|
549
|
+
self.chain_ready.emit(listing)
|
|
550
|
+
threading.Thread(target=work, daemon=True).start()
|
|
551
|
+
|
|
552
|
+
def _set_fw_status(self, text: str) -> None:
|
|
553
|
+
if hasattr(self, "fw_status"):
|
|
554
|
+
self.fw_status.setText(text)
|
|
555
|
+
|
|
556
|
+
def _chevron(self, title: str, target: QWidget, expanded: bool) -> QPushButton:
|
|
557
|
+
"""A flat header button that collapses/expands `target` (to save vertical space)."""
|
|
558
|
+
btn = QPushButton(); btn.setCheckable(True); btn.setChecked(expanded); btn.setFlat(True)
|
|
559
|
+
btn.setStyleSheet("text-align:left; font-size:13px; font-weight:600; border:none; padding:0;")
|
|
560
|
+
|
|
561
|
+
def sync(on):
|
|
562
|
+
target.setVisible(on)
|
|
563
|
+
btn.setText((" ▾ " if on else " ▸ ") + title)
|
|
564
|
+
btn.toggled.connect(sync); sync(expanded)
|
|
565
|
+
return btn
|
|
566
|
+
|
|
567
|
+
# Link delay (ingress / egress holding queues) --------------------------
|
|
568
|
+
def _delay_spin(self, val: float, hi: float, step: float, dec: int, suffix: str) -> QDoubleSpinBox:
|
|
569
|
+
s = QDoubleSpinBox()
|
|
570
|
+
s.setRange(0.0, hi); s.setSingleStep(step); s.setDecimals(dec)
|
|
571
|
+
s.setValue(val); s.setSuffix(suffix); s.setMaximumWidth(96)
|
|
572
|
+
return s
|
|
573
|
+
|
|
574
|
+
def _delay_row(self, lay, name: str, saved: str):
|
|
575
|
+
"""One side's controls (base / jitter / correlation), seeded from a saved string."""
|
|
576
|
+
base = jit = corr = 0.0
|
|
577
|
+
try:
|
|
578
|
+
parts = (saved or "").split()
|
|
579
|
+
if parts: base = float(parts[0])
|
|
580
|
+
if len(parts) > 1: jit = float(parts[1])
|
|
581
|
+
if len(parts) > 2: corr = float(parts[2])
|
|
582
|
+
except ValueError:
|
|
583
|
+
pass
|
|
584
|
+
row = QHBoxLayout()
|
|
585
|
+
tag = QLabel(name); tag.setMinimumWidth(64); tag.setStyleSheet("font-weight:600;")
|
|
586
|
+
row.addWidget(tag)
|
|
587
|
+
row.addWidget(QLabel("base")); b = self._delay_spin(base, 5000, 5, 0, " ms")
|
|
588
|
+
row.addWidget(b)
|
|
589
|
+
row.addWidget(QLabel("jitter")); j = self._delay_spin(jit, 1000, 1, 0, " ms")
|
|
590
|
+
row.addWidget(j)
|
|
591
|
+
row.addWidget(QLabel("corr")); c = self._delay_spin(corr, 0.99, 0.05, 2, "")
|
|
592
|
+
row.addWidget(c)
|
|
593
|
+
row.addStretch(1)
|
|
594
|
+
lay.addLayout(row)
|
|
595
|
+
return b, j, c
|
|
596
|
+
|
|
597
|
+
def _build_delay_panel(self) -> QWidget:
|
|
598
|
+
t = self.theme.theme
|
|
599
|
+
w = QFrame(); w.setObjectName("Card")
|
|
600
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};border:1px solid {t.line};"
|
|
601
|
+
f"border-radius:10px;}}")
|
|
602
|
+
lay = QVBoxLayout(w); lay.setContentsMargins(12, 8, 12, 10); lay.setSpacing(6)
|
|
603
|
+
|
|
604
|
+
# collapsible body (hint + the two parameter rows) — collapsed by default to save height
|
|
605
|
+
body = QWidget()
|
|
606
|
+
bl = QVBoxLayout(body); bl.setContentsMargins(0, 6, 0, 0); bl.setSpacing(6)
|
|
607
|
+
hint = QLabel("Hold every packet on the way in (ingress) or out (egress) to model link "
|
|
608
|
+
"latency. Jitter wanders with the correlation, order preserved. base 0 = off.")
|
|
609
|
+
hint.setObjectName("Faint"); hint.setWordWrap(True)
|
|
610
|
+
bl.addWidget(hint)
|
|
611
|
+
props = getattr(self.device, "properties", {}) or {}
|
|
612
|
+
self.di_base, self.di_jit, self.di_corr = self._delay_row(bl, "ingress", props.get("DelayIngress", ""))
|
|
613
|
+
self.de_base, self.de_jit, self.de_corr = self._delay_row(bl, "egress", props.get("DelayEgress", ""))
|
|
614
|
+
|
|
615
|
+
head = QHBoxLayout()
|
|
616
|
+
title = self._chevron("Link delay", body, expanded=False)
|
|
617
|
+
self.delay_status = QLabel(""); self.delay_status.setObjectName("Muted")
|
|
618
|
+
apply = QPushButton(" Apply"); apply.setObjectName("Accent")
|
|
619
|
+
apply.setIcon(icons.icon("send", "#ffffff", 13)); apply.clicked.connect(self._apply_delay)
|
|
620
|
+
clear = QPushButton(" Clear"); clear.clicked.connect(self._clear_delay)
|
|
621
|
+
head.addWidget(title); head.addStretch(1)
|
|
622
|
+
head.addWidget(self.delay_status); head.addSpacing(10)
|
|
623
|
+
head.addWidget(clear); head.addWidget(apply)
|
|
624
|
+
lay.addLayout(head)
|
|
625
|
+
lay.addWidget(body)
|
|
626
|
+
return w
|
|
627
|
+
|
|
628
|
+
@staticmethod
|
|
629
|
+
def _delay_cmd(side: str, base: float, jit: float, corr: float) -> str:
|
|
630
|
+
if base <= 0 and jit <= 0:
|
|
631
|
+
return f"delay {side} off"
|
|
632
|
+
return f"delay {side} {base:.0f} {jit:.0f} {corr:.2f}"
|
|
633
|
+
|
|
634
|
+
def _apply_delay(self) -> None:
|
|
635
|
+
ing = f"{self.di_base.value():.0f} {self.di_jit.value():.0f} {self.di_corr.value():.2f}"
|
|
636
|
+
egr = f"{self.de_base.value():.0f} {self.de_jit.value():.0f} {self.de_corr.value():.2f}"
|
|
637
|
+
props = getattr(self.device, "properties", None)
|
|
638
|
+
if props is not None: # persist onto the element
|
|
639
|
+
props["DelayIngress"] = ing if (self.di_base.value() or self.di_jit.value()) else ""
|
|
640
|
+
props["DelayEgress"] = egr if (self.de_base.value() or self.de_jit.value()) else ""
|
|
641
|
+
qf = self.query_fn
|
|
642
|
+
if qf is None:
|
|
643
|
+
self._set_delay_status("not running — press Run, then Apply")
|
|
644
|
+
return
|
|
645
|
+
import threading
|
|
646
|
+
self._set_delay_status("applying…")
|
|
647
|
+
cmds = [self._delay_cmd("ingress", self.di_base.value(), self.di_jit.value(), self.di_corr.value()),
|
|
648
|
+
self._delay_cmd("egress", self.de_base.value(), self.de_jit.value(), self.de_corr.value())]
|
|
649
|
+
|
|
650
|
+
def work():
|
|
651
|
+
out = ""
|
|
652
|
+
try:
|
|
653
|
+
for c in cmds:
|
|
654
|
+
qf(c)
|
|
655
|
+
out = qf("delay show")
|
|
656
|
+
except Exception as e:
|
|
657
|
+
out = f"(apply failed: {e})"
|
|
658
|
+
self.delay_ready.emit(out.strip() or "applied")
|
|
659
|
+
threading.Thread(target=work, daemon=True).start()
|
|
660
|
+
|
|
661
|
+
def _clear_delay(self) -> None:
|
|
662
|
+
for s in (self.di_base, self.di_jit, self.di_corr,
|
|
663
|
+
self.de_base, self.de_jit, self.de_corr):
|
|
664
|
+
s.setValue(0.0)
|
|
665
|
+
props = getattr(self.device, "properties", None)
|
|
666
|
+
if props is not None:
|
|
667
|
+
props["DelayIngress"] = ""; props["DelayEgress"] = ""
|
|
668
|
+
qf = self.query_fn
|
|
669
|
+
if qf is None:
|
|
670
|
+
self._set_delay_status("cleared (not running)")
|
|
671
|
+
return
|
|
672
|
+
import threading
|
|
673
|
+
self._set_delay_status("clearing…")
|
|
674
|
+
|
|
675
|
+
def work():
|
|
676
|
+
try:
|
|
677
|
+
qf("delay clear")
|
|
678
|
+
except Exception as e:
|
|
679
|
+
self.delay_ready.emit(f"(clear failed: {e})"); return
|
|
680
|
+
self.delay_ready.emit("cleared")
|
|
681
|
+
threading.Thread(target=work, daemon=True).start()
|
|
682
|
+
|
|
683
|
+
def _set_delay_status(self, text: str) -> None:
|
|
684
|
+
if hasattr(self, "delay_status"):
|
|
685
|
+
self.delay_status.setText(text.splitlines()[0] if text else "")
|
|
686
|
+
|
|
687
|
+
# Service Function Chain controls (router / legacy pipeline) -------------
|
|
688
|
+
def _build_sfc_row(self) -> QWidget:
|
|
689
|
+
t = self.theme.theme
|
|
690
|
+
w = QFrame(); w.setObjectName("Card")
|
|
691
|
+
w.setStyleSheet(f"QFrame#Card{{background:{t.panel2};border:1px solid {t.line};"
|
|
692
|
+
f"border-radius:10px;}}")
|
|
693
|
+
lay = QVBoxLayout(w); lay.setContentsMargins(12, 8, 12, 8); lay.setSpacing(5)
|
|
694
|
+
top = QHBoxLayout()
|
|
695
|
+
title = QLabel("Inline VNF chain")
|
|
696
|
+
title.setStyleSheet("font-size:13px; font-weight:600;")
|
|
697
|
+
top.addWidget(title)
|
|
698
|
+
top.addSpacing(12)
|
|
699
|
+
top.addWidget(QLabel("classifier:"))
|
|
700
|
+
self.classifier_edit = QLineEdit(self.program.classifier)
|
|
701
|
+
self.classifier_edit.setPlaceholderText("which traffic enters the chain (blank = all)")
|
|
702
|
+
self.classifier_edit.setMaximumWidth(240)
|
|
703
|
+
self.classifier_edit.textChanged.connect(self.program.set_classifier)
|
|
704
|
+
top.addWidget(self.classifier_edit)
|
|
705
|
+
top.addStretch(1)
|
|
706
|
+
self.deploy_btn = QPushButton(" Deploy chain")
|
|
707
|
+
self.deploy_btn.setObjectName("Accent")
|
|
708
|
+
self.deploy_btn.setIcon(icons.icon("send", "#ffffff", 13))
|
|
709
|
+
self.deploy_btn.clicked.connect(self._deploy_chain)
|
|
710
|
+
top.addWidget(self.deploy_btn)
|
|
711
|
+
lay.addLayout(top)
|
|
712
|
+
self.deployed_lbl = QLabel("Deployed: (press Run, then Deploy chain)")
|
|
713
|
+
self.deployed_lbl.setObjectName("Faint")
|
|
714
|
+
self.deploy_status = QLabel(""); self.deploy_status.setObjectName("Muted")
|
|
715
|
+
row2 = QHBoxLayout()
|
|
716
|
+
row2.addWidget(self.deployed_lbl, 1); row2.addWidget(self.deploy_status)
|
|
717
|
+
lay.addLayout(row2)
|
|
718
|
+
return w
|
|
719
|
+
|
|
720
|
+
def _deploy_chain(self) -> None:
|
|
721
|
+
"""Program the edited chain into the running gRouter over `gpipe` (clear + add each
|
|
722
|
+
real service function in order), then read back the live chain."""
|
|
723
|
+
if self.command_fn is None:
|
|
724
|
+
self._set_deploy_status("not running — press Run to deploy the chain")
|
|
725
|
+
return
|
|
726
|
+
import threading
|
|
727
|
+
self._set_deploy_status("deploying…")
|
|
728
|
+
prog = self.program
|
|
729
|
+
cf, qf = self.command_fn, self.query_fn
|
|
730
|
+
|
|
731
|
+
def work():
|
|
732
|
+
listing = ""
|
|
733
|
+
try:
|
|
734
|
+
for c in prog.deploy_commands():
|
|
735
|
+
cf(c) # cf sends `gpipe <c>`
|
|
736
|
+
listing = qf("gpipe list") if qf is not None else cf("list")
|
|
737
|
+
except Exception as e:
|
|
738
|
+
listing = f"(deploy failed: {e})"
|
|
739
|
+
self.chain_ready.emit(listing)
|
|
740
|
+
threading.Thread(target=work, daemon=True).start()
|
|
741
|
+
|
|
742
|
+
def _on_chain(self, text: str) -> None:
|
|
743
|
+
from ..domain.modulechain import chain_summary
|
|
744
|
+
summary = "Deployed: " + chain_summary(text)
|
|
745
|
+
if hasattr(self, "fw_deployed"): # firewall face
|
|
746
|
+
self.fw_deployed.setText(summary)
|
|
747
|
+
if not hasattr(self, "deployed_lbl"):
|
|
748
|
+
return
|
|
749
|
+
self.deployed_lbl.setText(summary)
|
|
750
|
+
ill = self.program.illustrative()
|
|
751
|
+
if ill:
|
|
752
|
+
self._set_deploy_status(f"{len(ill)} illustrative function(s) shown but not deployed")
|
|
753
|
+
else:
|
|
754
|
+
self._set_deploy_status("in sync" if text else "")
|
|
755
|
+
|
|
756
|
+
def _set_deploy_status(self, text: str) -> None:
|
|
757
|
+
if hasattr(self, "deploy_status"):
|
|
758
|
+
self.deploy_status.setText(text)
|
|
759
|
+
|
|
760
|
+
def _step(self) -> None:
|
|
761
|
+
if not self._trace:
|
|
762
|
+
self._inject(); return
|
|
763
|
+
cur = self._trace[self._step_idx]
|
|
764
|
+
if self._step_idx < len(self._trace) - 1 and "DROP" not in cur:
|
|
765
|
+
self._step_idx += 1
|
|
766
|
+
self._show_step()
|
|
767
|
+
|
|
768
|
+
def _reset(self) -> None:
|
|
769
|
+
self._trace = []; self._step_idx = -1
|
|
770
|
+
self._highlight(-1)
|
|
771
|
+
self.trace_lbl.setText("Inject a test packet, then Step through the pipeline.")
|
|
772
|
+
|
|
773
|
+
def _show_step(self) -> None:
|
|
774
|
+
self._highlight(self._step_idx)
|
|
775
|
+
st = self.program.stages()[self._step_idx]
|
|
776
|
+
self.trace_lbl.setText(f"{st.label}: {self._trace[self._step_idx]}")
|