devs-webadmin 2.0.12__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.
@@ -0,0 +1,755 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Devs Web Admin</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
8
+ <style>
9
+ * { box-sizing: border-box; margin: 0; padding: 0; }
10
+
11
+ body {
12
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
13
+ background: #0f172a;
14
+ color: #e2e8f0;
15
+ min-height: 100vh;
16
+ }
17
+
18
+ .app {
19
+ max-width: 960px;
20
+ margin: 0 auto;
21
+ padding: 2rem 1rem;
22
+ }
23
+
24
+ header {
25
+ display: flex;
26
+ align-items: center;
27
+ justify-content: space-between;
28
+ margin-bottom: 2rem;
29
+ }
30
+
31
+ header h1 {
32
+ font-size: 1.5rem;
33
+ font-weight: 600;
34
+ }
35
+
36
+ .refresh-btn {
37
+ background: none;
38
+ border: 1px solid #334155;
39
+ color: #94a3b8;
40
+ padding: 0.4rem 0.8rem;
41
+ border-radius: 6px;
42
+ cursor: pointer;
43
+ font-size: 0.85rem;
44
+ }
45
+ .refresh-btn:hover { border-color: #64748b; color: #e2e8f0; }
46
+
47
+ /* Start form */
48
+ .start-form {
49
+ background: #1e293b;
50
+ border: 1px solid #334155;
51
+ border-radius: 8px;
52
+ padding: 1.25rem;
53
+ margin-bottom: 2rem;
54
+ display: flex;
55
+ gap: 0.75rem;
56
+ align-items: flex-end;
57
+ flex-wrap: wrap;
58
+ }
59
+
60
+ .field {
61
+ display: flex;
62
+ flex-direction: column;
63
+ gap: 0.3rem;
64
+ flex: 1;
65
+ min-width: 180px;
66
+ }
67
+
68
+ .field label {
69
+ font-size: 0.75rem;
70
+ color: #94a3b8;
71
+ text-transform: uppercase;
72
+ letter-spacing: 0.05em;
73
+ }
74
+
75
+ .field input {
76
+ background: #0f172a;
77
+ border: 1px solid #334155;
78
+ color: #e2e8f0;
79
+ padding: 0.5rem 0.75rem;
80
+ border-radius: 6px;
81
+ font-size: 0.9rem;
82
+ }
83
+ .field input:focus {
84
+ outline: none;
85
+ border-color: #3b82f6;
86
+ }
87
+ .field input::placeholder { color: #475569; }
88
+
89
+ .btn {
90
+ padding: 0.5rem 1.25rem;
91
+ border: none;
92
+ border-radius: 6px;
93
+ font-size: 0.9rem;
94
+ cursor: pointer;
95
+ font-weight: 500;
96
+ white-space: nowrap;
97
+ }
98
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
99
+
100
+ .btn-primary { background: #3b82f6; color: #fff; }
101
+ .btn-primary:hover:not(:disabled) { background: #2563eb; }
102
+
103
+ .btn-danger { background: #ef4444; color: #fff; }
104
+ .btn-danger:hover:not(:disabled) { background: #dc2626; }
105
+
106
+ .btn-warning { background: #f59e0b; color: #000; }
107
+ .btn-warning:hover:not(:disabled) { background: #d97706; }
108
+
109
+ .btn-secondary { background: #6366f1; color: #fff; }
110
+ .btn-secondary:hover:not(:disabled) { background: #4f46e5; }
111
+
112
+ .btn-outline {
113
+ background: none;
114
+ border: 1px solid #475569;
115
+ color: #94a3b8;
116
+ }
117
+ .btn-outline:hover:not(:disabled) { border-color: #94a3b8; color: #e2e8f0; }
118
+
119
+ .btn-sm {
120
+ padding: 0.3rem 0.7rem;
121
+ font-size: 0.8rem;
122
+ }
123
+
124
+ /* Container list */
125
+ .section-title {
126
+ font-size: 1.1rem;
127
+ font-weight: 600;
128
+ margin-bottom: 1rem;
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 0.5rem;
132
+ }
133
+
134
+ .count-badge {
135
+ background: #334155;
136
+ padding: 0.15rem 0.5rem;
137
+ border-radius: 10px;
138
+ font-size: 0.75rem;
139
+ font-weight: 400;
140
+ }
141
+
142
+ .container-list {
143
+ display: flex;
144
+ flex-direction: column;
145
+ gap: 0.5rem;
146
+ }
147
+
148
+ .container-card {
149
+ background: #1e293b;
150
+ border: 1px solid #334155;
151
+ border-radius: 8px;
152
+ padding: 1rem 1.25rem;
153
+ }
154
+
155
+ .container-row {
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: space-between;
159
+ gap: 1rem;
160
+ }
161
+
162
+ .container-info {
163
+ display: flex;
164
+ flex-direction: column;
165
+ gap: 0.25rem;
166
+ min-width: 0;
167
+ flex: 1;
168
+ }
169
+
170
+ .container-name {
171
+ font-weight: 600;
172
+ font-size: 0.95rem;
173
+ }
174
+
175
+ .container-meta {
176
+ font-size: 0.8rem;
177
+ color: #94a3b8;
178
+ display: flex;
179
+ gap: 1rem;
180
+ flex-wrap: wrap;
181
+ }
182
+
183
+ .status-dot {
184
+ display: inline-block;
185
+ width: 8px;
186
+ height: 8px;
187
+ border-radius: 50%;
188
+ margin-right: 0.3rem;
189
+ }
190
+ .status-running { background: #22c55e; }
191
+ .status-exited { background: #ef4444; }
192
+ .status-other { background: #f59e0b; }
193
+
194
+ .container-actions {
195
+ display: flex;
196
+ gap: 0.4rem;
197
+ flex-shrink: 0;
198
+ }
199
+
200
+ .empty-state {
201
+ text-align: center;
202
+ padding: 3rem;
203
+ color: #64748b;
204
+ }
205
+
206
+ /* Tunnel panel */
207
+ .tunnel-panel {
208
+ margin-top: 0.75rem;
209
+ padding-top: 0.75rem;
210
+ border-top: 1px solid #334155;
211
+ }
212
+
213
+ .tunnel-row {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 0.75rem;
217
+ flex-wrap: wrap;
218
+ }
219
+
220
+ .tunnel-status {
221
+ font-size: 0.8rem;
222
+ color: #94a3b8;
223
+ }
224
+
225
+ .tunnel-link {
226
+ font-size: 0.8rem;
227
+ color: #60a5fa;
228
+ text-decoration: none;
229
+ }
230
+ .tunnel-link:hover { text-decoration: underline; }
231
+
232
+ /* Auth modal */
233
+ .modal-overlay {
234
+ position: fixed;
235
+ inset: 0;
236
+ background: rgba(0,0,0,0.6);
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ z-index: 200;
241
+ }
242
+
243
+ .modal {
244
+ background: #1e293b;
245
+ border: 1px solid #334155;
246
+ border-radius: 12px;
247
+ padding: 2rem;
248
+ max-width: 480px;
249
+ width: 90%;
250
+ }
251
+
252
+ .modal h2 {
253
+ font-size: 1.1rem;
254
+ margin-bottom: 1rem;
255
+ }
256
+
257
+ .modal p {
258
+ font-size: 0.9rem;
259
+ color: #94a3b8;
260
+ margin-bottom: 0.75rem;
261
+ line-height: 1.5;
262
+ }
263
+
264
+ .device-code {
265
+ font-family: monospace;
266
+ font-size: 1.4rem;
267
+ font-weight: 700;
268
+ letter-spacing: 0.1em;
269
+ color: #f0abfc;
270
+ background: #0f172a;
271
+ padding: 0.75rem 1.25rem;
272
+ border-radius: 8px;
273
+ text-align: center;
274
+ margin: 1rem 0;
275
+ user-select: all;
276
+ }
277
+
278
+ .modal-actions {
279
+ display: flex;
280
+ gap: 0.5rem;
281
+ margin-top: 1.25rem;
282
+ justify-content: flex-end;
283
+ }
284
+
285
+ /* Toast */
286
+ .toast {
287
+ position: fixed;
288
+ bottom: 1.5rem;
289
+ right: 1.5rem;
290
+ padding: 0.75rem 1.25rem;
291
+ border-radius: 8px;
292
+ font-size: 0.9rem;
293
+ z-index: 100;
294
+ animation: slideIn 0.2s ease;
295
+ }
296
+ .toast-success { background: #166534; color: #bbf7d0; }
297
+ .toast-error { background: #991b1b; color: #fecaca; }
298
+
299
+ @keyframes slideIn {
300
+ from { transform: translateY(1rem); opacity: 0; }
301
+ to { transform: translateY(0); opacity: 1; }
302
+ }
303
+
304
+ .spinner {
305
+ display: inline-block;
306
+ width: 14px;
307
+ height: 14px;
308
+ border: 2px solid rgba(255,255,255,0.3);
309
+ border-top-color: #fff;
310
+ border-radius: 50%;
311
+ animation: spin 0.6s linear infinite;
312
+ vertical-align: middle;
313
+ margin-right: 0.3rem;
314
+ }
315
+ @keyframes spin { to { transform: rotate(360deg); } }
316
+ </style>
317
+ </head>
318
+ <body>
319
+ <div id="app">
320
+ <div class="app">
321
+ <header>
322
+ <h1>Devs Web Admin</h1>
323
+ <button class="refresh-btn" @click="loadContainers" :disabled="loading">
324
+ {{ loading ? 'Loading...' : 'Refresh' }}
325
+ </button>
326
+ </header>
327
+
328
+ <!-- Start container form -->
329
+ <div class="start-form">
330
+ <div class="field">
331
+ <label>Repository (org/repo)</label>
332
+ <input v-model="startRepo" placeholder="e.g. ideonate/devs"
333
+ @keydown.enter="startContainer">
334
+ </div>
335
+ <div class="field">
336
+ <label>Dev Name</label>
337
+ <input v-model="startDevName" placeholder="e.g. sally"
338
+ @keydown.enter="startContainer">
339
+ </div>
340
+ <button class="btn btn-primary" @click="startContainer"
341
+ :disabled="!startRepo || !startDevName || starting">
342
+ <span v-if="starting" class="spinner"></span>
343
+ {{ starting ? 'Starting...' : 'Start' }}
344
+ </button>
345
+ </div>
346
+
347
+ <!-- Container list -->
348
+ <div class="section-title">
349
+ Containers
350
+ <span class="count-badge">{{ containers.length }}</span>
351
+ </div>
352
+
353
+ <div v-if="containers.length === 0 && !loading" class="empty-state">
354
+ No containers running. Start one above.
355
+ </div>
356
+
357
+ <div class="container-list">
358
+ <div v-for="c in containers" :key="c.container_id" class="container-card">
359
+ <div class="container-row">
360
+ <div class="container-info">
361
+ <div class="container-name">
362
+ <span class="status-dot"
363
+ :class="c.status === 'running' ? 'status-running' : c.status === 'exited' ? 'status-exited' : 'status-other'">
364
+ </span>
365
+ {{ c.dev_name }}
366
+ </div>
367
+ <div class="container-meta">
368
+ <span>{{ c.project_name }}</span>
369
+ <span>{{ c.status }}</span>
370
+ <span>{{ c.mode }}</span>
371
+ <span v-if="c.created">{{ formatDate(c.created) }}</span>
372
+ </div>
373
+ </div>
374
+ <div class="container-actions">
375
+ <button class="btn btn-warning btn-sm"
376
+ @click="stopContainer(c)"
377
+ :disabled="actionInProgress[c.container_id]">
378
+ Stop
379
+ </button>
380
+ <button class="btn btn-danger btn-sm"
381
+ @click="cleanContainer(c)"
382
+ :disabled="actionInProgress[c.container_id]">
383
+ Clean
384
+ </button>
385
+ </div>
386
+ </div>
387
+
388
+ <!-- Tunnel controls (only for running containers) -->
389
+ <div v-if="c.status === 'running'" class="tunnel-panel">
390
+ <div class="tunnel-row">
391
+ <span class="tunnel-status">
392
+ Tunnel:
393
+ <template v-if="tunnelState[c.name]?.running">running</template>
394
+ <template v-else>off</template>
395
+ </span>
396
+
397
+ <template v-if="tunnelState[c.name]?.running">
398
+ <a v-if="tunnelState[c.name]?.web_url"
399
+ :href="tunnelState[c.name].web_url"
400
+ target="_blank" class="tunnel-link">
401
+ Open in browser
402
+ </a>
403
+ <button class="btn btn-outline btn-sm"
404
+ @click="tunnelKill(c)"
405
+ :disabled="actionInProgress['tunnel-'+c.name]">
406
+ Kill tunnel
407
+ </button>
408
+ </template>
409
+
410
+ <template v-else>
411
+ <button class="btn btn-secondary btn-sm"
412
+ @click="tunnelStart(c)"
413
+ :disabled="actionInProgress['tunnel-'+c.name]">
414
+ <span v-if="actionInProgress['tunnel-'+c.name]" class="spinner"></span>
415
+ Start tunnel
416
+ </button>
417
+ <button class="btn btn-outline btn-sm"
418
+ @click="tunnelAuth(c)"
419
+ :disabled="actionInProgress['tunnel-'+c.name]">
420
+ Auth
421
+ </button>
422
+ </template>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ </div>
427
+
428
+ <!-- Auth modal -->
429
+ <div v-if="authModal" class="modal-overlay" @click.self="closeAuthModal">
430
+ <div class="modal">
431
+ <h2>Tunnel Authentication</h2>
432
+
433
+ <template v-if="authModal.status === 'starting'">
434
+ <p><span class="spinner"></span> Starting authentication...</p>
435
+ </template>
436
+
437
+ <template v-else-if="authModal.status === 'waiting_for_browser'">
438
+ <p>Open the link below and enter this code:</p>
439
+ <div class="device-code">{{ authModal.device_code }}</div>
440
+ <p>
441
+ <a :href="authModal.device_url" target="_blank" class="tunnel-link">
442
+ {{ authModal.device_url }}
443
+ </a>
444
+ </p>
445
+ <p style="color: #64748b; font-size: 0.8rem;">
446
+ <span class="spinner"></span>
447
+ Waiting for you to complete authentication in the browser...
448
+ </p>
449
+ </template>
450
+
451
+ <template v-else-if="authModal.status === 'authenticated'">
452
+ <p style="color: #bbf7d0;">Authentication successful! You can now start a tunnel.</p>
453
+ </template>
454
+
455
+ <template v-else-if="authModal.status === 'already_authenticated'">
456
+ <p style="color: #bbf7d0;">Already authenticated. You can start a tunnel.</p>
457
+ </template>
458
+
459
+ <template v-else>
460
+ <p style="color: #fecaca;">{{ authModal.message || 'Authentication failed.' }}</p>
461
+ </template>
462
+
463
+ <div class="modal-actions">
464
+ <button class="btn btn-outline btn-sm" @click="closeAuthModal">Close</button>
465
+ </div>
466
+ </div>
467
+ </div>
468
+
469
+ <!-- Toast -->
470
+ <div v-if="toast" class="toast" :class="'toast-' + toast.type">
471
+ {{ toast.message }}
472
+ </div>
473
+ </div>
474
+ </div>
475
+
476
+ <script>
477
+ const { createApp, ref, reactive, onMounted, onUnmounted } = Vue;
478
+
479
+ createApp({
480
+ setup() {
481
+ const containers = ref([]);
482
+ const loading = ref(false);
483
+ const starting = ref(false);
484
+ const startRepo = ref('');
485
+ const startDevName = ref('');
486
+ const toast = ref(null);
487
+ const actionInProgress = reactive({});
488
+ const tunnelState = reactive({}); // container_name -> { running, web_url, tunnel_name }
489
+ const authModal = ref(null);
490
+
491
+ let toastTimer = null;
492
+ let authPollTimer = null;
493
+
494
+ function showToast(message, type = 'success') {
495
+ if (toastTimer) clearTimeout(toastTimer);
496
+ toast.value = { message, type };
497
+ toastTimer = setTimeout(() => { toast.value = null; }, 3000);
498
+ }
499
+
500
+ async function loadContainers() {
501
+ loading.value = true;
502
+ try {
503
+ const res = await fetch('/api/containers');
504
+ if (!res.ok) throw new Error(await res.text());
505
+ const data = await res.json();
506
+ containers.value = data.containers;
507
+
508
+ // Check tunnel status for running containers
509
+ for (const c of data.containers) {
510
+ if (c.status === 'running') {
511
+ checkTunnelStatus(c.name);
512
+ }
513
+ }
514
+ } catch (e) {
515
+ showToast('Failed to load containers: ' + e.message, 'error');
516
+ } finally {
517
+ loading.value = false;
518
+ }
519
+ }
520
+
521
+ async function checkTunnelStatus(containerName) {
522
+ try {
523
+ const res = await fetch(`/api/tunnel/status?container_name=${encodeURIComponent(containerName)}`);
524
+ if (res.ok) {
525
+ const data = await res.json();
526
+ tunnelState[containerName] = {
527
+ running: data.running,
528
+ web_url: data.running ? `https://vscode.dev/tunnel/${data.tunnel_name}` : null,
529
+ tunnel_name: data.tunnel_name,
530
+ };
531
+ }
532
+ } catch (e) {
533
+ // Silently ignore status check failures
534
+ }
535
+ }
536
+
537
+ async function startContainer() {
538
+ if (!startRepo.value || !startDevName.value) return;
539
+ starting.value = true;
540
+ try {
541
+ const res = await fetch('/api/start', {
542
+ method: 'POST',
543
+ headers: { 'Content-Type': 'application/json' },
544
+ body: JSON.stringify({
545
+ repo: startRepo.value,
546
+ dev_name: startDevName.value,
547
+ }),
548
+ });
549
+ if (!res.ok) {
550
+ const err = await res.json();
551
+ throw new Error(err.detail || 'Start failed');
552
+ }
553
+ showToast(`Started ${startDevName.value} for ${startRepo.value}`);
554
+ startDevName.value = '';
555
+ await loadContainers();
556
+ } catch (e) {
557
+ showToast(e.message, 'error');
558
+ } finally {
559
+ starting.value = false;
560
+ }
561
+ }
562
+
563
+ async function stopContainer(c) {
564
+ actionInProgress[c.container_id] = true;
565
+ try {
566
+ const res = await fetch('/api/stop', {
567
+ method: 'POST',
568
+ headers: { 'Content-Type': 'application/json' },
569
+ body: JSON.stringify({ container_name: c.name }),
570
+ });
571
+ if (!res.ok) {
572
+ const err = await res.json();
573
+ throw new Error(err.detail || 'Stop failed');
574
+ }
575
+ showToast(`Stopped ${c.dev_name}`);
576
+ await loadContainers();
577
+ } catch (e) {
578
+ showToast(e.message, 'error');
579
+ } finally {
580
+ delete actionInProgress[c.container_id];
581
+ }
582
+ }
583
+
584
+ async function cleanContainer(c) {
585
+ actionInProgress[c.container_id] = true;
586
+ try {
587
+ const res = await fetch('/api/clean', {
588
+ method: 'POST',
589
+ headers: { 'Content-Type': 'application/json' },
590
+ body: JSON.stringify({ container_name: c.name }),
591
+ });
592
+ if (!res.ok) {
593
+ const err = await res.json();
594
+ throw new Error(err.detail || 'Clean failed');
595
+ }
596
+ showToast(`Cleaned ${c.dev_name}`);
597
+ await loadContainers();
598
+ } catch (e) {
599
+ showToast(e.message, 'error');
600
+ } finally {
601
+ delete actionInProgress[c.container_id];
602
+ }
603
+ }
604
+
605
+ // --- Tunnel ---
606
+
607
+ async function tunnelStart(c) {
608
+ const key = 'tunnel-' + c.name;
609
+ actionInProgress[key] = true;
610
+ try {
611
+ const res = await fetch('/api/tunnel/start', {
612
+ method: 'POST',
613
+ headers: { 'Content-Type': 'application/json' },
614
+ body: JSON.stringify({ container_name: c.name }),
615
+ });
616
+ if (!res.ok) {
617
+ const err = await res.json();
618
+ throw new Error(err.detail || 'Tunnel start failed');
619
+ }
620
+ const data = await res.json();
621
+
622
+ if (data.status === 'running') {
623
+ tunnelState[c.name] = {
624
+ running: true,
625
+ web_url: data.web_url,
626
+ tunnel_name: data.tunnel_name,
627
+ };
628
+ showToast(`Tunnel running for ${c.dev_name}`);
629
+ } else if (data.status === 'auth_required') {
630
+ showToast('Tunnel needs authentication - click Auth first', 'error');
631
+ } else {
632
+ showToast('Tunnel may still be starting, check status', 'error');
633
+ }
634
+ } catch (e) {
635
+ showToast(e.message, 'error');
636
+ } finally {
637
+ delete actionInProgress[key];
638
+ }
639
+ }
640
+
641
+ async function tunnelKill(c) {
642
+ const key = 'tunnel-' + c.name;
643
+ actionInProgress[key] = true;
644
+ try {
645
+ const res = await fetch('/api/tunnel/kill', {
646
+ method: 'POST',
647
+ headers: { 'Content-Type': 'application/json' },
648
+ body: JSON.stringify({ container_name: c.name }),
649
+ });
650
+ if (res.ok) {
651
+ delete tunnelState[c.name];
652
+ showToast(`Tunnel killed for ${c.dev_name}`);
653
+ }
654
+ } catch (e) {
655
+ showToast(e.message, 'error');
656
+ } finally {
657
+ delete actionInProgress[key];
658
+ }
659
+ }
660
+
661
+ async function tunnelAuth(c) {
662
+ const key = 'tunnel-' + c.name;
663
+ actionInProgress[key] = true;
664
+ authModal.value = { status: 'starting', containerName: c.name };
665
+
666
+ try {
667
+ const res = await fetch('/api/tunnel/auth', {
668
+ method: 'POST',
669
+ headers: { 'Content-Type': 'application/json' },
670
+ body: JSON.stringify({ container_name: c.name }),
671
+ });
672
+ if (!res.ok) {
673
+ const err = await res.json();
674
+ throw new Error(err.detail || 'Auth failed');
675
+ }
676
+ const data = await res.json();
677
+ authModal.value = { ...data, containerName: c.name };
678
+
679
+ if (data.status === 'waiting_for_browser') {
680
+ // Poll for completion
681
+ startAuthPoll(c.name);
682
+ }
683
+ } catch (e) {
684
+ authModal.value = { status: 'error', message: e.message };
685
+ } finally {
686
+ delete actionInProgress[key];
687
+ }
688
+ }
689
+
690
+ function startAuthPoll(containerName) {
691
+ if (authPollTimer) clearInterval(authPollTimer);
692
+ authPollTimer = setInterval(async () => {
693
+ try {
694
+ const res = await fetch(
695
+ `/api/tunnel/auth/status?container_name=${encodeURIComponent(containerName)}`
696
+ );
697
+ if (!res.ok) return;
698
+ const data = await res.json();
699
+
700
+ if (data.status === 'authenticated') {
701
+ authModal.value = { status: 'authenticated', containerName };
702
+ stopAuthPoll();
703
+ showToast('Tunnel authenticated!');
704
+ } else if (data.status === 'failed' || data.status === 'no_pending_auth') {
705
+ if (authModal.value?.status === 'waiting_for_browser') {
706
+ authModal.value = {
707
+ status: 'error',
708
+ message: data.message || 'Auth session ended',
709
+ containerName,
710
+ };
711
+ }
712
+ stopAuthPoll();
713
+ }
714
+ } catch (e) {
715
+ // ignore poll errors
716
+ }
717
+ }, 2000);
718
+ }
719
+
720
+ function stopAuthPoll() {
721
+ if (authPollTimer) {
722
+ clearInterval(authPollTimer);
723
+ authPollTimer = null;
724
+ }
725
+ }
726
+
727
+ function closeAuthModal() {
728
+ stopAuthPoll();
729
+ authModal.value = null;
730
+ }
731
+
732
+ function formatDate(iso) {
733
+ if (!iso) return '';
734
+ const d = new Date(iso);
735
+ return d.toLocaleString();
736
+ }
737
+
738
+ onMounted(() => { loadContainers(); });
739
+ onUnmounted(() => { stopAuthPoll(); });
740
+
741
+ return {
742
+ containers, loading, starting,
743
+ startRepo, startDevName,
744
+ toast, actionInProgress,
745
+ tunnelState, authModal,
746
+ loadContainers, startContainer,
747
+ stopContainer, cleanContainer,
748
+ tunnelStart, tunnelKill, tunnelAuth,
749
+ closeAuthModal, formatDate,
750
+ };
751
+ }
752
+ }).mount('#app');
753
+ </script>
754
+ </body>
755
+ </html>