openadapt-ml 0.2.0__py3-none-any.whl → 0.2.2__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.
- openadapt_ml/baselines/__init__.py +121 -0
- openadapt_ml/baselines/adapter.py +185 -0
- openadapt_ml/baselines/cli.py +314 -0
- openadapt_ml/baselines/config.py +448 -0
- openadapt_ml/baselines/parser.py +922 -0
- openadapt_ml/baselines/prompts.py +787 -0
- openadapt_ml/benchmarks/__init__.py +13 -115
- openadapt_ml/benchmarks/agent.py +265 -421
- openadapt_ml/benchmarks/azure.py +28 -19
- openadapt_ml/benchmarks/azure_ops_tracker.py +521 -0
- openadapt_ml/benchmarks/cli.py +1722 -4847
- openadapt_ml/benchmarks/trace_export.py +631 -0
- openadapt_ml/benchmarks/viewer.py +22 -5
- openadapt_ml/benchmarks/vm_monitor.py +530 -29
- openadapt_ml/benchmarks/waa_deploy/Dockerfile +47 -53
- openadapt_ml/benchmarks/waa_deploy/api_agent.py +21 -20
- openadapt_ml/cloud/azure_inference.py +3 -5
- openadapt_ml/cloud/lambda_labs.py +722 -307
- openadapt_ml/cloud/local.py +2038 -487
- openadapt_ml/cloud/ssh_tunnel.py +68 -26
- openadapt_ml/datasets/next_action.py +40 -30
- openadapt_ml/evals/grounding.py +8 -3
- openadapt_ml/evals/plot_eval_metrics.py +15 -13
- openadapt_ml/evals/trajectory_matching.py +41 -26
- openadapt_ml/experiments/demo_prompt/format_demo.py +16 -6
- openadapt_ml/experiments/demo_prompt/run_experiment.py +26 -16
- openadapt_ml/experiments/representation_shootout/__init__.py +70 -0
- openadapt_ml/experiments/representation_shootout/conditions.py +708 -0
- openadapt_ml/experiments/representation_shootout/config.py +390 -0
- openadapt_ml/experiments/representation_shootout/evaluator.py +659 -0
- openadapt_ml/experiments/representation_shootout/runner.py +687 -0
- openadapt_ml/experiments/waa_demo/runner.py +29 -14
- openadapt_ml/export/parquet.py +36 -24
- openadapt_ml/grounding/detector.py +18 -14
- openadapt_ml/ingest/__init__.py +8 -6
- openadapt_ml/ingest/capture.py +25 -22
- openadapt_ml/ingest/loader.py +7 -4
- openadapt_ml/ingest/synthetic.py +189 -100
- openadapt_ml/models/api_adapter.py +14 -4
- openadapt_ml/models/base_adapter.py +10 -2
- openadapt_ml/models/providers/__init__.py +288 -0
- openadapt_ml/models/providers/anthropic.py +266 -0
- openadapt_ml/models/providers/base.py +299 -0
- openadapt_ml/models/providers/google.py +376 -0
- openadapt_ml/models/providers/openai.py +342 -0
- openadapt_ml/models/qwen_vl.py +46 -19
- openadapt_ml/perception/__init__.py +35 -0
- openadapt_ml/perception/integration.py +399 -0
- openadapt_ml/retrieval/demo_retriever.py +50 -24
- openadapt_ml/retrieval/embeddings.py +9 -8
- openadapt_ml/retrieval/retriever.py +3 -1
- openadapt_ml/runtime/__init__.py +50 -0
- openadapt_ml/runtime/policy.py +18 -5
- openadapt_ml/runtime/safety_gate.py +471 -0
- openadapt_ml/schema/__init__.py +9 -0
- openadapt_ml/schema/converters.py +74 -27
- openadapt_ml/schema/episode.py +31 -18
- openadapt_ml/scripts/capture_screenshots.py +530 -0
- openadapt_ml/scripts/compare.py +85 -54
- openadapt_ml/scripts/demo_policy.py +4 -1
- openadapt_ml/scripts/eval_policy.py +15 -9
- openadapt_ml/scripts/make_gif.py +1 -1
- openadapt_ml/scripts/prepare_synthetic.py +3 -1
- openadapt_ml/scripts/train.py +21 -9
- openadapt_ml/segmentation/README.md +920 -0
- openadapt_ml/segmentation/__init__.py +97 -0
- openadapt_ml/segmentation/adapters/__init__.py +5 -0
- openadapt_ml/segmentation/adapters/capture_adapter.py +420 -0
- openadapt_ml/segmentation/annotator.py +610 -0
- openadapt_ml/segmentation/cache.py +290 -0
- openadapt_ml/segmentation/cli.py +674 -0
- openadapt_ml/segmentation/deduplicator.py +656 -0
- openadapt_ml/segmentation/frame_describer.py +788 -0
- openadapt_ml/segmentation/pipeline.py +340 -0
- openadapt_ml/segmentation/schemas.py +622 -0
- openadapt_ml/segmentation/segment_extractor.py +634 -0
- openadapt_ml/training/azure_ops_viewer.py +1097 -0
- openadapt_ml/training/benchmark_viewer.py +52 -41
- openadapt_ml/training/shared_ui.py +7 -7
- openadapt_ml/training/stub_provider.py +57 -35
- openadapt_ml/training/trainer.py +143 -86
- openadapt_ml/training/trl_trainer.py +70 -21
- openadapt_ml/training/viewer.py +323 -108
- openadapt_ml/training/viewer_components.py +180 -0
- {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.2.dist-info}/METADATA +215 -14
- openadapt_ml-0.2.2.dist-info/RECORD +116 -0
- openadapt_ml/benchmarks/base.py +0 -366
- openadapt_ml/benchmarks/data_collection.py +0 -432
- openadapt_ml/benchmarks/live_tracker.py +0 -180
- openadapt_ml/benchmarks/runner.py +0 -418
- openadapt_ml/benchmarks/waa.py +0 -761
- openadapt_ml/benchmarks/waa_live.py +0 -619
- openadapt_ml-0.2.0.dist-info/RECORD +0 -86
- {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.2.dist-info}/WHEEL +0 -0
- {openadapt_ml-0.2.0.dist-info → openadapt_ml-0.2.2.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">▶</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">🖥</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
|