Spaces:
Sleeping
Sleeping
Graham Paasch commited on
Commit ·
e085479
1
Parent(s): 8ab2fb2
Fix ray module import error and add interactive consultation UI
Browse files- Make ray import optional in agent/ray_executor.py with graceful fallback
- Add try/except guards in agent/pipeline_engine.py for ray_executor usage
- Add interactive consultation tab to app.py with chat interface
- Users can now have back-and-forth conversation before running pipeline
- Consultation summary can be extracted and used in full pipeline
- agent/pipeline_engine.py +23 -7
- agent/ray_executor.py +509 -446
- app.py +156 -0
agent/pipeline_engine.py
CHANGED
|
@@ -194,10 +194,15 @@ class OvergrowthPipeline:
|
|
| 194 |
self.rca_analyzer = RootCauseAnalyzer(self.incident_db)
|
| 195 |
self.test_generator = RegressionTestGenerator()
|
| 196 |
|
| 197 |
-
# Ray distributed execution
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
def stage0_preflight(self, model: NetworkModel) -> Dict[str, Any]:
|
| 203 |
"""
|
|
@@ -326,8 +331,8 @@ class OvergrowthPipeline:
|
|
| 326 |
|
| 327 |
Uses parallel execution when parallel_mode=True and >10 devices
|
| 328 |
"""
|
| 329 |
-
# Use parallel execution for large fleets
|
| 330 |
-
if self.parallel_mode and len(model.devices) > 10:
|
| 331 |
return self._parallel_config_generation(model)
|
| 332 |
|
| 333 |
configs = {}
|
|
@@ -1150,6 +1155,10 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1150 |
Args:
|
| 1151 |
ray_address: Ray cluster address (None for local mode)
|
| 1152 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1153 |
self.parallel_mode = True
|
| 1154 |
if ray_address:
|
| 1155 |
self.ray_executor.ray_address = ray_address
|
|
@@ -1163,7 +1172,8 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1163 |
def disable_parallel_mode(self):
|
| 1164 |
"""Disable parallel execution mode"""
|
| 1165 |
self.parallel_mode = False
|
| 1166 |
-
self.ray_executor
|
|
|
|
| 1167 |
logger.info("Parallel mode disabled")
|
| 1168 |
|
| 1169 |
def parallel_deploy_fleet(self, model: NetworkModel,
|
|
@@ -1186,6 +1196,12 @@ Be specific and practical. Use RFC1918 addressing. Consider scalability and secu
|
|
| 1186 |
logger.warning("Parallel mode not enabled - enabling automatically")
|
| 1187 |
self.enable_parallel_mode()
|
| 1188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1189 |
# Generate configs for all devices
|
| 1190 |
configs = self._generate_configs_for_batfish(model)
|
| 1191 |
|
|
|
|
| 194 |
self.rca_analyzer = RootCauseAnalyzer(self.incident_db)
|
| 195 |
self.test_generator = RegressionTestGenerator()
|
| 196 |
|
| 197 |
+
# Ray distributed execution (optional)
|
| 198 |
+
try:
|
| 199 |
+
from agent.ray_executor import RayExecutor
|
| 200 |
+
self.ray_executor = RayExecutor()
|
| 201 |
+
self.parallel_mode = False # Enable for fleet operations
|
| 202 |
+
except (ImportError, NotImplementedError) as e:
|
| 203 |
+
logger.warning(f"Ray executor not available: {e}")
|
| 204 |
+
self.ray_executor = None
|
| 205 |
+
self.parallel_mode = False
|
| 206 |
|
| 207 |
def stage0_preflight(self, model: NetworkModel) -> Dict[str, Any]:
|
| 208 |
"""
|
|
|
|
| 331 |
|
| 332 |
Uses parallel execution when parallel_mode=True and >10 devices
|
| 333 |
"""
|
| 334 |
+
# Use parallel execution for large fleets (only if ray_executor available)
|
| 335 |
+
if self.parallel_mode and self.ray_executor and len(model.devices) > 10:
|
| 336 |
return self._parallel_config_generation(model)
|
| 337 |
|
| 338 |
configs = {}
|
|
|
|
| 1155 |
Args:
|
| 1156 |
ray_address: Ray cluster address (None for local mode)
|
| 1157 |
"""
|
| 1158 |
+
if not self.ray_executor:
|
| 1159 |
+
logger.error("Ray executor not available - cannot enable parallel mode")
|
| 1160 |
+
return
|
| 1161 |
+
|
| 1162 |
self.parallel_mode = True
|
| 1163 |
if ray_address:
|
| 1164 |
self.ray_executor.ray_address = ray_address
|
|
|
|
| 1172 |
def disable_parallel_mode(self):
|
| 1173 |
"""Disable parallel execution mode"""
|
| 1174 |
self.parallel_mode = False
|
| 1175 |
+
if self.ray_executor:
|
| 1176 |
+
self.ray_executor.shutdown()
|
| 1177 |
logger.info("Parallel mode disabled")
|
| 1178 |
|
| 1179 |
def parallel_deploy_fleet(self, model: NetworkModel,
|
|
|
|
| 1196 |
logger.warning("Parallel mode not enabled - enabling automatically")
|
| 1197 |
self.enable_parallel_mode()
|
| 1198 |
|
| 1199 |
+
if not self.ray_executor:
|
| 1200 |
+
return {
|
| 1201 |
+
'status': 'error',
|
| 1202 |
+
'message': 'Ray executor not available - cannot perform parallel deployment'
|
| 1203 |
+
}
|
| 1204 |
+
|
| 1205 |
# Generate configs for all devices
|
| 1206 |
configs = self._generate_configs_for_batfish(model)
|
| 1207 |
|
agent/ray_executor.py
CHANGED
|
@@ -10,14 +10,22 @@ Enables parallel execution of:
|
|
| 10 |
Works locally (single machine) or on Ray clusters with zero code changes.
|
| 11 |
"""
|
| 12 |
|
| 13 |
-
import ray
|
| 14 |
-
from ray.util.queue import Queue as RayQueue
|
| 15 |
-
import time
|
| 16 |
import logging
|
| 17 |
from typing import List, Dict, Any, Optional, Callable, Tuple
|
| 18 |
from dataclasses import dataclass, field
|
| 19 |
from enum import Enum
|
| 20 |
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
from datetime import datetime
|
| 22 |
|
| 23 |
logger = logging.getLogger(__name__)
|
|
@@ -98,513 +106,568 @@ class ExecutionProgress:
|
|
| 98 |
}
|
| 99 |
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
self
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
| 136 |
|
| 137 |
|
| 138 |
-
@ray.remote
|
| 139 |
-
def generate_device_config(device_id: str, device_data: Dict[str, Any],
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
Args:
|
| 145 |
-
device_id: Unique device identifier
|
| 146 |
-
device_data: Device parameters (hostname, ip, role, etc.)
|
| 147 |
-
template_fn: Function to generate config from device data
|
| 148 |
-
progress_tracker: Progress tracking actor
|
| 149 |
-
|
| 150 |
-
Returns:
|
| 151 |
-
TaskResult with generated config or error
|
| 152 |
-
"""
|
| 153 |
-
start_time = time.time()
|
| 154 |
-
|
| 155 |
-
try:
|
| 156 |
-
# Update status to running
|
| 157 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
|
| 158 |
-
|
| 159 |
-
# Generate config
|
| 160 |
-
config = template_fn(device_data)
|
| 161 |
-
|
| 162 |
-
duration = time.time() - start_time
|
| 163 |
-
result = TaskResult(
|
| 164 |
-
device_id=device_id,
|
| 165 |
-
status=TaskStatus.SUCCESS,
|
| 166 |
-
result=config,
|
| 167 |
-
duration_seconds=duration
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
# Update status to success
|
| 171 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
|
| 172 |
-
ray.get(progress_tracker.add_result.remote(result))
|
| 173 |
-
|
| 174 |
-
return result
|
| 175 |
-
|
| 176 |
-
except Exception as e:
|
| 177 |
-
duration = time.time() - start_time
|
| 178 |
-
result = TaskResult(
|
| 179 |
-
device_id=device_id,
|
| 180 |
-
status=TaskStatus.FAILED,
|
| 181 |
-
error=str(e),
|
| 182 |
-
duration_seconds=duration
|
| 183 |
-
)
|
| 184 |
-
|
| 185 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
|
| 186 |
-
ray.get(progress_tracker.add_result.remote(result))
|
| 187 |
-
|
| 188 |
-
return result
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
@ray.remote
|
| 192 |
-
def analyze_device_config(device_id: str, config: str,
|
| 193 |
-
batfish_client: Any, progress_tracker: Any) -> TaskResult:
|
| 194 |
-
"""
|
| 195 |
-
Ray remote function for parallel Batfish analysis.
|
| 196 |
-
|
| 197 |
-
Args:
|
| 198 |
-
device_id: Unique device identifier
|
| 199 |
-
config: Device configuration to analyze
|
| 200 |
-
batfish_client: Batfish client instance
|
| 201 |
-
progress_tracker: Progress tracking actor
|
| 202 |
-
|
| 203 |
-
Returns:
|
| 204 |
-
TaskResult with analysis results or error
|
| 205 |
-
"""
|
| 206 |
-
start_time = time.time()
|
| 207 |
-
|
| 208 |
-
try:
|
| 209 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
|
| 210 |
-
|
| 211 |
-
# Run Batfish analysis
|
| 212 |
-
analysis = batfish_client.analyze_configs({device_id: config})
|
| 213 |
-
|
| 214 |
-
duration = time.time() - start_time
|
| 215 |
-
result = TaskResult(
|
| 216 |
-
device_id=device_id,
|
| 217 |
-
status=TaskStatus.SUCCESS,
|
| 218 |
-
result=analysis,
|
| 219 |
-
duration_seconds=duration
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
|
| 223 |
-
ray.get(progress_tracker.add_result.remote(result))
|
| 224 |
-
|
| 225 |
-
return result
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
error=str(e),
|
| 233 |
-
duration_seconds=duration
|
| 234 |
-
)
|
| 235 |
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
| 238 |
|
| 239 |
-
return result
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
@ray.remote
|
| 243 |
-
def deploy_to_device(device_id: str, config: str,
|
| 244 |
-
gns3_client: Any, progress_tracker: Any,
|
| 245 |
-
max_retries: int = 3) -> TaskResult:
|
| 246 |
-
"""
|
| 247 |
-
Ray remote function for parallel device deployment.
|
| 248 |
-
|
| 249 |
-
Args:
|
| 250 |
-
device_id: Unique device identifier
|
| 251 |
-
config: Configuration to deploy
|
| 252 |
-
gns3_client: GNS3 client instance
|
| 253 |
-
progress_tracker: Progress tracking actor
|
| 254 |
-
max_retries: Maximum retry attempts on failure
|
| 255 |
-
|
| 256 |
-
Returns:
|
| 257 |
-
TaskResult with deployment status or error
|
| 258 |
-
"""
|
| 259 |
-
start_time = time.time()
|
| 260 |
-
retry_count = 0
|
| 261 |
-
|
| 262 |
-
while retry_count <= max_retries:
|
| 263 |
try:
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
else:
|
| 267 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
|
| 268 |
|
| 269 |
-
#
|
| 270 |
-
|
| 271 |
|
| 272 |
duration = time.time() - start_time
|
| 273 |
result = TaskResult(
|
| 274 |
device_id=device_id,
|
| 275 |
status=TaskStatus.SUCCESS,
|
| 276 |
-
result=
|
| 277 |
-
duration_seconds=duration
|
| 278 |
-
retry_count=retry_count
|
| 279 |
)
|
| 280 |
|
|
|
|
| 281 |
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
|
| 282 |
ray.get(progress_tracker.add_result.remote(result))
|
| 283 |
|
| 284 |
return result
|
| 285 |
|
| 286 |
except Exception as e:
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
|
| 299 |
-
ray.get(progress_tracker.add_result.remote(result))
|
| 300 |
-
|
| 301 |
-
return result
|
| 302 |
|
| 303 |
-
|
| 304 |
-
time.sleep(2 ** retry_count)
|
| 305 |
|
| 306 |
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
Provides parallel execution of config generation, analysis, and deployment
|
| 312 |
-
across thousands of devices using Ray's distributed computing framework.
|
| 313 |
-
"""
|
| 314 |
-
|
| 315 |
-
def __init__(self, ray_address: Optional[str] = None, num_cpus: Optional[int] = None):
|
| 316 |
"""
|
| 317 |
-
|
| 318 |
|
| 319 |
Args:
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
"""
|
| 323 |
-
|
| 324 |
-
self.num_cpus = num_cpus
|
| 325 |
-
self.initialized = False
|
| 326 |
-
self._progress_tracker = None
|
| 327 |
-
|
| 328 |
-
def initialize(self):
|
| 329 |
-
"""Initialize Ray runtime"""
|
| 330 |
-
if self.initialized:
|
| 331 |
-
return
|
| 332 |
|
| 333 |
try:
|
| 334 |
-
|
| 335 |
-
if ray.is_initialized():
|
| 336 |
-
logger.info("Ray already initialized")
|
| 337 |
-
else:
|
| 338 |
-
# Initialize Ray
|
| 339 |
-
if self.ray_address:
|
| 340 |
-
# Connect to existing cluster
|
| 341 |
-
ray.init(address=self.ray_address)
|
| 342 |
-
logger.info(f"Connected to Ray cluster at {self.ray_address}")
|
| 343 |
-
else:
|
| 344 |
-
# Start local Ray instance
|
| 345 |
-
init_kwargs = {}
|
| 346 |
-
if self.num_cpus:
|
| 347 |
-
init_kwargs['num_cpus'] = self.num_cpus
|
| 348 |
-
|
| 349 |
-
ray.init(**init_kwargs)
|
| 350 |
-
logger.info(f"Started local Ray instance with {ray.available_resources().get('CPU', 0)} CPUs")
|
| 351 |
|
| 352 |
-
|
|
|
|
| 353 |
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
if self.initialized and ray.is_initialized():
|
| 361 |
-
ray.shutdown()
|
| 362 |
-
self.initialized = False
|
| 363 |
-
logger.info("Ray shutdown complete")
|
| 364 |
-
|
| 365 |
-
def parallel_config_generation(self, devices: List[Dict[str, Any]],
|
| 366 |
-
template_fn: Callable,
|
| 367 |
-
batch_size: int = 100) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 368 |
-
"""
|
| 369 |
-
Generate configs for multiple devices in parallel.
|
| 370 |
-
|
| 371 |
-
Args:
|
| 372 |
-
devices: List of device data dicts
|
| 373 |
-
template_fn: Function to generate config from device data
|
| 374 |
-
batch_size: Number of devices to process in each batch
|
| 375 |
-
|
| 376 |
-
Returns:
|
| 377 |
-
Tuple of (results, final_progress)
|
| 378 |
-
"""
|
| 379 |
-
self.initialize()
|
| 380 |
-
|
| 381 |
-
# Create progress tracker
|
| 382 |
-
progress_tracker = ProgressTracker.remote(total_devices=len(devices))
|
| 383 |
-
|
| 384 |
-
# Initialize pending count
|
| 385 |
-
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 386 |
-
for _ in range(len(devices) - 1):
|
| 387 |
-
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 388 |
-
|
| 389 |
-
# Launch parallel tasks
|
| 390 |
-
futures = []
|
| 391 |
-
for device in devices:
|
| 392 |
-
future = generate_device_config.remote(
|
| 393 |
-
device_id=device['device_id'],
|
| 394 |
-
device_data=device,
|
| 395 |
-
template_fn=template_fn,
|
| 396 |
-
progress_tracker=progress_tracker
|
| 397 |
)
|
| 398 |
-
futures.append(future)
|
| 399 |
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
# Get final results
|
| 410 |
-
results = ray.get(progress_tracker.get_results.remote())
|
| 411 |
-
final_progress = ray.get(progress_tracker.get_progress.remote())
|
| 412 |
-
|
| 413 |
-
return results, final_progress
|
| 414 |
-
|
| 415 |
-
def parallel_batfish_analysis(self, configs: Dict[str, str],
|
| 416 |
-
batfish_client: Any,
|
| 417 |
-
batch_size: int = 50) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 418 |
-
"""
|
| 419 |
-
Analyze configs in parallel using Batfish.
|
| 420 |
-
|
| 421 |
-
Args:
|
| 422 |
-
configs: Dict mapping device_id to config string
|
| 423 |
-
batfish_client: Batfish client instance
|
| 424 |
-
batch_size: Number of configs to analyze in each batch
|
| 425 |
-
|
| 426 |
-
Returns:
|
| 427 |
-
Tuple of (results, final_progress)
|
| 428 |
-
"""
|
| 429 |
-
self.initialize()
|
| 430 |
-
|
| 431 |
-
progress_tracker = ProgressTracker.remote(total_devices=len(configs))
|
| 432 |
-
|
| 433 |
-
# Initialize pending count
|
| 434 |
-
for _ in range(len(configs)):
|
| 435 |
-
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 436 |
-
|
| 437 |
-
# Launch parallel analysis tasks
|
| 438 |
-
futures = []
|
| 439 |
-
for device_id, config in configs.items():
|
| 440 |
-
future = analyze_device_config.remote(
|
| 441 |
device_id=device_id,
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
)
|
| 446 |
-
futures.append(future)
|
| 447 |
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
return results, final_progress
|
| 459 |
-
|
| 460 |
-
def parallel_deployment(self, deployments: Dict[str, str],
|
| 461 |
-
gns3_client: Any,
|
| 462 |
-
batch_size: int = 20,
|
| 463 |
-
max_retries: int = 3) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 464 |
"""
|
| 465 |
-
|
| 466 |
|
| 467 |
Args:
|
| 468 |
-
|
|
|
|
| 469 |
gns3_client: GNS3 client instance
|
| 470 |
-
|
| 471 |
-
max_retries: Maximum retry attempts
|
| 472 |
|
| 473 |
Returns:
|
| 474 |
-
|
| 475 |
"""
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
progress_tracker = ProgressTracker.remote(total_devices=len(deployments))
|
| 479 |
-
|
| 480 |
-
# Initialize pending count
|
| 481 |
-
for _ in range(len(deployments)):
|
| 482 |
-
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 483 |
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
"""
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
Implements canary deployment pattern:
|
| 525 |
-
- Stage 1: 1% of fleet
|
| 526 |
-
- Stage 2: 10% of fleet
|
| 527 |
-
- Stage 3: 50% of fleet
|
| 528 |
-
- Stage 4: 100% of fleet
|
| 529 |
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
gns3_client: GNS3 client instance
|
| 533 |
-
stages: List of percentages for each stage (0.0 to 1.0)
|
| 534 |
-
validation_fn: Optional function to validate stage success
|
| 535 |
-
|
| 536 |
-
Returns:
|
| 537 |
-
Tuple of (results, final_progress)
|
| 538 |
"""
|
| 539 |
-
self.initialize()
|
| 540 |
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
|
|
|
| 551 |
|
| 552 |
-
|
| 553 |
-
|
|
|
|
|
|
|
| 554 |
|
| 555 |
-
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
-
|
| 558 |
-
results, progress = self.parallel_deployment(
|
| 559 |
-
deployments=stage_deployments,
|
| 560 |
-
gns3_client=gns3_client,
|
| 561 |
-
batch_size=min(20, len(stage_devices))
|
| 562 |
-
)
|
| 563 |
|
| 564 |
-
|
|
|
|
|
|
|
| 565 |
|
| 566 |
-
#
|
| 567 |
-
|
| 568 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 569 |
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
)
|
| 578 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
final_progress = ExecutionProgress(
|
| 594 |
total_devices=total_devices,
|
| 595 |
completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
|
| 596 |
failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
|
| 597 |
)
|
| 598 |
return all_results, final_progress.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
|
| 600 |
-
|
| 601 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
|
|
|
| 609 |
|
| 610 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Works locally (single machine) or on Ray clusters with zero code changes.
|
| 11 |
"""
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
import logging
|
| 14 |
from typing import List, Dict, Any, Optional, Callable, Tuple
|
| 15 |
from dataclasses import dataclass, field
|
| 16 |
from enum import Enum
|
| 17 |
import asyncio
|
| 18 |
+
|
| 19 |
+
# Optional ray import - gracefully degrade if not available
|
| 20 |
+
try:
|
| 21 |
+
import ray
|
| 22 |
+
from ray.util.queue import Queue as RayQueue
|
| 23 |
+
RAY_AVAILABLE = True
|
| 24 |
+
except ImportError:
|
| 25 |
+
RAY_AVAILABLE = False
|
| 26 |
+
logging.warning("Ray not installed - distributed execution disabled. Install with: pip install ray")
|
| 27 |
+
|
| 28 |
+
import time
|
| 29 |
from datetime import datetime
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
|
|
|
| 106 |
}
|
| 107 |
|
| 108 |
|
| 109 |
+
# Only define ray-dependent classes if ray is available
|
| 110 |
+
if RAY_AVAILABLE:
|
| 111 |
+
@ray.remote
|
| 112 |
+
class ProgressTracker:
|
| 113 |
+
"""Actor for tracking execution progress across distributed workers"""
|
| 114 |
+
|
| 115 |
+
def __init__(self, total_devices: int):
|
| 116 |
+
self.progress = ExecutionProgress(total_devices=total_devices)
|
| 117 |
+
self.results: List[TaskResult] = []
|
| 118 |
+
|
| 119 |
+
def update_status(self, device_id: str, status: TaskStatus):
|
| 120 |
+
"""Update device status"""
|
| 121 |
+
if status == TaskStatus.RUNNING:
|
| 122 |
+
self.progress.running += 1
|
| 123 |
+
self.progress.pending -= 1
|
| 124 |
+
elif status == TaskStatus.SUCCESS:
|
| 125 |
+
self.progress.running -= 1
|
| 126 |
+
self.progress.completed += 1
|
| 127 |
+
elif status == TaskStatus.FAILED:
|
| 128 |
+
self.progress.running -= 1
|
| 129 |
+
self.progress.failed += 1
|
| 130 |
+
|
| 131 |
+
def add_result(self, result: TaskResult):
|
| 132 |
+
"""Add task result"""
|
| 133 |
+
self.results.append(result)
|
| 134 |
+
|
| 135 |
+
def get_progress(self) -> Dict[str, Any]:
|
| 136 |
+
"""Get current progress"""
|
| 137 |
+
return self.progress.to_dict()
|
| 138 |
+
|
| 139 |
+
def get_results(self) -> List[TaskResult]:
|
| 140 |
+
"""Get all results"""
|
| 141 |
+
return self.results
|
| 142 |
+
|
| 143 |
+
def get_failed_devices(self) -> List[str]:
|
| 144 |
+
"""Get list of failed device IDs"""
|
| 145 |
+
return [r.device_id for r in self.results if r.status == TaskStatus.FAILED]
|
| 146 |
|
| 147 |
|
| 148 |
+
@ray.remote
|
| 149 |
+
def generate_device_config(device_id: str, device_data: Dict[str, Any],
|
| 150 |
+
template_fn: Callable, progress_tracker: Any) -> TaskResult:
|
| 151 |
+
"""
|
| 152 |
+
Ray remote function for parallel config generation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
+
Args:
|
| 155 |
+
device_id: Unique device identifier
|
| 156 |
+
device_data: Device parameters (hostname, ip, role, etc.)
|
| 157 |
+
template_fn: Function to generate config from device data
|
| 158 |
+
progress_tracker: Progress tracking actor
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
Returns:
|
| 161 |
+
TaskResult with generated config or error
|
| 162 |
+
"""
|
| 163 |
+
start_time = time.time()
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
try:
|
| 166 |
+
# Update status to running
|
| 167 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
# Generate config
|
| 170 |
+
config = template_fn(device_data)
|
| 171 |
|
| 172 |
duration = time.time() - start_time
|
| 173 |
result = TaskResult(
|
| 174 |
device_id=device_id,
|
| 175 |
status=TaskStatus.SUCCESS,
|
| 176 |
+
result=config,
|
| 177 |
+
duration_seconds=duration
|
|
|
|
| 178 |
)
|
| 179 |
|
| 180 |
+
# Update status to success
|
| 181 |
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
|
| 182 |
ray.get(progress_tracker.add_result.remote(result))
|
| 183 |
|
| 184 |
return result
|
| 185 |
|
| 186 |
except Exception as e:
|
| 187 |
+
duration = time.time() - start_time
|
| 188 |
+
result = TaskResult(
|
| 189 |
+
device_id=device_id,
|
| 190 |
+
status=TaskStatus.FAILED,
|
| 191 |
+
error=str(e),
|
| 192 |
+
duration_seconds=duration
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
|
| 196 |
+
ray.get(progress_tracker.add_result.remote(result))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
+
return result
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
+
@ray.remote
|
| 202 |
+
def analyze_device_config(device_id: str, config: str,
|
| 203 |
+
batfish_client: Any, progress_tracker: Any) -> TaskResult:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
"""
|
| 205 |
+
Ray remote function for parallel Batfish analysis.
|
| 206 |
|
| 207 |
Args:
|
| 208 |
+
device_id: Unique device identifier
|
| 209 |
+
config: Device configuration to analyze
|
| 210 |
+
batfish_client: Batfish client instance
|
| 211 |
+
progress_tracker: Progress tracking actor
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
TaskResult with analysis results or error
|
| 215 |
"""
|
| 216 |
+
start_time = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
try:
|
| 219 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
|
| 221 |
+
# Run Batfish analysis
|
| 222 |
+
analysis = batfish_client.analyze_configs({device_id: config})
|
| 223 |
|
| 224 |
+
duration = time.time() - start_time
|
| 225 |
+
result = TaskResult(
|
| 226 |
+
device_id=device_id,
|
| 227 |
+
status=TaskStatus.SUCCESS,
|
| 228 |
+
result=analysis,
|
| 229 |
+
duration_seconds=duration
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
)
|
|
|
|
| 231 |
|
| 232 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
|
| 233 |
+
ray.get(progress_tracker.add_result.remote(result))
|
| 234 |
+
|
| 235 |
+
return result
|
| 236 |
+
|
| 237 |
+
except Exception as e:
|
| 238 |
+
duration = time.time() - start_time
|
| 239 |
+
result = TaskResult(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
device_id=device_id,
|
| 241 |
+
status=TaskStatus.FAILED,
|
| 242 |
+
error=str(e),
|
| 243 |
+
duration_seconds=duration
|
| 244 |
)
|
|
|
|
| 245 |
|
| 246 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
|
| 247 |
+
ray.get(progress_tracker.add_result.remote(result))
|
| 248 |
+
|
| 249 |
+
return result
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
@ray.remote
|
| 253 |
+
def deploy_to_device(device_id: str, config: str,
|
| 254 |
+
gns3_client: Any, progress_tracker: Any,
|
| 255 |
+
max_retries: int = 3) -> TaskResult:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
"""
|
| 257 |
+
Ray remote function for parallel device deployment.
|
| 258 |
|
| 259 |
Args:
|
| 260 |
+
device_id: Unique device identifier
|
| 261 |
+
config: Configuration to deploy
|
| 262 |
gns3_client: GNS3 client instance
|
| 263 |
+
progress_tracker: Progress tracking actor
|
| 264 |
+
max_retries: Maximum retry attempts on failure
|
| 265 |
|
| 266 |
Returns:
|
| 267 |
+
TaskResult with deployment status or error
|
| 268 |
"""
|
| 269 |
+
start_time = time.time()
|
| 270 |
+
retry_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
+
while retry_count <= max_retries:
|
| 273 |
+
try:
|
| 274 |
+
if retry_count > 0:
|
| 275 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RETRYING))
|
| 276 |
+
else:
|
| 277 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.RUNNING))
|
| 278 |
+
|
| 279 |
+
# Deploy config to device
|
| 280 |
+
deployment_result = gns3_client.apply_config(device_id, config)
|
| 281 |
+
|
| 282 |
+
duration = time.time() - start_time
|
| 283 |
+
result = TaskResult(
|
| 284 |
+
device_id=device_id,
|
| 285 |
+
status=TaskStatus.SUCCESS,
|
| 286 |
+
result=deployment_result,
|
| 287 |
+
duration_seconds=duration,
|
| 288 |
+
retry_count=retry_count
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.SUCCESS))
|
| 292 |
+
ray.get(progress_tracker.add_result.remote(result))
|
| 293 |
+
|
| 294 |
+
return result
|
| 295 |
+
|
| 296 |
+
except Exception as e:
|
| 297 |
+
retry_count += 1
|
| 298 |
+
if retry_count > max_retries:
|
| 299 |
+
duration = time.time() - start_time
|
| 300 |
+
result = TaskResult(
|
| 301 |
+
device_id=device_id,
|
| 302 |
+
status=TaskStatus.FAILED,
|
| 303 |
+
error=f"Failed after {retry_count} retries: {str(e)}",
|
| 304 |
+
duration_seconds=duration,
|
| 305 |
+
retry_count=retry_count - 1
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
ray.get(progress_tracker.update_status.remote(device_id, TaskStatus.FAILED))
|
| 309 |
+
ray.get(progress_tracker.add_result.remote(result))
|
| 310 |
+
|
| 311 |
+
return result
|
| 312 |
+
|
| 313 |
+
# Exponential backoff
|
| 314 |
+
time.sleep(2 ** retry_count)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
class RayExecutor:
|
| 318 |
"""
|
| 319 |
+
Distributed execution engine for hyperscale network automation.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
+
Provides parallel execution of config generation, analysis, and deployment
|
| 322 |
+
across thousands of devices using Ray's distributed computing framework.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
"""
|
|
|
|
| 324 |
|
| 325 |
+
def __init__(self, ray_address: Optional[str] = None, num_cpus: Optional[int] = None):
|
| 326 |
+
"""
|
| 327 |
+
Initialize Ray executor.
|
| 328 |
+
|
| 329 |
+
Args:
|
| 330 |
+
ray_address: Ray cluster address (None for local mode)
|
| 331 |
+
num_cpus: Number of CPUs to use (None for auto-detect)
|
| 332 |
+
"""
|
| 333 |
+
self.ray_address = ray_address
|
| 334 |
+
self.num_cpus = num_cpus
|
| 335 |
+
self.initialized = False
|
| 336 |
+
self._progress_tracker = NoneNone
|
| 337 |
|
| 338 |
+
def initialize(self):
|
| 339 |
+
"""Initialize Ray runtime"""
|
| 340 |
+
if self.initialized:
|
| 341 |
+
return
|
| 342 |
+
|
| 343 |
+
try:
|
| 344 |
+
# Check if Ray is already initialized
|
| 345 |
+
if ray.is_initialized():
|
| 346 |
+
logger.info("Ray already initialized")
|
| 347 |
+
else:
|
| 348 |
+
# Initialize Ray
|
| 349 |
+
if self.ray_address:
|
| 350 |
+
# Connect to existing cluster
|
| 351 |
+
ray.init(address=self.ray_address)
|
| 352 |
+
logger.info(f"Connected to Ray cluster at {self.ray_address}")
|
| 353 |
+
else:
|
| 354 |
+
# Start local Ray instance
|
| 355 |
+
init_kwargs = {}
|
| 356 |
+
if self.num_cpus:
|
| 357 |
+
init_kwargs['num_cpus'] = self.num_cpus
|
| 358 |
+
|
| 359 |
+
ray.init(**init_kwargs)
|
| 360 |
+
logger.info(f"Started local Ray instance with {ray.available_resources().get('CPU', 0)} CPUs")
|
| 361 |
+
|
| 362 |
+
self.initialized = True
|
| 363 |
+
|
| 364 |
+
except Exception as e:
|
| 365 |
+
logger.error(f"Failed to initialize Ray: {e}")
|
| 366 |
+
raise
|
| 367 |
+
|
| 368 |
+
def shutdown(self):
|
| 369 |
+
"""Shutdown Ray runtime"""
|
| 370 |
+
if self.initialized and ray.is_initialized():
|
| 371 |
+
ray.shutdown()
|
| 372 |
+
self.initialized = False
|
| 373 |
+
logger.info("Ray shutdown complete")
|
| 374 |
+
|
| 375 |
+
def parallel_config_generation(self, devices: List[Dict[str, Any]],
|
| 376 |
+
template_fn: Callable,
|
| 377 |
+
batch_size: int = 100) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 378 |
+
"""
|
| 379 |
+
Generate configs for multiple devices in parallel.
|
| 380 |
+
|
| 381 |
+
Args:
|
| 382 |
+
devices: List of device data dicts
|
| 383 |
+
template_fn: Function to generate config from device data
|
| 384 |
+
batch_size: Number of devices to process in each batch
|
| 385 |
+
|
| 386 |
+
Returns:
|
| 387 |
+
Tuple of (results, final_progress)
|
| 388 |
+
"""
|
| 389 |
+
self.initialize()
|
| 390 |
+
|
| 391 |
+
# Create progress tracker
|
| 392 |
+
progress_tracker = ProgressTracker.remote(total_devices=len(devices))
|
| 393 |
+
|
| 394 |
+
# Initialize pending count
|
| 395 |
+
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 396 |
+
for _ in range(len(devices) - 1):
|
| 397 |
+
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 398 |
+
|
| 399 |
+
# Launch parallel tasks
|
| 400 |
+
futures = []
|
| 401 |
+
for device in devices:
|
| 402 |
+
future = generate_device_config.remote(
|
| 403 |
+
device_id=device['device_id'],
|
| 404 |
+
device_data=device,
|
| 405 |
+
template_fn=template_fn,
|
| 406 |
+
progress_tracker=progress_tracker
|
| 407 |
+
)
|
| 408 |
+
futures.append(future)
|
| 409 |
+
|
| 410 |
+
# Process in batches to avoid overwhelming the cluster
|
| 411 |
+
if len(futures) >= batch_size:
|
| 412 |
+
ray.get(futures)
|
| 413 |
+
futures = []
|
| 414 |
+
|
| 415 |
+
# Wait for remaining tasks
|
| 416 |
+
if futures:
|
| 417 |
+
ray.get(futures)
|
| 418 |
+
|
| 419 |
+
# Get final results
|
| 420 |
+
results = ray.get(progress_tracker.get_results.remote())
|
| 421 |
+
final_progress = ray.get(progress_tracker.get_progress.remote())
|
| 422 |
+
|
| 423 |
+
return results, final_progress
|
| 424 |
|
| 425 |
+
def parallel_batfish_analysis(self, configs: Dict[str, str],
|
| 426 |
+
batfish_client: Any,
|
| 427 |
+
batch_size: int = 50) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 428 |
+
"""
|
| 429 |
+
Analyze configs in parallel using Batfish.
|
| 430 |
|
| 431 |
+
Args:
|
| 432 |
+
configs: Dict mapping device_id to config string
|
| 433 |
+
batfish_client: Batfish client instance
|
| 434 |
+
batch_size: Number of configs to analyze in each batch
|
| 435 |
|
| 436 |
+
Returns:
|
| 437 |
+
Tuple of (results, final_progress)
|
| 438 |
+
"""
|
| 439 |
+
self.initialize()
|
| 440 |
|
| 441 |
+
progress_tracker = ProgressTracker.remote(total_devices=len(configs))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
|
| 443 |
+
# Initialize pending count
|
| 444 |
+
for _ in range(len(configs)):
|
| 445 |
+
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 446 |
|
| 447 |
+
# Launch parallel analysis tasks
|
| 448 |
+
futures = []
|
| 449 |
+
for device_id, config in configs.items():
|
| 450 |
+
future = analyze_device_config.remote(
|
| 451 |
+
device_id=device_id,
|
| 452 |
+
config=config,
|
| 453 |
+
batfish_client=batfish_client,
|
| 454 |
+
progress_tracker=progress_tracker
|
| 455 |
+
)
|
| 456 |
+
futures.append(future)
|
| 457 |
+
|
| 458 |
+
if len(futures) >= batch_size:
|
| 459 |
+
ray.get(futures)
|
| 460 |
+
futures = []
|
| 461 |
+
|
| 462 |
+
if futures:
|
| 463 |
+
ray.get(futures)
|
| 464 |
+
|
| 465 |
+
results = ray.get(progress_tracker.get_results.remote())
|
| 466 |
+
final_progress = ray.get(progress_tracker.get_progress.remote())
|
| 467 |
+
|
| 468 |
+
return results, final_progressress
|
| 469 |
+
|
| 470 |
+
def parallel_deployment(self, deployments: Dict[str, str],
|
| 471 |
+
gns3_client: Any,
|
| 472 |
+
batch_size: int = 20,
|
| 473 |
+
max_retries: int = 3) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 474 |
+
"""
|
| 475 |
+
Deploy configs to multiple devices in parallel.
|
| 476 |
+
|
| 477 |
+
Args:
|
| 478 |
+
deployments: Dict mapping device_id to config string
|
| 479 |
+
gns3_client: GNS3 client instance
|
| 480 |
+
batch_size: Number of devices to deploy to simultaneously
|
| 481 |
+
max_retries: Maximum retry attempts per device
|
| 482 |
|
| 483 |
+
Returns:
|
| 484 |
+
Tuple of (results, final_progress)
|
| 485 |
+
"""
|
| 486 |
+
self.initialize()
|
| 487 |
+
|
| 488 |
+
progress_tracker = ProgressTracker.remote(total_devices=len(deployments))
|
| 489 |
+
|
| 490 |
+
# Initialize pending count
|
| 491 |
+
for _ in range(len(deployments)):
|
| 492 |
+
ray.get(progress_tracker.update_status.remote("_init_", TaskStatus.PENDING))
|
| 493 |
+
|
| 494 |
+
# Launch parallel deployment tasks
|
| 495 |
+
futures = []
|
| 496 |
+
for device_id, config in deployments.items():
|
| 497 |
+
future = deploy_to_device.remote(
|
| 498 |
+
device_id=device_id,
|
| 499 |
+
config=config,
|
| 500 |
+
gns3_client=gns3_client,
|
| 501 |
+
progress_tracker=progress_tracker,
|
| 502 |
+
max_retries=max_retries
|
| 503 |
)
|
| 504 |
+
futures.append(future)
|
| 505 |
+
|
| 506 |
+
# Deploy in smaller batches to avoid overwhelming network
|
| 507 |
+
if len(futures) >= batch_size:
|
| 508 |
+
ray.get(futures)
|
| 509 |
+
futures = []
|
| 510 |
|
| 511 |
+
if futures:
|
| 512 |
+
ray.get(futures)
|
| 513 |
+
|
| 514 |
+
results = ray.get(progress_tracker.get_results.remote())
|
| 515 |
+
final_progress = ray.get(progress_tracker.get_progress.remote())
|
| 516 |
+
|
| 517 |
+
return results, final_progress
|
| 518 |
+
|
| 519 |
+
def get_cluster_resources(self) -> Dict[str, Any]:
|
| 520 |
+
"""Get available cluster resources"""
|
| 521 |
+
self.initialize()
|
| 522 |
+
return {
|
| 523 |
+
'available': ray.available_resources(),
|
| 524 |
+
'total': ray.cluster_resources()
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
def staggered_rollout(self, deployments: Dict[str, str],
|
| 528 |
+
gns3_client: Any,
|
| 529 |
+
stages: List[float] = [0.01, 0.1, 0.5, 1.0],
|
| 530 |
+
validation_fn: Optional[Callable] = None) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 531 |
+
"""
|
| 532 |
+
Deploy to devices in stages with validation between stages.
|
| 533 |
+
|
| 534 |
+
Implements canary deployment pattern:
|
| 535 |
+
- Stage 1: 1% of fleet
|
| 536 |
+
- Stage 2: 10% of fleet
|
| 537 |
+
- Stage 3: 50% of fleet
|
| 538 |
+
- Stage 4: 100% of fleet
|
| 539 |
+
|
| 540 |
+
Args:
|
| 541 |
+
deployments: Dict mapping device_id to config
|
| 542 |
+
gns3_client: GNS3 client instance
|
| 543 |
+
stages: List of percentages for each stage (0.0 to 1.0)
|
| 544 |
+
validation_fn: Optional function to validate stage success
|
| 545 |
+
|
| 546 |
+
Returns:
|
| 547 |
+
Tuple of (results, final_progress)
|
| 548 |
+
"""
|
| 549 |
+
self.initialize()
|
| 550 |
+
|
| 551 |
+
device_ids = list(deployments.keys())
|
| 552 |
+
total_devices = len(device_ids)
|
| 553 |
+
all_results = []
|
| 554 |
+
|
| 555 |
+
current_index = 0
|
| 556 |
+
|
| 557 |
+
for stage_pct in stages:
|
| 558 |
+
stage_count = int(total_devices * stage_pct) - current_index
|
| 559 |
+
if stage_count <= 0:
|
| 560 |
+
continue
|
| 561 |
+
|
| 562 |
+
stage_devices = device_ids[current_index:current_index + stage_count]
|
| 563 |
+
stage_deployments = {did: deployments[did] for did in stage_devices}
|
| 564 |
+
|
| 565 |
+
logger.info(f"Starting stage {stage_pct*100}%: deploying to {len(stage_devices)} devices")
|
| 566 |
+
|
| 567 |
+
# Deploy this stage
|
| 568 |
+
results, progress = self.parallel_deployment(
|
| 569 |
+
deployments=stage_deployments,
|
| 570 |
+
gns3_client=gns3_client,
|
| 571 |
+
batch_size=min(20, len(stage_devices))
|
| 572 |
+
)
|
| 573 |
+
|
| 574 |
+
all_results.extend(results)
|
| 575 |
+
|
| 576 |
+
# Check for failures
|
| 577 |
+
failed_count = sum(1 for r in results if r.status == TaskStatus.FAILED)
|
| 578 |
+
failure_rate = failed_count / len(results) if results else 0
|
| 579 |
+
|
| 580 |
+
if failure_rate > 0.1: # More than 10% failure rate
|
| 581 |
+
logger.error(f"Stage failed with {failure_rate*100}% failure rate. Stopping rollout.")
|
| 582 |
+
# Return partial results
|
| 583 |
final_progress = ExecutionProgress(
|
| 584 |
total_devices=total_devices,
|
| 585 |
completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
|
| 586 |
failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
|
| 587 |
)
|
| 588 |
return all_results, final_progress.to_dict()
|
| 589 |
+
|
| 590 |
+
# Run validation if provided
|
| 591 |
+
if validation_fn:
|
| 592 |
+
try:
|
| 593 |
+
if not validation_fn(stage_devices, results):
|
| 594 |
+
logger.error("Stage validation failed. Stopping rollout.")
|
| 595 |
+
final_progress = ExecutionProgress(
|
| 596 |
+
total_devices=total_devices,
|
| 597 |
+
completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
|
| 598 |
+
failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
|
| 599 |
+
)
|
| 600 |
+
return all_results, final_progress.to_dict()
|
| 601 |
+
except Exception as e:
|
| 602 |
+
logger.error(f"Stage validation error: {e}. Stopping rollout.")
|
| 603 |
+
final_progress = ExecutionProgress(
|
| 604 |
+
total_devices=total_devices,
|
| 605 |
+
completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
|
| 606 |
+
failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
|
| 607 |
+
)
|
| 608 |
+
return all_results, final_progress.to_dict()
|
| 609 |
+
|
| 610 |
+
logger.info(f"Stage {stage_pct*100}% completed successfully")
|
| 611 |
+
current_index += stage_count
|
| 612 |
|
| 613 |
+
# Create final progress
|
| 614 |
+
final_progress = ExecutionProgress(
|
| 615 |
+
total_devices=total_devices,
|
| 616 |
+
completed=sum(1 for r in all_results if r.status == TaskStatus.SUCCESS),
|
| 617 |
+
failed=sum(1 for r in all_results if r.status == TaskStatus.FAILED)
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
return all_results, final_progress.to_dict()
|
| 621 |
+
|
| 622 |
+
else:
|
| 623 |
+
# Fallback executor when ray is not available
|
| 624 |
+
class RayExecutor:
|
| 625 |
+
"""Fallback executor without distributed capabilities"""
|
| 626 |
+
|
| 627 |
+
def __init__(self, ray_address: Optional[str] = None, num_cpus: Optional[int] = None):
|
| 628 |
+
logging.warning("Ray not available - using fallback sequential executor")
|
| 629 |
+
self.ray_address = ray_address
|
| 630 |
+
self.num_cpus = num_cpus
|
| 631 |
+
self.initialized = False
|
| 632 |
+
|
| 633 |
+
def initialize(self):
|
| 634 |
+
"""No-op initialization for fallback"""
|
| 635 |
+
self.initialized = True
|
| 636 |
+
|
| 637 |
+
def shutdown(self):
|
| 638 |
+
"""No-op shutdown for fallback"""
|
| 639 |
+
self.initialized = False
|
| 640 |
+
|
| 641 |
+
def parallel_config_generation(self, devices: List[Dict[str, Any]],
|
| 642 |
+
template_fn: Callable,
|
| 643 |
+
batch_size: int = 100) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 644 |
+
"""Sequential fallback for config generation"""
|
| 645 |
+
raise NotImplementedError(
|
| 646 |
+
"Distributed execution requires ray. Install with: pip install ray"
|
| 647 |
+
)
|
| 648 |
|
| 649 |
+
def parallel_batfish_analysis(self, configs: Dict[str, str],
|
| 650 |
+
batfish_client: Any,
|
| 651 |
+
batch_size: int = 50) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 652 |
+
"""Sequential fallback for analysis"""
|
| 653 |
+
raise NotImplementedError(
|
| 654 |
+
"Distributed execution requires ray. Install with: pip install ray"
|
| 655 |
+
)
|
| 656 |
|
| 657 |
+
def parallel_deployment(self, deployments: Dict[str, str],
|
| 658 |
+
gns3_client: Any,
|
| 659 |
+
batch_size: int = 20,
|
| 660 |
+
max_retries: int = 3) -> Tuple[List[TaskResult], ExecutionProgress]:
|
| 661 |
+
"""Sequential fallback for deployment"""
|
| 662 |
+
raise NotImplementedError(
|
| 663 |
+
"Distributed execution requires ray. Install with: pip install ray"
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
def staged_rollout(self, deployments: Dict[str, str],
|
| 667 |
+
gns3_client: Any,
|
| 668 |
+
stages: List[float] = None,
|
| 669 |
+
validation_fn: Optional[Callable] = None) -> Tuple[List[TaskResult], Dict[str, Any]]:
|
| 670 |
+
"""Sequential fallback for staged rollout"""
|
| 671 |
+
raise NotImplementedError(
|
| 672 |
+
"Distributed execution requires ray. Install with: pip install ray"
|
| 673 |
+
)
|
app.py
CHANGED
|
@@ -237,6 +237,162 @@ def build_ui():
|
|
| 237 |
|
| 238 |
gr.Markdown("---")
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
# Main Pipeline UI
|
| 241 |
gr.Markdown("""### 🌿 Network Automation Pipeline
|
| 242 |
**From Consultation → Production**
|
|
|
|
| 237 |
|
| 238 |
gr.Markdown("---")
|
| 239 |
|
| 240 |
+
# ===== INTERACTIVE CONSULTATION TAB =====
|
| 241 |
+
gr.Markdown("""### 💬 Interactive Consultation
|
| 242 |
+
**Have a conversation before running the full pipeline**
|
| 243 |
+
|
| 244 |
+
Not sure what you need? Start here! The AI will ask clarifying questions to understand your network requirements.
|
| 245 |
+
""")
|
| 246 |
+
|
| 247 |
+
with gr.Row(elem_classes=["og-main-row"]):
|
| 248 |
+
with gr.Column(elem_classes=["og-panel"]):
|
| 249 |
+
gr.Markdown("#### 🤝 Consultation Chat")
|
| 250 |
+
consultation_chat = gr.Chatbot(
|
| 251 |
+
label="AI Network Consultant",
|
| 252 |
+
value=[],
|
| 253 |
+
height=400,
|
| 254 |
+
show_label=False,
|
| 255 |
+
)
|
| 256 |
+
consultation_input = gr.Textbox(
|
| 257 |
+
label="Your message",
|
| 258 |
+
placeholder="Hi, I need help designing a network for my small business...",
|
| 259 |
+
lines=2,
|
| 260 |
+
)
|
| 261 |
+
with gr.Row():
|
| 262 |
+
send_consultation_btn = gr.Button("📤 Send", variant="primary")
|
| 263 |
+
start_new_consultation_btn = gr.Button("🔄 New Consultation", variant="secondary")
|
| 264 |
+
use_consultation_btn = gr.Button("✅ Use This for Pipeline", variant="primary")
|
| 265 |
+
|
| 266 |
+
consultation_summary = gr.Textbox(
|
| 267 |
+
label="Consultation Summary (will be used in pipeline)",
|
| 268 |
+
lines=5,
|
| 269 |
+
interactive=False,
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# Consultation state
|
| 273 |
+
consultation_state = gr.State(value={"consultant": None, "is_complete": False})
|
| 274 |
+
|
| 275 |
+
def start_consultation():
|
| 276 |
+
"""Initialize a new consultation"""
|
| 277 |
+
return {
|
| 278 |
+
"consultant": None,
|
| 279 |
+
"is_complete": False
|
| 280 |
+
}, [], ""
|
| 281 |
+
|
| 282 |
+
def send_consultation_message(user_message, state):
|
| 283 |
+
"""Send a message in the consultation"""
|
| 284 |
+
if not user_message.strip():
|
| 285 |
+
# Get current chat history from state
|
| 286 |
+
consultant = state.get("consultant")
|
| 287 |
+
if consultant and hasattr(consultant, 'conversation_history'):
|
| 288 |
+
# Convert conversation history to chatbot format
|
| 289 |
+
chatbot_messages = []
|
| 290 |
+
for msg in consultant.conversation_history:
|
| 291 |
+
if msg.role == "user" and not msg.content.startswith("Initial request:"):
|
| 292 |
+
chatbot_messages.append((msg.content, None))
|
| 293 |
+
elif msg.role == "assistant" and chatbot_messages:
|
| 294 |
+
# Pair with previous user message
|
| 295 |
+
if chatbot_messages[-1][1] is None:
|
| 296 |
+
chatbot_messages[-1] = (chatbot_messages[-1][0], msg.content)
|
| 297 |
+
else:
|
| 298 |
+
chatbot_messages.append((None, msg.content))
|
| 299 |
+
return state, chatbot_messages, ""
|
| 300 |
+
return state, [], ""
|
| 301 |
+
|
| 302 |
+
from agent.consultation import NetworkConsultant
|
| 303 |
+
|
| 304 |
+
# Get or create consultant
|
| 305 |
+
consultant = state.get("consultant")
|
| 306 |
+
first_message = consultant is None
|
| 307 |
+
|
| 308 |
+
if first_message:
|
| 309 |
+
consultant = NetworkConsultant()
|
| 310 |
+
state["consultant"] = consultant
|
| 311 |
+
# Start consultation with user's first message
|
| 312 |
+
is_complete, response, intent_data = consultant.start_consultation(user_message)
|
| 313 |
+
else:
|
| 314 |
+
# Continue existing consultation
|
| 315 |
+
is_complete, response, intent_data = consultant.continue_consultation(user_message)
|
| 316 |
+
|
| 317 |
+
# Update completion state
|
| 318 |
+
state["is_complete"] = is_complete
|
| 319 |
+
if is_complete and intent_data:
|
| 320 |
+
state["intent_data"] = intent_data
|
| 321 |
+
|
| 322 |
+
# Build chatbot display from conversation history
|
| 323 |
+
chatbot_messages = []
|
| 324 |
+
for msg in consultant.conversation_history:
|
| 325 |
+
# Skip system messages and "Initial request:" prefix
|
| 326 |
+
if msg.role == "system":
|
| 327 |
+
continue
|
| 328 |
+
if msg.role == "user":
|
| 329 |
+
content = msg.content
|
| 330 |
+
if content.startswith("Initial request: "):
|
| 331 |
+
content = content[len("Initial request: "):]
|
| 332 |
+
chatbot_messages.append((content, None))
|
| 333 |
+
elif msg.role == "assistant":
|
| 334 |
+
# Pair with the last user message
|
| 335 |
+
if chatbot_messages and chatbot_messages[-1][1] is None:
|
| 336 |
+
chatbot_messages[-1] = (chatbot_messages[-1][0], msg.content)
|
| 337 |
+
else:
|
| 338 |
+
# Standalone assistant message (shouldn't happen normally)
|
| 339 |
+
chatbot_messages.append((None, msg.content))
|
| 340 |
+
|
| 341 |
+
return state, chatbot_messages, ""
|
| 342 |
+
|
| 343 |
+
def use_consultation_summary(state):
|
| 344 |
+
"""Extract the consultation summary for use in the pipeline"""
|
| 345 |
+
consultant = state.get("consultant")
|
| 346 |
+
if not consultant or not consultant.conversation_history:
|
| 347 |
+
return "No consultation history yet. Start a conversation first!"
|
| 348 |
+
|
| 349 |
+
# If consultation is complete, use the structured intent
|
| 350 |
+
if state.get("is_complete") and state.get("intent_data"):
|
| 351 |
+
intent = state["intent_data"]
|
| 352 |
+
summary = "# Consultation Summary (Complete)\n\n"
|
| 353 |
+
for key, value in intent.items():
|
| 354 |
+
summary += f"**{key}:** {value}\n\n"
|
| 355 |
+
return summary
|
| 356 |
+
|
| 357 |
+
# Otherwise, combine all messages into a summary
|
| 358 |
+
summary = "# Consultation Summary (In Progress)\n\n"
|
| 359 |
+
for msg in consultant.conversation_history:
|
| 360 |
+
if msg.role == "system":
|
| 361 |
+
continue
|
| 362 |
+
prefix = "**You:** " if msg.role == "user" else "**AI:** "
|
| 363 |
+
content = msg.content
|
| 364 |
+
if content.startswith("Initial request: "):
|
| 365 |
+
content = content[len("Initial request: "):]
|
| 366 |
+
summary += f"{prefix}{content}\n\n"
|
| 367 |
+
|
| 368 |
+
return summary
|
| 369 |
+
|
| 370 |
+
# Wire up consultation handlers
|
| 371 |
+
start_new_consultation_btn.click(
|
| 372 |
+
fn=start_consultation,
|
| 373 |
+
outputs=[consultation_state, consultation_chat, consultation_input]
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
send_consultation_btn.click(
|
| 377 |
+
fn=send_consultation_message,
|
| 378 |
+
inputs=[consultation_input, consultation_state],
|
| 379 |
+
outputs=[consultation_state, consultation_chat, consultation_input]
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
consultation_input.submit(
|
| 383 |
+
fn=send_consultation_message,
|
| 384 |
+
inputs=[consultation_input, consultation_state],
|
| 385 |
+
outputs=[consultation_state, consultation_chat, consultation_input]
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
use_consultation_btn.click(
|
| 389 |
+
fn=use_consultation_summary,
|
| 390 |
+
inputs=[consultation_state],
|
| 391 |
+
outputs=[consultation_summary]
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
gr.Markdown("---")
|
| 395 |
+
|
| 396 |
# Main Pipeline UI
|
| 397 |
gr.Markdown("""### 🌿 Network Automation Pipeline
|
| 398 |
**From Consultation → Production**
|