openadapt-ml 0.2.0__py3-none-any.whl → 0.2.1__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.
Files changed (95) hide show
  1. openadapt_ml/baselines/__init__.py +121 -0
  2. openadapt_ml/baselines/adapter.py +185 -0
  3. openadapt_ml/baselines/cli.py +314 -0
  4. openadapt_ml/baselines/config.py +448 -0
  5. openadapt_ml/baselines/parser.py +922 -0
  6. openadapt_ml/baselines/prompts.py +787 -0
  7. openadapt_ml/benchmarks/__init__.py +13 -115
  8. openadapt_ml/benchmarks/agent.py +265 -421
  9. openadapt_ml/benchmarks/azure.py +28 -19
  10. openadapt_ml/benchmarks/azure_ops_tracker.py +521 -0
  11. openadapt_ml/benchmarks/cli.py +1722 -4847
  12. openadapt_ml/benchmarks/trace_export.py +631 -0
  13. openadapt_ml/benchmarks/viewer.py +22 -5
  14. openadapt_ml/benchmarks/vm_monitor.py +530 -29
  15. openadapt_ml/benchmarks/waa_deploy/Dockerfile +47 -53
  16. openadapt_ml/benchmarks/waa_deploy/api_agent.py +21 -20
  17. openadapt_ml/cloud/azure_inference.py +3 -5
  18. openadapt_ml/cloud/lambda_labs.py +722 -307
  19. openadapt_ml/cloud/local.py +2038 -487
  20. openadapt_ml/cloud/ssh_tunnel.py +68 -26
  21. openadapt_ml/datasets/next_action.py +40 -30
  22. openadapt_ml/evals/grounding.py +8 -3
  23. openadapt_ml/evals/plot_eval_metrics.py +15 -13
  24. openadapt_ml/evals/trajectory_matching.py +41 -26
  25. openadapt_ml/experiments/demo_prompt/format_demo.py +16 -6
  26. openadapt_ml/experiments/demo_prompt/run_experiment.py +26 -16
  27. openadapt_ml/experiments/representation_shootout/__init__.py +70 -0
  28. openadapt_ml/experiments/representation_shootout/conditions.py +708 -0
  29. openadapt_ml/experiments/representation_shootout/config.py +390 -0
  30. openadapt_ml/experiments/representation_shootout/evaluator.py +659 -0
  31. openadapt_ml/experiments/representation_shootout/runner.py +687 -0
  32. openadapt_ml/experiments/waa_demo/runner.py +29 -14
  33. openadapt_ml/export/parquet.py +36 -24
  34. openadapt_ml/grounding/detector.py +18 -14
  35. openadapt_ml/ingest/__init__.py +8 -6
  36. openadapt_ml/ingest/capture.py +25 -22
  37. openadapt_ml/ingest/loader.py +7 -4
  38. openadapt_ml/ingest/synthetic.py +189 -100
  39. openadapt_ml/models/api_adapter.py +14 -4
  40. openadapt_ml/models/base_adapter.py +10 -2
  41. openadapt_ml/models/providers/__init__.py +288 -0
  42. openadapt_ml/models/providers/anthropic.py +266 -0
  43. openadapt_ml/models/providers/base.py +299 -0
  44. openadapt_ml/models/providers/google.py +376 -0
  45. openadapt_ml/models/providers/openai.py +342 -0
  46. openadapt_ml/models/qwen_vl.py +46 -19
  47. openadapt_ml/perception/__init__.py +35 -0
  48. openadapt_ml/perception/integration.py +399 -0
  49. openadapt_ml/retrieval/demo_retriever.py +50 -24
  50. openadapt_ml/retrieval/embeddings.py +9 -8
  51. openadapt_ml/retrieval/retriever.py +3 -1
  52. openadapt_ml/runtime/__init__.py +50 -0
  53. openadapt_ml/runtime/policy.py +18 -5
  54. openadapt_ml/runtime/safety_gate.py +471 -0
  55. openadapt_ml/schema/__init__.py +9 -0
  56. openadapt_ml/schema/converters.py +74 -27
  57. openadapt_ml/schema/episode.py +31 -18
  58. openadapt_ml/scripts/capture_screenshots.py +530 -0
  59. openadapt_ml/scripts/compare.py +85 -54
  60. openadapt_ml/scripts/demo_policy.py +4 -1
  61. openadapt_ml/scripts/eval_policy.py +15 -9
  62. openadapt_ml/scripts/make_gif.py +1 -1
  63. openadapt_ml/scripts/prepare_synthetic.py +3 -1
  64. openadapt_ml/scripts/train.py +21 -9
  65. openadapt_ml/segmentation/README.md +920 -0
  66. openadapt_ml/segmentation/__init__.py +97 -0
  67. openadapt_ml/segmentation/adapters/__init__.py +5 -0
  68. openadapt_ml/segmentation/adapters/capture_adapter.py +420 -0
  69. openadapt_ml/segmentation/annotator.py +610 -0
  70. openadapt_ml/segmentation/cache.py +290 -0
  71. openadapt_ml/segmentation/cli.py +674 -0
  72. openadapt_ml/segmentation/deduplicator.py +656 -0
  73. openadapt_ml/segmentation/frame_describer.py +788 -0
  74. openadapt_ml/segmentation/pipeline.py +340 -0
  75. openadapt_ml/segmentation/schemas.py +622 -0
  76. openadapt_ml/segmentation/segment_extractor.py +634 -0
  77. openadapt_ml/training/azure_ops_viewer.py +1097 -0
  78. openadapt_ml/training/benchmark_viewer.py +52 -41
  79. openadapt_ml/training/shared_ui.py +7 -7
  80. openadapt_ml/training/stub_provider.py +57 -35
  81. openadapt_ml/training/trainer.py +143 -86
  82. openadapt_ml/training/trl_trainer.py +70 -21
  83. openadapt_ml/training/viewer.py +323 -108
  84. openadapt_ml/training/viewer_components.py +180 -0
  85. {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.1.dist-info}/METADATA +215 -14
  86. openadapt_ml-0.2.1.dist-info/RECORD +116 -0
  87. openadapt_ml/benchmarks/base.py +0 -366
  88. openadapt_ml/benchmarks/data_collection.py +0 -432
  89. openadapt_ml/benchmarks/live_tracker.py +0 -180
  90. openadapt_ml/benchmarks/runner.py +0 -418
  91. openadapt_ml/benchmarks/waa.py +0 -761
  92. openadapt_ml/benchmarks/waa_live.py +0 -619
  93. openadapt_ml-0.2.0.dist-info/RECORD +0 -86
  94. {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.1.dist-info}/WHEEL +0 -0
  95. {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1097 @@
1
+ """Azure Operations Dashboard HTML generation.
2
+
3
+ Generates a real-time dashboard for monitoring Azure VM operations
4
+ (Docker builds, Windows boot, benchmark runs, etc.).
5
+
6
+ Usage:
7
+ from openadapt_ml.training.azure_ops_viewer import generate_azure_ops_dashboard
8
+
9
+ # Generate and write HTML
10
+ generate_azure_ops_dashboard(Path("training_output/current/azure_ops.html"))
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from openadapt_ml.training.shared_ui import (
18
+ get_shared_header_css as _get_shared_header_css,
19
+ )
20
+
21
+
22
+ def generate_azure_ops_dashboard(output_path: Path | str | None = None) -> str:
23
+ """Generate Azure Operations Dashboard HTML.
24
+
25
+ Args:
26
+ output_path: Optional path to write the HTML file.
27
+
28
+ Returns:
29
+ HTML string.
30
+ """
31
+ shared_header_css = _get_shared_header_css()
32
+
33
+ html = f"""<!DOCTYPE html>
34
+ <html lang="en">
35
+ <head>
36
+ <meta charset="UTF-8">
37
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
38
+ <title>Azure Operations Dashboard</title>
39
+ <style>
40
+ :root {{
41
+ --bg-primary: #0a0a0f;
42
+ --bg-secondary: #12121a;
43
+ --bg-tertiary: #1a1a24;
44
+ --border-color: rgba(255, 255, 255, 0.06);
45
+ --text-primary: #f0f0f0;
46
+ --text-secondary: #888;
47
+ --text-muted: #555;
48
+ --accent: #00d4aa;
49
+ --accent-dim: rgba(0, 212, 170, 0.15);
50
+ --success: #34d399;
51
+ --error: #ff5f5f;
52
+ --warning: #f59e0b;
53
+ }}
54
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
55
+ body {{
56
+ font-family: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Inter", sans-serif;
57
+ background: var(--bg-primary);
58
+ color: var(--text-primary);
59
+ min-height: 100vh;
60
+ line-height: 1.5;
61
+ }}
62
+ .container {{
63
+ max-width: 1400px;
64
+ margin: 0 auto;
65
+ padding: 24px;
66
+ }}
67
+ {shared_header_css}
68
+
69
+ /* Header with nav */
70
+ .unified-header {{
71
+ display: flex;
72
+ align-items: center;
73
+ justify-content: space-between;
74
+ padding: 12px 24px;
75
+ background: linear-gradient(180deg, rgba(18,18,26,0.98) 0%, rgba(26,26,36,0.98) 100%);
76
+ border-bottom: 1px solid rgba(255,255,255,0.08);
77
+ margin-bottom: 20px;
78
+ gap: 16px;
79
+ flex-wrap: wrap;
80
+ box-shadow: 0 2px 8px rgba(0,0,0,0.3);
81
+ }}
82
+ .nav-tabs {{
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 4px;
86
+ background: rgba(0,0,0,0.3);
87
+ padding: 4px;
88
+ border-radius: 8px;
89
+ }}
90
+ .nav-tab {{
91
+ padding: 8px 16px;
92
+ border-radius: 6px;
93
+ font-size: 0.85rem;
94
+ font-weight: 500;
95
+ text-decoration: none;
96
+ color: var(--text-secondary);
97
+ background: transparent;
98
+ border: none;
99
+ transition: all 0.2s;
100
+ cursor: pointer;
101
+ }}
102
+ .nav-tab:hover {{
103
+ color: var(--text-primary);
104
+ background: rgba(255,255,255,0.05);
105
+ }}
106
+ .nav-tab.active {{
107
+ color: var(--bg-primary);
108
+ background: var(--accent);
109
+ font-weight: 600;
110
+ }}
111
+
112
+ /* Status Grid */
113
+ .status-grid {{
114
+ display: grid;
115
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
116
+ gap: 16px;
117
+ margin-bottom: 24px;
118
+ }}
119
+
120
+ /* Status Cards */
121
+ .status-card {{
122
+ background: var(--bg-secondary);
123
+ border: 1px solid var(--border-color);
124
+ border-radius: 12px;
125
+ padding: 20px;
126
+ }}
127
+ .status-card h3 {{
128
+ font-size: 0.75rem;
129
+ color: var(--text-muted);
130
+ text-transform: uppercase;
131
+ letter-spacing: 0.05em;
132
+ margin-bottom: 12px;
133
+ }}
134
+ .status-card .value {{
135
+ font-size: 1.5rem;
136
+ font-weight: 600;
137
+ font-family: "SF Mono", Monaco, monospace;
138
+ }}
139
+ .status-card .value.success {{ color: var(--success); }}
140
+ .status-card .value.warning {{ color: var(--warning); }}
141
+ .status-card .value.error {{ color: var(--error); }}
142
+ .status-card .value.accent {{ color: var(--accent); }}
143
+ .status-card .sub {{
144
+ font-size: 0.8rem;
145
+ color: var(--text-secondary);
146
+ margin-top: 4px;
147
+ }}
148
+
149
+ /* Progress Section */
150
+ .progress-section {{
151
+ background: var(--bg-secondary);
152
+ border: 1px solid var(--border-color);
153
+ border-radius: 12px;
154
+ padding: 20px;
155
+ margin-bottom: 24px;
156
+ }}
157
+ .progress-header {{
158
+ display: flex;
159
+ justify-content: space-between;
160
+ align-items: center;
161
+ margin-bottom: 16px;
162
+ }}
163
+ .progress-header h2 {{
164
+ font-size: 1.1rem;
165
+ font-weight: 600;
166
+ }}
167
+ .progress-header .operation-badge {{
168
+ padding: 4px 12px;
169
+ border-radius: 6px;
170
+ font-size: 0.75rem;
171
+ font-weight: 600;
172
+ text-transform: uppercase;
173
+ }}
174
+ .progress-header .operation-badge.running {{
175
+ background: rgba(0, 212, 170, 0.2);
176
+ color: var(--accent);
177
+ }}
178
+ .progress-header .operation-badge.idle {{
179
+ background: rgba(136, 136, 136, 0.2);
180
+ color: var(--text-secondary);
181
+ }}
182
+ .progress-header .operation-badge.complete {{
183
+ background: rgba(52, 211, 153, 0.2);
184
+ color: var(--success);
185
+ }}
186
+ .progress-header .operation-badge.failed {{
187
+ background: rgba(255, 95, 95, 0.2);
188
+ color: var(--error);
189
+ }}
190
+
191
+ .progress-bar-container {{
192
+ background: var(--bg-tertiary);
193
+ border-radius: 8px;
194
+ height: 24px;
195
+ overflow: hidden;
196
+ margin-bottom: 12px;
197
+ }}
198
+ .progress-bar {{
199
+ height: 100%;
200
+ background: linear-gradient(90deg, var(--accent) 0%, #00f5c4 100%);
201
+ border-radius: 8px;
202
+ transition: width 0.5s ease;
203
+ display: flex;
204
+ align-items: center;
205
+ justify-content: center;
206
+ }}
207
+ .progress-bar span {{
208
+ font-size: 0.75rem;
209
+ font-weight: 600;
210
+ color: var(--bg-primary);
211
+ }}
212
+ .progress-info {{
213
+ display: flex;
214
+ justify-content: space-between;
215
+ font-size: 0.85rem;
216
+ color: var(--text-secondary);
217
+ }}
218
+
219
+ /* VNC Button */
220
+ .vnc-button {{
221
+ display: inline-flex;
222
+ align-items: center;
223
+ gap: 8px;
224
+ padding: 12px 24px;
225
+ background: var(--accent);
226
+ color: var(--bg-primary);
227
+ border: none;
228
+ border-radius: 8px;
229
+ font-size: 0.9rem;
230
+ font-weight: 600;
231
+ cursor: pointer;
232
+ text-decoration: none;
233
+ transition: all 0.2s;
234
+ }}
235
+ .vnc-button:hover {{
236
+ background: #00f5c4;
237
+ transform: translateY(-1px);
238
+ }}
239
+ .vnc-button:disabled {{
240
+ background: var(--text-muted);
241
+ cursor: not-allowed;
242
+ transform: none;
243
+ }}
244
+
245
+ /* Log Viewer */
246
+ .log-section {{
247
+ background: var(--bg-secondary);
248
+ border: 1px solid var(--border-color);
249
+ border-radius: 12px;
250
+ overflow: hidden;
251
+ }}
252
+ .log-header {{
253
+ display: flex;
254
+ justify-content: space-between;
255
+ align-items: center;
256
+ padding: 12px 16px;
257
+ background: var(--bg-tertiary);
258
+ border-bottom: 1px solid var(--border-color);
259
+ }}
260
+ .log-header h3 {{
261
+ font-size: 0.85rem;
262
+ font-weight: 600;
263
+ }}
264
+ .log-controls {{
265
+ display: flex;
266
+ gap: 8px;
267
+ }}
268
+ .log-controls button {{
269
+ padding: 4px 12px;
270
+ border-radius: 4px;
271
+ border: 1px solid var(--border-color);
272
+ background: transparent;
273
+ color: var(--text-secondary);
274
+ font-size: 0.75rem;
275
+ cursor: pointer;
276
+ transition: all 0.2s;
277
+ }}
278
+ .log-controls button:hover {{
279
+ border-color: var(--accent);
280
+ color: var(--accent);
281
+ }}
282
+ .log-controls button.active {{
283
+ background: var(--accent);
284
+ color: var(--bg-primary);
285
+ border-color: var(--accent);
286
+ }}
287
+ .log-viewer {{
288
+ font-family: "SF Mono", Monaco, "Courier New", monospace;
289
+ font-size: 0.8rem;
290
+ line-height: 1.6;
291
+ padding: 16px;
292
+ background: #000;
293
+ color: #ccc;
294
+ height: 400px;
295
+ overflow-y: auto;
296
+ white-space: pre-wrap;
297
+ word-break: break-all;
298
+ }}
299
+ .log-viewer .log-line {{
300
+ padding: 2px 0;
301
+ }}
302
+ .log-viewer .log-line.error {{
303
+ color: var(--error);
304
+ }}
305
+ .log-viewer .log-line.success {{
306
+ color: var(--success);
307
+ }}
308
+ .log-viewer .log-line.warning {{
309
+ color: var(--warning);
310
+ }}
311
+ .log-viewer .log-line.step {{
312
+ color: var(--accent);
313
+ font-weight: bold;
314
+ }}
315
+
316
+ /* Error Banner */
317
+ .error-banner {{
318
+ background: rgba(255, 95, 95, 0.15);
319
+ border: 1px solid var(--error);
320
+ border-radius: 8px;
321
+ padding: 16px;
322
+ margin-bottom: 24px;
323
+ display: none;
324
+ }}
325
+ .error-banner.visible {{
326
+ display: block;
327
+ }}
328
+ .error-banner h4 {{
329
+ color: var(--error);
330
+ font-size: 0.9rem;
331
+ margin-bottom: 8px;
332
+ }}
333
+ .error-banner p {{
334
+ font-size: 0.85rem;
335
+ color: var(--text-secondary);
336
+ }}
337
+
338
+ /* VNC Embed Section */
339
+ .vnc-section {{
340
+ background: var(--bg-secondary);
341
+ border: 1px solid var(--border-color);
342
+ border-radius: 12px;
343
+ margin-bottom: 24px;
344
+ overflow: hidden;
345
+ }}
346
+ .vnc-header {{
347
+ display: flex;
348
+ justify-content: space-between;
349
+ align-items: center;
350
+ padding: 12px 16px;
351
+ background: var(--bg-tertiary);
352
+ border-bottom: 1px solid var(--border-color);
353
+ cursor: pointer;
354
+ user-select: none;
355
+ }}
356
+ .vnc-header:hover {{
357
+ background: rgba(26,26,36,0.9);
358
+ }}
359
+ .vnc-header h3 {{
360
+ font-size: 0.85rem;
361
+ font-weight: 600;
362
+ display: flex;
363
+ align-items: center;
364
+ gap: 8px;
365
+ }}
366
+ .vnc-header .toggle-icon {{
367
+ font-size: 0.7rem;
368
+ color: var(--text-muted);
369
+ transition: transform 0.2s;
370
+ }}
371
+ .vnc-header .toggle-icon.expanded {{
372
+ transform: rotate(90deg);
373
+ }}
374
+ .vnc-controls {{
375
+ display: flex;
376
+ gap: 8px;
377
+ align-items: center;
378
+ }}
379
+ .vnc-controls button {{
380
+ padding: 4px 12px;
381
+ border-radius: 4px;
382
+ border: 1px solid var(--border-color);
383
+ background: transparent;
384
+ color: var(--text-secondary);
385
+ font-size: 0.75rem;
386
+ cursor: pointer;
387
+ transition: all 0.2s;
388
+ }}
389
+ .vnc-controls button:hover {{
390
+ border-color: var(--accent);
391
+ color: var(--accent);
392
+ }}
393
+ .vnc-controls .size-select {{
394
+ padding: 4px 8px;
395
+ border-radius: 4px;
396
+ border: 1px solid var(--border-color);
397
+ background: var(--bg-secondary);
398
+ color: var(--text-secondary);
399
+ font-size: 0.75rem;
400
+ cursor: pointer;
401
+ }}
402
+ .vnc-container {{
403
+ display: none;
404
+ background: #000;
405
+ position: relative;
406
+ }}
407
+ .vnc-container.visible {{
408
+ display: block;
409
+ }}
410
+ .vnc-iframe {{
411
+ width: 100%;
412
+ border: none;
413
+ background: #000;
414
+ }}
415
+ .vnc-placeholder {{
416
+ display: flex;
417
+ flex-direction: column;
418
+ align-items: center;
419
+ justify-content: center;
420
+ height: 400px;
421
+ color: var(--text-muted);
422
+ gap: 12px;
423
+ }}
424
+ .vnc-placeholder .icon {{
425
+ font-size: 3rem;
426
+ opacity: 0.3;
427
+ }}
428
+ .vnc-placeholder .message {{
429
+ font-size: 0.9rem;
430
+ }}
431
+ .vnc-status {{
432
+ font-size: 0.75rem;
433
+ color: var(--text-muted);
434
+ }}
435
+ .vnc-status.connected {{
436
+ color: var(--success);
437
+ }}
438
+ .vnc-status.disconnected {{
439
+ color: var(--error);
440
+ }}
441
+
442
+ /* VM Info Row */
443
+ .vm-info {{
444
+ display: flex;
445
+ gap: 24px;
446
+ margin-bottom: 24px;
447
+ flex-wrap: wrap;
448
+ }}
449
+ .vm-info-item {{
450
+ display: flex;
451
+ align-items: center;
452
+ gap: 8px;
453
+ font-size: 0.85rem;
454
+ }}
455
+ .vm-info-item .label {{
456
+ color: var(--text-muted);
457
+ }}
458
+ .vm-info-item .value {{
459
+ color: var(--text-primary);
460
+ font-family: "SF Mono", Monaco, monospace;
461
+ }}
462
+ .vm-info-item .status-dot {{
463
+ width: 8px;
464
+ height: 8px;
465
+ border-radius: 50%;
466
+ background: var(--text-muted);
467
+ }}
468
+ .vm-info-item .status-dot.running {{
469
+ background: var(--success);
470
+ animation: pulse 2s infinite;
471
+ }}
472
+ @keyframes pulse {{
473
+ 0%, 100% {{ opacity: 1; }}
474
+ 50% {{ opacity: 0.5; }}
475
+ }}
476
+
477
+ /* Refresh indicator */
478
+ .refresh-indicator {{
479
+ font-size: 0.75rem;
480
+ color: var(--text-muted);
481
+ }}
482
+ .refresh-indicator.loading {{
483
+ color: var(--accent);
484
+ }}
485
+ </style>
486
+ </head>
487
+ <body>
488
+ <!-- Header -->
489
+ <div class="unified-header">
490
+ <div class="nav-tabs">
491
+ <a href="dashboard.html" class="nav-tab">Training</a>
492
+ <a href="viewer.html" class="nav-tab">Viewer</a>
493
+ <a href="benchmark.html" class="nav-tab">Benchmarks</a>
494
+ <a href="azure_ops.html" class="nav-tab active">Azure Ops</a>
495
+ </div>
496
+ <div class="refresh-indicator" id="refresh-indicator">Connecting...</div>
497
+ </div>
498
+
499
+ <div class="container">
500
+ <!-- Error Banner -->
501
+ <div class="error-banner" id="error-banner">
502
+ <h4>Operation Error</h4>
503
+ <p id="error-message"></p>
504
+ </div>
505
+
506
+ <!-- VM Info Row -->
507
+ <div class="vm-info">
508
+ <div class="vm-info-item">
509
+ <span class="status-dot" id="vm-status-dot"></span>
510
+ <span class="label">VM:</span>
511
+ <span class="value" id="vm-state">Unknown</span>
512
+ </div>
513
+ <div class="vm-info-item">
514
+ <span class="label">IP:</span>
515
+ <span class="value" id="vm-ip">-</span>
516
+ </div>
517
+ <div class="vm-info-item">
518
+ <span class="label">Size:</span>
519
+ <span class="value" id="vm-size">-</span>
520
+ </div>
521
+ <div class="vm-info-item">
522
+ <a href="http://localhost:8006" target="_blank" class="vnc-button" id="vnc-button" disabled>
523
+ Open VNC Desktop
524
+ </a>
525
+ </div>
526
+ </div>
527
+
528
+ <!-- Status Cards -->
529
+ <div class="status-grid">
530
+ <div class="status-card">
531
+ <h3>Running Cost</h3>
532
+ <div class="value accent" id="cost-value">$0.00</div>
533
+ <div class="sub" id="cost-rate">$0.00/hr</div>
534
+ </div>
535
+ <div class="status-card">
536
+ <h3>Elapsed Time</h3>
537
+ <div class="value" id="elapsed-value">0m 0s</div>
538
+ <div class="sub" id="started-at">Not started</div>
539
+ </div>
540
+ <div class="status-card">
541
+ <h3>ETA</h3>
542
+ <div class="value" id="eta-value">-</div>
543
+ <div class="sub" id="projected-cost">-</div>
544
+ </div>
545
+ </div>
546
+
547
+ <!-- Progress Section -->
548
+ <div class="progress-section">
549
+ <div class="progress-header">
550
+ <h2 id="operation-title">Waiting for operation...</h2>
551
+ <span class="operation-badge idle" id="operation-badge">Idle</span>
552
+ </div>
553
+ <div class="progress-bar-container">
554
+ <div class="progress-bar" id="progress-bar" style="width: 0%">
555
+ <span id="progress-text">0%</span>
556
+ </div>
557
+ </div>
558
+ <div class="progress-info">
559
+ <span id="phase-text">-</span>
560
+ <span id="step-text">Step 0 / 0</span>
561
+ </div>
562
+ </div>
563
+
564
+ <!-- VNC Embed Section -->
565
+ <div class="vnc-section">
566
+ <div class="vnc-header" onclick="toggleVNC()">
567
+ <h3>
568
+ <span class="toggle-icon" id="vnc-toggle-icon">&#9654;</span>
569
+ Windows VM Screen
570
+ <span class="vnc-status" id="vnc-status">Checking...</span>
571
+ </h3>
572
+ <div class="vnc-controls" onclick="event.stopPropagation()">
573
+ <select class="size-select" id="vnc-size" onchange="updateVNCSize()">
574
+ <option value="400">400px</option>
575
+ <option value="500">500px</option>
576
+ <option value="600" selected>600px</option>
577
+ <option value="800">800px</option>
578
+ <option value="1000">1000px</option>
579
+ </select>
580
+ <button onclick="refreshVNC()">Refresh</button>
581
+ <button onclick="openVNCExternal()">Open in New Tab</button>
582
+ </div>
583
+ </div>
584
+ <div class="vnc-container" id="vnc-container">
585
+ <div class="vnc-placeholder" id="vnc-placeholder">
586
+ <span class="icon">&#128421;</span>
587
+ <span class="message">VNC not available - VM may not be running</span>
588
+ <span class="message" style="font-size: 0.8rem; color: var(--text-muted);">Start the VM and ensure SSH tunnel is active (localhost:8006)</span>
589
+ </div>
590
+ <iframe
591
+ id="vnc-iframe"
592
+ class="vnc-iframe"
593
+ src=""
594
+ style="display: none; height: 600px;"
595
+ allow="clipboard-read; clipboard-write"
596
+ sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"
597
+ ></iframe>
598
+ </div>
599
+ </div>
600
+
601
+ <!-- Log Viewer -->
602
+ <div class="log-section">
603
+ <div class="log-header">
604
+ <h3>Live Logs</h3>
605
+ <div class="log-controls">
606
+ <button id="auto-scroll-btn" class="active" onclick="toggleAutoScroll()">Auto-scroll</button>
607
+ <button onclick="clearLogs()">Clear</button>
608
+ <button id="copy-logs-btn" onclick="copyLogs()">Copy Logs</button>
609
+ </div>
610
+ </div>
611
+ <div class="log-viewer" id="log-viewer">
612
+ <div class="log-line">Waiting for logs...</div>
613
+ </div>
614
+ </div>
615
+ </div>
616
+
617
+ <script>
618
+ let autoScroll = true;
619
+ let lastLogLength = 0;
620
+ let pollInterval = null;
621
+ let eventSource = null;
622
+ let useSSE = true; // Try SSE first, fallback to polling
623
+ // Note: elapsed_seconds and cost_usd are now computed server-side
624
+ // No client-side timer needed - server sends fresh values on each request
625
+
626
+ function formatDuration(seconds) {{
627
+ if (!seconds || seconds <= 0) return '-';
628
+ const h = Math.floor(seconds / 3600);
629
+ const m = Math.floor((seconds % 3600) / 60);
630
+ const s = Math.floor(seconds % 60);
631
+ if (h > 0) return `${{h}}h ${{m}}m ${{s}}s`;
632
+ if (m > 0) return `${{m}}m ${{s}}s`;
633
+ return `${{s}}s`;
634
+ }}
635
+
636
+ function formatCost(usd) {{
637
+ if (usd === null || usd === undefined) return '$0.00';
638
+ return `$${{usd.toFixed(2)}}`;
639
+ }}
640
+
641
+ // Note: Client-side timer removed. Server now computes elapsed_seconds
642
+ // and cost_usd fresh on every request, ensuring accuracy without
643
+ // client clock dependencies.
644
+
645
+ function classifyLogLine(line) {{
646
+ const lower = line.toLowerCase();
647
+ if (lower.includes('error') || lower.includes('failed') || lower.includes('exception')) {{
648
+ return 'error';
649
+ }}
650
+ if (lower.includes('success') || lower.includes('complete') || lower.includes('done')) {{
651
+ return 'success';
652
+ }}
653
+ if (lower.includes('warning') || lower.includes('warn')) {{
654
+ return 'warning';
655
+ }}
656
+ if (/step\\s+\\d+\\/\\d+/i.test(line)) {{
657
+ return 'step';
658
+ }}
659
+ return '';
660
+ }}
661
+
662
+ function updateUI(status) {{
663
+ // Server now sends pre-computed elapsed_seconds and cost_usd
664
+ // No client-side storage needed
665
+
666
+ // Operation badge
667
+ const badge = document.getElementById('operation-badge');
668
+ const operation = status.operation || 'idle';
669
+ badge.textContent = operation.replace(/_/g, ' ').toUpperCase();
670
+ badge.className = 'operation-badge ' + (
671
+ operation === 'idle' ? 'idle' :
672
+ operation === 'complete' ? 'complete' :
673
+ operation === 'failed' ? 'failed' : 'running'
674
+ );
675
+
676
+ // Operation title
677
+ const titles = {{
678
+ 'idle': 'Waiting for operation...',
679
+ 'vm_create': 'Creating Azure VM',
680
+ 'docker_install': 'Installing Docker',
681
+ 'docker_build': 'Building Docker Image',
682
+ 'windows_boot': 'Booting Windows VM',
683
+ 'benchmark': 'Running Benchmark',
684
+ 'complete': 'Operation Complete',
685
+ 'failed': 'Operation Failed'
686
+ }};
687
+ document.getElementById('operation-title').textContent = titles[operation] || `Running: ${{operation}}`;
688
+
689
+ // Progress bar
690
+ const pct = Math.min(100, Math.max(0, status.progress_pct || 0));
691
+ document.getElementById('progress-bar').style.width = `${{pct}}%`;
692
+ document.getElementById('progress-text').textContent = `${{pct.toFixed(0)}}%`;
693
+
694
+ // Phase and step
695
+ document.getElementById('phase-text').textContent = status.phase || '-';
696
+ document.getElementById('step-text').textContent =
697
+ status.total_steps > 0
698
+ ? `Step ${{status.step}} / ${{status.total_steps}}`
699
+ : '-';
700
+
701
+ // Cost
702
+ document.getElementById('cost-value').textContent = formatCost(status.cost_usd);
703
+ document.getElementById('cost-rate').textContent = `$${{(status.hourly_rate_usd || 0).toFixed(3)}}/hr`;
704
+
705
+ // Elapsed time
706
+ document.getElementById('elapsed-value').textContent = formatDuration(status.elapsed_seconds);
707
+ if (status.started_at) {{
708
+ const started = new Date(status.started_at);
709
+ document.getElementById('started-at').textContent = `Started: ${{started.toLocaleTimeString()}}`;
710
+ }} else {{
711
+ document.getElementById('started-at').textContent = 'Not started';
712
+ }}
713
+
714
+ // ETA
715
+ document.getElementById('eta-value').textContent = formatDuration(status.eta_seconds);
716
+ if (status.eta_seconds && status.hourly_rate_usd) {{
717
+ const projectedTotal = (status.elapsed_seconds + status.eta_seconds) / 3600 * status.hourly_rate_usd;
718
+ document.getElementById('projected-cost').textContent = `Projected total: $${{projectedTotal.toFixed(2)}}`;
719
+ }} else {{
720
+ document.getElementById('projected-cost').textContent = '-';
721
+ }}
722
+
723
+ // VM info
724
+ document.getElementById('vm-state').textContent = status.vm_state || 'unknown';
725
+ document.getElementById('vm-ip').textContent = status.vm_ip || '-';
726
+ document.getElementById('vm-size').textContent = status.vm_size || '-';
727
+
728
+ const statusDot = document.getElementById('vm-status-dot');
729
+ statusDot.className = 'status-dot' + (status.vm_state === 'running' ? ' running' : '');
730
+
731
+ // VNC button
732
+ const vncBtn = document.getElementById('vnc-button');
733
+ if (status.vnc_url && status.vm_state === 'running') {{
734
+ vncBtn.href = status.vnc_url;
735
+ vncBtn.removeAttribute('disabled');
736
+ }} else {{
737
+ vncBtn.setAttribute('disabled', 'true');
738
+ }}
739
+
740
+ // Update VNC embed status based on VM state
741
+ updateVNCFromVMState(status.vm_state, status.vnc_url);
742
+
743
+ // Error banner
744
+ const errorBanner = document.getElementById('error-banner');
745
+ const errorMsg = document.getElementById('error-message');
746
+ if (status.error) {{
747
+ errorBanner.classList.add('visible');
748
+ errorMsg.textContent = status.error;
749
+ }} else {{
750
+ errorBanner.classList.remove('visible');
751
+ }}
752
+
753
+ // Log viewer
754
+ const logViewer = document.getElementById('log-viewer');
755
+ const logs = status.log_tail || [];
756
+
757
+ if (logs.length !== lastLogLength) {{
758
+ lastLogLength = logs.length;
759
+ logViewer.innerHTML = logs.map(line => {{
760
+ const cls = classifyLogLine(line);
761
+ return `<div class="log-line ${{cls}}">${{escapeHtml(line)}}</div>`;
762
+ }}).join('') || '<div class="log-line">No logs yet...</div>';
763
+
764
+ if (autoScroll) {{
765
+ logViewer.scrollTop = logViewer.scrollHeight;
766
+ }}
767
+ }}
768
+ }}
769
+
770
+ function escapeHtml(text) {{
771
+ const div = document.createElement('div');
772
+ div.textContent = text;
773
+ return div.innerHTML;
774
+ }}
775
+
776
+ function toggleAutoScroll() {{
777
+ autoScroll = !autoScroll;
778
+ const btn = document.getElementById('auto-scroll-btn');
779
+ btn.classList.toggle('active', autoScroll);
780
+ }}
781
+
782
+ function clearLogs() {{
783
+ document.getElementById('log-viewer').innerHTML = '<div class="log-line">Logs cleared...</div>';
784
+ lastLogLength = 0;
785
+ }}
786
+
787
+ function copyLogs() {{
788
+ const logViewer = document.getElementById('log-viewer');
789
+ const logLines = logViewer.querySelectorAll('.log-line');
790
+ const text = Array.from(logLines).map(line => line.textContent).join('\\n');
791
+
792
+ navigator.clipboard.writeText(text).then(() => {{
793
+ const btn = document.getElementById('copy-logs-btn');
794
+ const originalText = btn.textContent;
795
+ btn.textContent = 'Copied!';
796
+ btn.classList.add('active');
797
+ setTimeout(() => {{
798
+ btn.textContent = originalText;
799
+ btn.classList.remove('active');
800
+ }}, 1500);
801
+ }}).catch(err => {{
802
+ console.error('Failed to copy logs:', err);
803
+ alert('Failed to copy logs to clipboard');
804
+ }});
805
+ }}
806
+
807
+ // VNC Embed functionality
808
+ const VNC_URL = 'http://localhost:8006';
809
+ let vncExpanded = false;
810
+ let vncAvailable = false;
811
+ let vncCheckInterval = null;
812
+
813
+ function toggleVNC() {{
814
+ vncExpanded = !vncExpanded;
815
+ const container = document.getElementById('vnc-container');
816
+ const toggleIcon = document.getElementById('vnc-toggle-icon');
817
+
818
+ if (vncExpanded) {{
819
+ container.classList.add('visible');
820
+ toggleIcon.classList.add('expanded');
821
+ // Check VNC availability and load if available
822
+ checkVNCAndLoad();
823
+ }} else {{
824
+ container.classList.remove('visible');
825
+ toggleIcon.classList.remove('expanded');
826
+ }}
827
+ }}
828
+
829
+ function updateVNCSize() {{
830
+ const sizeSelect = document.getElementById('vnc-size');
831
+ const iframe = document.getElementById('vnc-iframe');
832
+ iframe.style.height = sizeSelect.value + 'px';
833
+ }}
834
+
835
+ function refreshVNC() {{
836
+ const iframe = document.getElementById('vnc-iframe');
837
+ if (vncAvailable) {{
838
+ // Force reload by resetting src
839
+ const currentSrc = iframe.src;
840
+ iframe.src = '';
841
+ setTimeout(() => {{ iframe.src = currentSrc; }}, 100);
842
+ }} else {{
843
+ checkVNCAndLoad();
844
+ }}
845
+ }}
846
+
847
+ function openVNCExternal() {{
848
+ window.open(VNC_URL, '_blank');
849
+ }}
850
+
851
+ async function checkVNCAvailability() {{
852
+ try {{
853
+ // Try to fetch from VNC URL (this may fail due to CORS, but that's actually fine)
854
+ // We'll use a different approach - check if the server responds
855
+ const controller = new AbortController();
856
+ const timeoutId = setTimeout(() => controller.abort(), 3000);
857
+
858
+ // We can't directly check the VNC URL due to CORS, but we can check our backend
859
+ // which should know about tunnel status
860
+ const response = await fetch('/api/tunnels', {{ signal: controller.signal }});
861
+ clearTimeout(timeoutId);
862
+
863
+ if (response.ok) {{
864
+ const tunnels = await response.json();
865
+ // Check if VNC tunnel is active
866
+ if (tunnels.vnc && tunnels.vnc.active) {{
867
+ return true;
868
+ }}
869
+ }}
870
+ // Fallback: try to detect if noVNC is running by checking the iframe
871
+ // This is a bit hacky but works in practice
872
+ return false;
873
+ }} catch (e) {{
874
+ // If /api/tunnels doesn't exist, try direct check
875
+ // Create an img element to test if VNC server responds
876
+ return new Promise((resolve) => {{
877
+ const img = new Image();
878
+ img.onload = () => resolve(true);
879
+ img.onerror = () => {{
880
+ // Error could mean CORS (server is up) or actually down
881
+ // Try to load iframe anyway if we get an error (likely CORS)
882
+ resolve('maybe');
883
+ }};
884
+ img.src = VNC_URL + '/favicon.ico?' + Date.now();
885
+ setTimeout(() => resolve(false), 3000);
886
+ }});
887
+ }}
888
+ }}
889
+
890
+ async function checkVNCAndLoad() {{
891
+ const statusEl = document.getElementById('vnc-status');
892
+ const placeholder = document.getElementById('vnc-placeholder');
893
+ const iframe = document.getElementById('vnc-iframe');
894
+
895
+ statusEl.textContent = 'Checking...';
896
+ statusEl.className = 'vnc-status';
897
+
898
+ const available = await checkVNCAvailability();
899
+
900
+ if (available === true || available === 'maybe') {{
901
+ // VNC appears to be available, load the iframe
902
+ vncAvailable = true;
903
+ statusEl.textContent = 'Connected';
904
+ statusEl.className = 'vnc-status connected';
905
+ placeholder.style.display = 'none';
906
+ iframe.style.display = 'block';
907
+
908
+ // Only set src if not already set
909
+ if (!iframe.src || iframe.src === window.location.href) {{
910
+ iframe.src = VNC_URL;
911
+ }}
912
+
913
+ // Start periodic checks to update status
914
+ startVNCStatusCheck();
915
+ }} else {{
916
+ // VNC not available
917
+ vncAvailable = false;
918
+ statusEl.textContent = 'Disconnected';
919
+ statusEl.className = 'vnc-status disconnected';
920
+ placeholder.style.display = 'flex';
921
+ iframe.style.display = 'none';
922
+ iframe.src = '';
923
+
924
+ // Start periodic checks to detect when VNC becomes available
925
+ startVNCStatusCheck();
926
+ }}
927
+ }}
928
+
929
+ function startVNCStatusCheck() {{
930
+ // Only run checks if VNC section is expanded
931
+ if (vncCheckInterval) {{
932
+ clearInterval(vncCheckInterval);
933
+ }}
934
+ vncCheckInterval = setInterval(async () => {{
935
+ if (!vncExpanded) {{
936
+ clearInterval(vncCheckInterval);
937
+ vncCheckInterval = null;
938
+ return;
939
+ }}
940
+ await checkVNCAndLoad();
941
+ }}, 10000); // Check every 10 seconds
942
+ }}
943
+
944
+ // Also update VNC status when VM state changes
945
+ function updateVNCFromVMState(vmState, vncUrl) {{
946
+ const statusEl = document.getElementById('vnc-status');
947
+
948
+ if (vmState === 'running' && vncUrl) {{
949
+ if (!vncAvailable && vncExpanded) {{
950
+ // VM just came online, check VNC
951
+ checkVNCAndLoad();
952
+ }}
953
+ }} else if (vmState !== 'running') {{
954
+ // VM is not running, mark VNC as disconnected
955
+ vncAvailable = false;
956
+ statusEl.textContent = 'VM Offline';
957
+ statusEl.className = 'vnc-status disconnected';
958
+
959
+ if (vncExpanded) {{
960
+ const placeholder = document.getElementById('vnc-placeholder');
961
+ const iframe = document.getElementById('vnc-iframe');
962
+ placeholder.style.display = 'flex';
963
+ iframe.style.display = 'none';
964
+ }}
965
+ }}
966
+ }}
967
+
968
+ function updateIndicator(mode, extra) {{
969
+ const indicator = document.getElementById('refresh-indicator');
970
+ if (mode === 'sse') {{
971
+ indicator.textContent = 'Connected via SSE';
972
+ indicator.classList.remove('loading');
973
+ }} else if (mode === 'polling') {{
974
+ indicator.textContent = 'Polling every 2s';
975
+ indicator.classList.remove('loading');
976
+ }} else if (mode === 'connecting') {{
977
+ indicator.textContent = 'Connecting...';
978
+ indicator.classList.add('loading');
979
+ }} else if (mode === 'error') {{
980
+ indicator.textContent = `Error: ${{extra || 'unknown'}}`;
981
+ indicator.classList.remove('loading');
982
+ }}
983
+ }}
984
+
985
+ // SSE connection
986
+ function connectSSE() {{
987
+ if (eventSource) {{
988
+ eventSource.close();
989
+ }}
990
+
991
+ updateIndicator('connecting');
992
+
993
+ eventSource = new EventSource('/api/azure-ops-sse');
994
+
995
+ eventSource.addEventListener('connected', (event) => {{
996
+ console.log('SSE connected:', JSON.parse(event.data));
997
+ updateIndicator('sse');
998
+ }});
999
+
1000
+ eventSource.addEventListener('status', (event) => {{
1001
+ const status = JSON.parse(event.data);
1002
+ updateUI(status);
1003
+ }});
1004
+
1005
+ eventSource.addEventListener('heartbeat', (event) => {{
1006
+ // Keep-alive received, connection is healthy
1007
+ console.log('SSE heartbeat:', JSON.parse(event.data));
1008
+ }});
1009
+
1010
+ eventSource.addEventListener('error', (event) => {{
1011
+ if (event.data) {{
1012
+ const error = JSON.parse(event.data);
1013
+ console.error('SSE error event:', error);
1014
+ }}
1015
+ }});
1016
+
1017
+ eventSource.onerror = (event) => {{
1018
+ console.warn('SSE connection error, falling back to polling');
1019
+ eventSource.close();
1020
+ eventSource = null;
1021
+ useSSE = false;
1022
+ startPolling();
1023
+ }};
1024
+ }}
1025
+
1026
+ // Polling fallback
1027
+ async function fetchStatus() {{
1028
+ const indicator = document.getElementById('refresh-indicator');
1029
+ indicator.textContent = 'Refreshing...';
1030
+ indicator.classList.add('loading');
1031
+
1032
+ try {{
1033
+ const response = await fetch('/api/azure-ops-status');
1034
+ if (!response.ok) throw new Error(`HTTP ${{response.status}}`);
1035
+ const status = await response.json();
1036
+ updateUI(status);
1037
+ updateIndicator('polling');
1038
+ }} catch (error) {{
1039
+ console.error('Failed to fetch status:', error);
1040
+ updateIndicator('error', error.message);
1041
+ }}
1042
+ }}
1043
+
1044
+ function startPolling() {{
1045
+ updateIndicator('polling');
1046
+ fetchStatus(); // Initial fetch
1047
+ pollInterval = setInterval(fetchStatus, 2000); // Poll every 2 seconds
1048
+ }}
1049
+
1050
+ function stopPolling() {{
1051
+ if (pollInterval) {{
1052
+ clearInterval(pollInterval);
1053
+ pollInterval = null;
1054
+ }}
1055
+ }}
1056
+
1057
+ function stopSSE() {{
1058
+ if (eventSource) {{
1059
+ eventSource.close();
1060
+ eventSource = null;
1061
+ }}
1062
+ }}
1063
+
1064
+ // Initialize connection
1065
+ function initConnection() {{
1066
+ // Server computes elapsed_seconds/cost_usd fresh on each request
1067
+ // No client-side timer needed - just connect to data source
1068
+ if (useSSE && typeof EventSource !== 'undefined') {{
1069
+ connectSSE();
1070
+ }} else {{
1071
+ startPolling();
1072
+ }}
1073
+ }}
1074
+
1075
+ // Handle visibility changes
1076
+ document.addEventListener('visibilitychange', () => {{
1077
+ if (document.hidden) {{
1078
+ stopPolling();
1079
+ stopSSE();
1080
+ }} else {{
1081
+ initConnection();
1082
+ }}
1083
+ }});
1084
+
1085
+ // Initialize
1086
+ document.addEventListener('DOMContentLoaded', initConnection);
1087
+ </script>
1088
+ </body>
1089
+ </html>
1090
+ """
1091
+
1092
+ if output_path:
1093
+ output_path = Path(output_path)
1094
+ output_path.parent.mkdir(parents=True, exist_ok=True)
1095
+ output_path.write_text(html)
1096
+
1097
+ return html