Gaston895 commited on
Commit
2432a32
Β·
1 Parent(s): 680e518

feat: SSE live stream + URL heartbeat QR codes update in real-time without page reload

Browse files
Files changed (2) hide show
  1. app.py +93 -18
  2. templates/ar_dashboard.html +239 -156
app.py CHANGED
@@ -1,21 +1,46 @@
1
- from flask import Flask, render_template, request, jsonify
2
  from flask_cors import CORS
3
  from datetime import datetime
4
  import os
 
 
 
5
 
6
  app = Flask(__name__)
7
  CORS(app)
8
 
9
- # In-memory store for the frontend base URL.
10
- # The Cloudflare Worker calls /api/update-urls whenever the frontend URL changes.
 
 
11
  current_urls = {
12
  'base_url': '',
13
  'last_updated': '',
14
  'portals': []
15
  }
16
 
17
- # All AEGIS analysis windows + their metadata.
18
- # Paths MUST match the React Router routes defined in frontend/src/App.tsx.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  WINDOW_DEFINITIONS = [
20
  {
21
  'window': 1,
@@ -135,19 +160,17 @@ def index():
135
 
136
  @app.route('/ping', methods=['GET'])
137
  def ping():
138
- """UptimeRobot keep-alive β€” returns 200 instantly, no heavy work."""
139
  return jsonify({'status': 'ok', 'message': 'AR Space is alive'}), 200
140
 
141
 
142
  @app.route('/health')
143
  def health():
144
- """Health check."""
145
  return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()})
146
 
147
 
148
  @app.route('/api/status')
149
  def status():
150
- """Current AR system status β€” called by the worker."""
151
  return jsonify({
152
  'status': 'online',
153
  'base_url': current_urls.get('base_url', ''),
@@ -160,15 +183,15 @@ def status():
160
  @app.route('/api/update-urls', methods=['POST'])
161
  def update_urls():
162
  """
163
- Called by the Cloudflare Worker (or directly) to register the frontend
164
- base URL. Once set, /api/qr-data returns ready-to-use QR links.
165
  """
166
  try:
167
  data = request.get_json()
168
  if not data or 'base_url' not in data:
169
  return jsonify({'error': 'base_url is required'}), 400
170
 
171
- base_url = data['base_url'].rstrip('/')
172
  portals = build_portals(base_url)
173
  timestamp = datetime.now().isoformat()
174
 
@@ -178,6 +201,13 @@ def update_urls():
178
  'portals': portals,
179
  })
180
 
 
 
 
 
 
 
 
181
  return jsonify({
182
  'success': True,
183
  'message': 'URLs updated successfully',
@@ -190,13 +220,59 @@ def update_urls():
190
  return jsonify({'error': str(e)}), 500
191
 
192
 
193
- @app.route('/api/qr-data', methods=['GET'])
194
- def qr_data():
195
  """
196
- Returns all window URLs ready for QR-code generation.
197
- Window 11 frontend calls this via the worker proxy to render the QR grid.
198
- Optional ?window=N to get a single window's data.
 
199
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  window_param = request.args.get('window')
201
  base_url = current_urls.get('base_url', '')
202
 
@@ -230,7 +306,6 @@ def qr_data():
230
 
231
  @app.route('/api/windows', methods=['GET'])
232
  def windows():
233
- """Static list of all window definitions (no base_url needed)."""
234
  return jsonify({
235
  'success': True,
236
  'windows': WINDOW_DEFINITIONS,
@@ -240,4 +315,4 @@ def windows():
240
 
241
  if __name__ == '__main__':
242
  port = int(os.environ.get('PORT', 7860))
243
- app.run(host='0.0.0.0', port=port, debug=False)
 
1
+ from flask import Flask, render_template, request, jsonify, Response, stream_with_context
2
  from flask_cors import CORS
3
  from datetime import datetime
4
  import os
5
+ import json
6
+ import time
7
+ import threading
8
 
9
  app = Flask(__name__)
10
  CORS(app)
11
 
12
+ # ── In-memory state ───────────────────────────────────────────────────────────
13
+ # The Cloudflare Worker calls /api/update-urls whenever the Electron app
14
+ # re-registers its cloudflared tunnel URL (every 1 s for the first 30 s,
15
+ # then every 10 s as a slow heartbeat).
16
  current_urls = {
17
  'base_url': '',
18
  'last_updated': '',
19
  'portals': []
20
  }
21
 
22
+ # SSE subscriber queues β€” each connected browser gets its own queue.
23
+ # When /api/update-urls is called, we push the new data to all queues.
24
+ _sse_lock = threading.Lock()
25
+ _sse_clients: list = [] # list of threading.Queue
26
+
27
+
28
+ def _broadcast_sse(data: dict):
29
+ """Push a JSON payload to every connected SSE client."""
30
+ msg = f"data: {json.dumps(data)}\n\n"
31
+ with _sse_lock:
32
+ dead = []
33
+ for q in _sse_clients:
34
+ try:
35
+ q.put_nowait(msg)
36
+ except Exception:
37
+ dead.append(q)
38
+ for q in dead:
39
+ _sse_clients.remove(q)
40
+
41
+
42
+ # ── Window definitions ────────────────────────────────────────────────────────
43
+ # Paths MUST match the React Router routes in frontend/src/App.tsx
44
  WINDOW_DEFINITIONS = [
45
  {
46
  'window': 1,
 
160
 
161
  @app.route('/ping', methods=['GET'])
162
  def ping():
163
+ """UptimeRobot keep-alive."""
164
  return jsonify({'status': 'ok', 'message': 'AR Space is alive'}), 200
165
 
166
 
167
  @app.route('/health')
168
  def health():
 
169
  return jsonify({'status': 'healthy', 'timestamp': datetime.now().isoformat()})
170
 
171
 
172
  @app.route('/api/status')
173
  def status():
 
174
  return jsonify({
175
  'status': 'online',
176
  'base_url': current_urls.get('base_url', ''),
 
183
  @app.route('/api/update-urls', methods=['POST'])
184
  def update_urls():
185
  """
186
+ Called by the Cloudflare Worker every ~1 s (burst) then every 10 s.
187
+ Updates in-memory state and pushes to all SSE subscribers instantly.
188
  """
189
  try:
190
  data = request.get_json()
191
  if not data or 'base_url' not in data:
192
  return jsonify({'error': 'base_url is required'}), 400
193
 
194
+ base_url = data['base_url'].rstrip('/')
195
  portals = build_portals(base_url)
196
  timestamp = datetime.now().isoformat()
197
 
 
201
  'portals': portals,
202
  })
203
 
204
+ # Push to all connected SSE clients immediately
205
+ _broadcast_sse({
206
+ 'base_url': base_url,
207
+ 'timestamp': timestamp,
208
+ 'portals': portals,
209
+ })
210
+
211
  return jsonify({
212
  'success': True,
213
  'message': 'URLs updated successfully',
 
220
  return jsonify({'error': str(e)}), 500
221
 
222
 
223
+ @app.route('/api/stream')
224
+ def sse_stream():
225
  """
226
+ Server-Sent Events endpoint.
227
+ The dashboard connects here and receives live updates whenever
228
+ /api/update-urls is called (i.e. every 1 s during the 30 s burst
229
+ and every 10 s after that).
230
  """
231
+ import queue as _queue
232
+
233
+ q = _queue.Queue(maxsize=50)
234
+ with _sse_lock:
235
+ _sse_clients.append(q)
236
+
237
+ # Send the current state immediately on connect so the page
238
+ # renders QR codes even if no new update arrives yet.
239
+ if current_urls.get('base_url'):
240
+ initial = json.dumps({
241
+ 'base_url': current_urls['base_url'],
242
+ 'timestamp': current_urls['last_updated'],
243
+ 'portals': current_urls['portals'],
244
+ })
245
+ q.put_nowait(f"data: {initial}\n\n")
246
+
247
+ def generate():
248
+ try:
249
+ while True:
250
+ try:
251
+ msg = q.get(timeout=25)
252
+ yield msg
253
+ except Exception:
254
+ # Send keep-alive comment every 25 s to prevent proxy timeouts
255
+ yield ": keepalive\n\n"
256
+ except GeneratorExit:
257
+ pass
258
+ finally:
259
+ with _sse_lock:
260
+ if q in _sse_clients:
261
+ _sse_clients.remove(q)
262
+
263
+ return Response(
264
+ stream_with_context(generate()),
265
+ mimetype='text/event-stream',
266
+ headers={
267
+ 'Cache-Control': 'no-cache',
268
+ 'X-Accel-Buffering': 'no', # nginx: disable buffering
269
+ 'Connection': 'keep-alive',
270
+ }
271
+ )
272
+
273
+
274
+ @app.route('/api/qr-data', methods=['GET'])
275
+ def qr_data():
276
  window_param = request.args.get('window')
277
  base_url = current_urls.get('base_url', '')
278
 
 
306
 
307
  @app.route('/api/windows', methods=['GET'])
308
  def windows():
 
309
  return jsonify({
310
  'success': True,
311
  'windows': WINDOW_DEFINITIONS,
 
315
 
316
  if __name__ == '__main__':
317
  port = int(os.environ.get('PORT', 7860))
318
+ app.run(host='0.0.0.0', port=port, debug=False, threaded=True)
templates/ar_dashboard.html CHANGED
@@ -5,9 +5,7 @@
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>AEGIS BIO DIGITAL LAB β€” AR Portal</title>
7
 
8
- <!-- QR Code generator (no external dependency for QR, uses qrcode.js CDN) -->
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
10
- <!-- Lucide icons -->
11
  <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
12
 
13
  <style>
@@ -20,7 +18,7 @@
20
  min-height: 100vh;
21
  }
22
 
23
- /* ── Header ─────────────────────────────────────────────────────── */
24
  .header {
25
  background: rgba(0,0,0,.5);
26
  padding: 1.25rem 2rem;
@@ -35,172 +33,167 @@
35
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
36
  background-clip: text;
37
  }
38
- .header .subtitle { font-size: .95rem; opacity: .7; margin-top: .25rem; }
39
-
40
  .status-bar {
41
  display: flex; justify-content: center; gap: 2rem;
42
- margin-top: .75rem; flex-wrap: wrap;
43
  }
44
- .status-item { display: flex; align-items: center; gap: .4rem; font-size: .85rem; opacity: .8; }
45
  .online { color: #4caf50; }
46
- .offline { color: #f44336; }
 
 
 
 
 
 
 
 
 
 
47
 
48
- /* ── Base URL banner ────────────────────────────────────────────── */
49
  .url-banner {
50
- margin: 1rem 2rem;
51
- padding: .75rem 1.25rem;
52
- border-radius: 10px;
53
- border: 1px solid rgba(0,200,255,.25);
54
- background: rgba(0,0,0,.35);
55
- display: flex; align-items: center; gap: .75rem;
56
- font-size: .85rem; word-break: break-all;
57
  }
58
  .url-banner .label { color: #00d4ff; white-space: nowrap; font-weight: 600; }
59
- .url-banner .value { opacity: .85; }
60
  .url-banner.no-url { border-color: rgba(255,165,0,.3); }
61
  .url-banner.no-url .label { color: orange; }
62
 
63
- /* ── Grid ───────────────────────────────────────────────────────── */
64
- .main { padding: 1.5rem 2rem; max-width: 1400px; margin: 0 auto; }
65
-
66
  .portals-grid {
67
  display: grid;
68
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
69
- gap: 1.25rem;
70
  }
71
 
72
- /* ── Portal card ─────────────────────────────────────────────────── */
73
  .portal-card {
74
  background: rgba(0,0,0,.45);
75
- border: 1px solid rgba(0,200,255,.2);
76
- border-radius: 14px;
77
- padding: 1.25rem;
78
- display: flex; flex-direction: column; align-items: center;
79
- gap: .75rem;
80
- transition: transform .25s, border-color .25s, box-shadow .25s;
81
  backdrop-filter: blur(8px);
82
  }
83
  .portal-card:hover {
84
- transform: translateY(-4px);
85
- border-color: rgba(0,200,255,.55);
86
- box-shadow: 0 8px 28px rgba(0,180,255,.2);
87
  }
88
-
89
  .portal-icon {
90
- width: 48px; height: 48px; border-radius: 10px;
91
  display: flex; align-items: center; justify-content: center;
92
- flex-shrink: 0;
93
  }
94
- .portal-icon svg { width: 22px; height: 22px; color: white; }
95
-
96
  .portal-info { text-align: center; width: 100%; }
97
- .portal-info h3 { font-size: 1rem; margin-bottom: .3rem; color: #d0eeff; }
98
- .portal-info .win-badge {
99
- display: inline-block; font-size: .7rem; color: #00d4ff;
100
- border: 1px solid rgba(0,212,255,.35); border-radius: 20px;
101
- padding: .1rem .5rem; margin-bottom: .4rem;
102
  }
103
- .portal-info p { font-size: .8rem; opacity: .7; line-height: 1.4; }
104
 
105
- /* QR code box */
106
  .qr-wrap {
107
- background: white; border-radius: 8px;
108
- padding: 6px; display: inline-block;
109
  box-shadow: 0 2px 10px rgba(0,0,0,.4);
 
 
 
 
 
110
  }
111
- .qr-wrap canvas, .qr-wrap img { display: block; }
112
 
113
  .launch-btn {
114
- display: inline-flex; align-items: center; gap: .4rem;
115
- padding: .45rem 1rem;
116
- background: rgba(0,200,255,.15);
117
- border: 1px solid rgba(0,200,255,.35);
118
- border-radius: 20px;
119
- color: #00d4ff; font-size: .82rem; font-weight: 500;
120
- text-decoration: none;
121
- transition: background .2s, color .2s;
122
- cursor: pointer;
123
  }
124
- .launch-btn:hover { background: rgba(0,200,255,.3); color: white; }
125
- .launch-btn.disabled { opacity: .4; cursor: not-allowed; pointer-events: none; }
126
-
127
- /* ── Color classes ───────────────────────────────────────────────── */
128
- .color-blue { background: linear-gradient(135deg,#2196f3,#1565c0); }
129
- .color-purple { background: linear-gradient(135deg,#9c27b0,#6a1b9a); }
130
- .color-yellow { background: linear-gradient(135deg,#ff9800,#e65100); }
131
- .color-red { background: linear-gradient(135deg,#f44336,#b71c1c); }
132
- .color-cyan { background: linear-gradient(135deg,#00bcd4,#006064); }
133
- .color-orange { background: linear-gradient(135deg,#ff5722,#bf360c); }
134
- .color-green { background: linear-gradient(135deg,#4caf50,#1b5e20); }
135
- .color-pink { background: linear-gradient(135deg,#e91e63,#880e4f); }
136
- .color-indigo { background: linear-gradient(135deg,#3f51b5,#1a237e); }
137
- .color-teal { background: linear-gradient(135deg,#009688,#004d40); }
138
-
139
- /* ── No-URL placeholder ─────────────────────────────────────────── */
140
- .no-url-msg {
141
- text-align: center; padding: 3rem 1rem; opacity: .6;
142
- grid-column: 1/-1;
143
  }
144
- .no-url-msg h3 { font-size: 1.3rem; color: orange; margin-bottom: .75rem; }
145
- .no-url-msg p { font-size: .9rem; line-height: 1.6; }
146
 
147
- /* ── Footer ─────────────────────────────────────────────────────── */
148
  footer {
149
- text-align: center; padding: 2rem;
150
- border-top: 1px solid rgba(0,200,255,.2);
151
- font-size: .8rem; opacity: .55; margin-top: 2rem;
152
  }
153
 
154
- /* ── Responsive ─────────────────────────────────────────────────── */
155
  @media (max-width: 600px) {
156
  .portals-grid { grid-template-columns: 1fr; }
157
  .header h1 { font-size: 1.4rem; }
158
- .status-bar { gap: 1rem; }
159
  }
160
  </style>
161
  </head>
162
  <body>
163
 
164
- <!-- ── Header ───────────────────────────────────────────────────────────── -->
165
  <header class="header">
166
  <h1>AEGIS BIO DIGITAL LAB</h1>
167
- <div class="subtitle">AUGMENTED REALITY PORTAL β€” QR ACCESS DASHBOARD</div>
168
  <div class="status-bar">
169
- <div class="status-item online">
170
- <i data-lucide="radio" style="width:14px;height:14px;"></i>
171
- <span>AR Space Online</span>
172
  </div>
173
  <div class="status-item">
174
- <i data-lucide="clock" style="width:14px;height:14px;"></i>
175
- <span id="ts">{{ last_updated or 'Not yet synced' }}</span>
176
  </div>
177
  <div class="status-item">
178
- <i data-lucide="layout-grid" style="width:14px;height:14px;"></i>
179
- <span>{{ portals|length }} windows</span>
180
  </div>
181
  </div>
182
  </header>
183
 
184
- <!-- ── Base URL banner ───────────────────────────────────────────────────── -->
185
- {% if base_url %}
186
- <div class="url-banner">
187
- <span class="label"><i data-lucide="link" style="width:14px;height:14px;vertical-align:middle;"></i> Base URL:</span>
188
- <span class="value">{{ base_url }}</span>
189
- </div>
190
- {% else %}
191
- <div class="url-banner no-url">
192
- <span class="label">⚠ Base URL not set.</span>
193
- <span class="value">Start the Electron app β€” cloudflared will register the tunnel URL automatically.</span>
194
  </div>
195
- {% endif %}
196
 
197
- <!-- ── Portal grid ───────────────────────────────────────────────────────── -->
198
  <main class="main">
199
  <div class="portals-grid" id="grid">
200
-
201
  {% if portals %}
202
  {% for p in portals %}
203
- <div class="portal-card">
204
  <div class="portal-icon color-{{ p.color }}">
205
  <i data-lucide="{{ p.icon }}"></i>
206
  </div>
@@ -209,76 +202,166 @@
209
  <h3>{{ p.name }}</h3>
210
  <p>{{ p.description }}</p>
211
  </div>
212
- <!-- QR code rendered by JS below -->
213
  <div class="qr-wrap" id="qr-{{ p.window }}"></div>
214
  <a class="launch-btn" href="{{ p.url }}" target="_blank" rel="noopener noreferrer">
215
- <i data-lucide="external-link" style="width:13px;height:13px;"></i>
216
  Open Window {{ p.window }}
217
  </a>
218
  </div>
219
  {% endfor %}
220
  {% else %}
221
- <div class="no-url-msg">
222
- <h3>⚠ No URLs configured yet</h3>
223
- <p>
224
- Start the Electron app β€” cloudflared will create a public tunnel and
225
- automatically POST the URL to the Cloudflare Worker, which forwards it
226
- here. Refresh this page once the tunnel is up.
227
- </p>
228
  </div>
229
  {% endif %}
230
-
231
  </div>
232
  </main>
233
 
234
- <footer>
235
- <p>AEGIS Bio Digital Lab 10 Β· AR Portal Β· Powered by Cloudflare Quick Tunnels</p>
236
- </footer>
237
 
238
  <script>
239
- // ── Lucide icons ────────────────────────────────────────────────────────
240
- lucide.createIcons();
241
 
242
- // ── Inject portal URLs from Jinja so JS can read them ──────────────────
243
- const PORTALS = {{ portals | tojson }};
244
 
245
- // ── Render QR codes ─────────────────────────────────────────────────────
246
- PORTALS.forEach(function(p) {
247
- const el = document.getElementById('qr-' + p.window);
248
- if (!el || !p.url) return;
249
- try {
250
- new QRCode(el, {
251
- text: p.url,
252
- width: 140,
253
- height: 140,
254
- colorDark: '#000000',
255
- colorLight: '#ffffff',
256
- correctLevel: QRCode.CorrectLevel.M,
257
- });
258
- } catch(e) {
259
- el.textContent = p.url;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  }
 
 
 
261
  });
 
262
 
263
- // ── Live clock ───────────────────────────────────────────────────────────
264
- function tick() {
265
- const el = document.getElementById('ts');
266
- if (el && !el.dataset.server) {
267
- el.textContent = new Date().toLocaleString();
268
- }
269
- }
270
- {% if last_updated %}
271
- // Keep the server timestamp for first render, then switch to clock
272
- setTimeout(function() {
273
- const el = document.getElementById('ts');
274
- if (el) { el.dataset.server = ''; setInterval(tick, 1000); }
275
- }, 3000);
276
- {% else %}
277
- setInterval(tick, 1000);
278
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
- // ── Auto-refresh every 60 s so new URLs appear without manual reload ────
281
- setTimeout(function() { location.reload(); }, 60000);
282
  </script>
283
  </body>
284
  </html>
 
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>AEGIS BIO DIGITAL LAB β€” AR Portal</title>
7
 
 
8
  <script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
 
9
  <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
10
 
11
  <style>
 
18
  min-height: 100vh;
19
  }
20
 
21
+ /* ── Header ── */
22
  .header {
23
  background: rgba(0,0,0,.5);
24
  padding: 1.25rem 2rem;
 
33
  -webkit-background-clip: text; -webkit-text-fill-color: transparent;
34
  background-clip: text;
35
  }
36
+ .header .subtitle { font-size: .9rem; opacity: .65; margin-top: .2rem; }
 
37
  .status-bar {
38
  display: flex; justify-content: center; gap: 2rem;
39
+ margin-top: .6rem; flex-wrap: wrap;
40
  }
41
+ .status-item { display: flex; align-items: center; gap: .35rem; font-size: .82rem; opacity: .8; }
42
  .online { color: #4caf50; }
43
+ .pending { color: orange; }
44
+
45
+ /* ── Live indicator ── */
46
+ #live-dot {
47
+ display: inline-block; width: 8px; height: 8px;
48
+ border-radius: 50%; background: #4caf50;
49
+ margin-right: 4px;
50
+ animation: blink 1.2s ease-in-out infinite;
51
+ }
52
+ #live-dot.waiting { background: orange; animation: none; }
53
+ @keyframes blink { 0%,100%{opacity:1} 50%{opacity:.2} }
54
 
55
+ /* ── URL banner ── */
56
  .url-banner {
57
+ margin: .75rem 1.5rem;
58
+ padding: .6rem 1rem;
59
+ border-radius: 8px;
60
+ border: 1px solid rgba(0,200,255,.2);
61
+ background: rgba(0,0,0,.3);
62
+ display: flex; align-items: center; gap: .6rem;
63
+ font-size: .82rem; word-break: break-all;
64
  }
65
  .url-banner .label { color: #00d4ff; white-space: nowrap; font-weight: 600; }
 
66
  .url-banner.no-url { border-color: rgba(255,165,0,.3); }
67
  .url-banner.no-url .label { color: orange; }
68
 
69
+ /* ── Grid ── */
70
+ .main { padding: 1.25rem 1.5rem; max-width: 1400px; margin: 0 auto; }
 
71
  .portals-grid {
72
  display: grid;
73
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
74
+ gap: 1.1rem;
75
  }
76
 
77
+ /* ── Card ── */
78
  .portal-card {
79
  background: rgba(0,0,0,.45);
80
+ border: 1px solid rgba(0,200,255,.18);
81
+ border-radius: 12px;
82
+ padding: 1.1rem;
83
+ display: flex; flex-direction: column; align-items: center; gap: .65rem;
84
+ transition: transform .22s, border-color .22s, box-shadow .22s;
 
85
  backdrop-filter: blur(8px);
86
  }
87
  .portal-card:hover {
88
+ transform: translateY(-3px);
89
+ border-color: rgba(0,200,255,.5);
90
+ box-shadow: 0 6px 22px rgba(0,180,255,.18);
91
  }
 
92
  .portal-icon {
93
+ width: 44px; height: 44px; border-radius: 9px;
94
  display: flex; align-items: center; justify-content: center;
 
95
  }
96
+ .portal-icon svg { width: 20px; height: 20px; color: white; }
 
97
  .portal-info { text-align: center; width: 100%; }
98
+ .portal-info h3 { font-size: .95rem; color: #d0eeff; margin-bottom: .25rem; }
99
+ .win-badge {
100
+ display: inline-block; font-size: .68rem; color: #00d4ff;
101
+ border: 1px solid rgba(0,212,255,.3); border-radius: 20px;
102
+ padding: .08rem .45rem; margin-bottom: .3rem;
103
  }
104
+ .portal-info p { font-size: .76rem; opacity: .65; line-height: 1.4; }
105
 
106
+ /* QR box */
107
  .qr-wrap {
108
+ background: white; border-radius: 7px; padding: 5px;
109
+ display: inline-block;
110
  box-shadow: 0 2px 10px rgba(0,0,0,.4);
111
+ min-width: 130px; min-height: 130px;
112
+ display: flex; align-items: center; justify-content: center;
113
+ }
114
+ .qr-wrap .qr-placeholder {
115
+ font-size: .72rem; color: #aaa; text-align: center; padding: .5rem;
116
  }
 
117
 
118
  .launch-btn {
119
+ display: inline-flex; align-items: center; gap: .35rem;
120
+ padding: .4rem .9rem;
121
+ background: rgba(0,200,255,.14);
122
+ border: 1px solid rgba(0,200,255,.3);
123
+ border-radius: 18px; color: #00d4ff;
124
+ font-size: .78rem; font-weight: 500;
125
+ text-decoration: none; cursor: pointer;
126
+ transition: background .18s, color .18s;
 
127
  }
128
+ .launch-btn:hover { background: rgba(0,200,255,.28); color: white; }
129
+
130
+ /* Color classes */
131
+ .color-blue { background: linear-gradient(135deg,#2196f3,#1565c0); }
132
+ .color-purple { background: linear-gradient(135deg,#9c27b0,#6a1b9a); }
133
+ .color-yellow { background: linear-gradient(135deg,#ff9800,#e65100); }
134
+ .color-red { background: linear-gradient(135deg,#f44336,#b71c1c); }
135
+ .color-cyan { background: linear-gradient(135deg,#00bcd4,#006064); }
136
+ .color-orange { background: linear-gradient(135deg,#ff5722,#bf360c); }
137
+ .color-green { background: linear-gradient(135deg,#4caf50,#1b5e20); }
138
+ .color-pink { background: linear-gradient(135deg,#e91e63,#880e4f); }
139
+ .color-indigo { background: linear-gradient(135deg,#3f51b5,#1a237e); }
140
+ .color-teal { background: linear-gradient(135deg,#009688,#004d40); }
141
+
142
+ /* No-URL placeholder */
143
+ .waiting-msg {
144
+ grid-column: 1/-1; text-align: center; padding: 3rem 1rem;
 
 
145
  }
146
+ .waiting-msg h3 { color: orange; margin-bottom: .6rem; font-size: 1.2rem; }
147
+ .waiting-msg p { opacity: .6; font-size: .88rem; line-height: 1.6; }
148
 
 
149
  footer {
150
+ text-align: center; padding: 1.5rem;
151
+ border-top: 1px solid rgba(0,200,255,.15);
152
+ font-size: .75rem; opacity: .45; margin-top: 1.5rem;
153
  }
154
 
 
155
  @media (max-width: 600px) {
156
  .portals-grid { grid-template-columns: 1fr; }
157
  .header h1 { font-size: 1.4rem; }
 
158
  }
159
  </style>
160
  </head>
161
  <body>
162
 
 
163
  <header class="header">
164
  <h1>AEGIS BIO DIGITAL LAB</h1>
165
+ <div class="subtitle">AUGMENTED REALITY PORTAL β€” LIVE QR ACCESS</div>
166
  <div class="status-bar">
167
+ <div class="status-item" id="live-status">
168
+ <span id="live-dot" class="waiting"></span>
169
+ <span id="live-label">Connecting to live stream…</span>
170
  </div>
171
  <div class="status-item">
172
+ <i data-lucide="clock" style="width:13px;height:13px;"></i>
173
+ <span id="ts">{{ last_updated or 'β€”' }}</span>
174
  </div>
175
  <div class="status-item">
176
+ <i data-lucide="layout-grid" style="width:13px;height:13px;"></i>
177
+ <span id="portal-count">{{ portals|length }} windows</span>
178
  </div>
179
  </div>
180
  </header>
181
 
182
+ <div class="url-banner {% if not base_url %}no-url{% endif %}" id="url-banner">
183
+ {% if base_url %}
184
+ <span class="label">πŸ”— Base URL:</span>
185
+ <span id="url-value">{{ base_url }}</span>
186
+ {% else %}
187
+ <span class="label">⚠ Waiting for tunnel URL…</span>
188
+ <span id="url-value" style="opacity:.6">Start the Electron app β€” cloudflared registers the URL automatically.</span>
189
+ {% endif %}
 
 
190
  </div>
 
191
 
 
192
  <main class="main">
193
  <div class="portals-grid" id="grid">
 
194
  {% if portals %}
195
  {% for p in portals %}
196
+ <div class="portal-card" id="card-{{ p.window }}">
197
  <div class="portal-icon color-{{ p.color }}">
198
  <i data-lucide="{{ p.icon }}"></i>
199
  </div>
 
202
  <h3>{{ p.name }}</h3>
203
  <p>{{ p.description }}</p>
204
  </div>
 
205
  <div class="qr-wrap" id="qr-{{ p.window }}"></div>
206
  <a class="launch-btn" href="{{ p.url }}" target="_blank" rel="noopener noreferrer">
207
+ <i data-lucide="external-link" style="width:12px;height:12px;"></i>
208
  Open Window {{ p.window }}
209
  </a>
210
  </div>
211
  {% endfor %}
212
  {% else %}
213
+ <div class="waiting-msg" id="waiting-msg">
214
+ <h3>⏳ Waiting for live URL…</h3>
215
+ <p>The QR codes will appear automatically once the Electron app<br>
216
+ starts cloudflared and registers the tunnel URL.</p>
 
 
 
217
  </div>
218
  {% endif %}
 
219
  </div>
220
  </main>
221
 
222
+ <footer>AEGIS Bio Digital Lab 10 Β· AR Portal Β· Live via Cloudflare Quick Tunnels</footer>
223
+
224
+ <script id="portals-data" type="application/json">{{ portals | tojson }}</script>
225
 
226
  <script>
227
+ lucide.createIcons();
 
228
 
229
+ // ── Initial portals from server-side render ──────────────────────────────────
230
+ const INITIAL_PORTALS = JSON.parse(document.getElementById('portals-data').textContent || '[]');
231
 
232
+ // ── QR rendering ─────────────────────────────────────────────────────────────
233
+ const _qrInstances = {};
234
+
235
+ function renderQR(windowNum, url) {
236
+ const el = document.getElementById('qr-' + windowNum);
237
+ if (!el) return;
238
+
239
+ // Clear previous QR
240
+ el.innerHTML = '';
241
+ delete _qrInstances[windowNum];
242
+
243
+ if (!url) {
244
+ el.innerHTML = '<div class="qr-placeholder">No URL</div>';
245
+ return;
246
+ }
247
+ try {
248
+ _qrInstances[windowNum] = new QRCode(el, {
249
+ text: url,
250
+ width: 130, height: 130,
251
+ colorDark: '#000000', colorLight: '#ffffff',
252
+ correctLevel: QRCode.CorrectLevel.M,
253
+ });
254
+ } catch(e) {
255
+ el.innerHTML = `<div class="qr-placeholder" style="font-size:.65rem;word-break:break-all;">${url}</div>`;
256
+ }
257
+ }
258
+
259
+ // Render initial QRs from SSR data
260
+ INITIAL_PORTALS.forEach(p => renderQR(p.window, p.url));
261
+
262
+ // ── Build/update the grid from fresh portal data ─────────────────────────────
263
+ function updateGrid(portals) {
264
+ const grid = document.getElementById('grid');
265
+
266
+ // Remove the "waiting" placeholder if present
267
+ const waiting = document.getElementById('waiting-msg');
268
+ if (waiting) waiting.remove();
269
+
270
+ // Color map
271
+ const COLORS = {
272
+ 1:'blue',2:'blue',3:'purple',4:'yellow',5:'red',
273
+ 6:'cyan',7:'orange',8:'green',9:'pink',10:'indigo',11:'teal'
274
+ };
275
+ const ICONS = {
276
+ 1:'home',2:'cpu',3:'git-branch',4:'trending-up',5:'shield',
277
+ 6:'cloud',7:'activity',8:'zap',9:'image',10:'eye',11:'scan'
278
+ };
279
+
280
+ portals.forEach(p => {
281
+ let card = document.getElementById('card-' + p.window);
282
+
283
+ if (!card) {
284
+ // Create new card
285
+ card = document.createElement('div');
286
+ card.className = 'portal-card';
287
+ card.id = 'card-' + p.window;
288
+ card.innerHTML = `
289
+ <div class="portal-icon color-${COLORS[p.window] || 'blue'}">
290
+ <i data-lucide="${ICONS[p.window] || 'globe'}"></i>
291
+ </div>
292
+ <div class="portal-info">
293
+ <span class="win-badge">Window ${p.window}</span>
294
+ <h3>${p.name}</h3>
295
+ <p>${p.description}</p>
296
+ </div>
297
+ <div class="qr-wrap" id="qr-${p.window}"></div>
298
+ <a class="launch-btn" href="${p.url}" target="_blank" rel="noopener noreferrer">
299
+ <i data-lucide="external-link" style="width:12px;height:12px;"></i>
300
+ Open Window ${p.window}
301
+ </a>`;
302
+ grid.appendChild(card);
303
+ lucide.createIcons();
304
+ } else {
305
+ // Update the launch button URL
306
+ const btn = card.querySelector('.launch-btn');
307
+ if (btn) btn.href = p.url;
308
  }
309
+
310
+ // Always re-render QR with new URL
311
+ renderQR(p.window, p.url);
312
  });
313
+ }
314
 
315
+ // ── URL banner update ─────────────────────────────────────────────────────────
316
+ function updateBanner(baseUrl, timestamp) {
317
+ const banner = document.getElementById('url-banner');
318
+ const val = document.getElementById('url-value');
319
+ const ts = document.getElementById('ts');
320
+ const count = document.getElementById('portal-count');
321
+
322
+ if (val) val.textContent = baseUrl;
323
+ if (ts) ts.textContent = timestamp ? new Date(timestamp).toLocaleString() : 'β€”';
324
+ banner.classList.remove('no-url');
325
+
326
+ // Update window count after grid is built
327
+ setTimeout(() => {
328
+ const cards = document.querySelectorAll('.portal-card').length;
329
+ if (count) count.textContent = cards + ' windows';
330
+ }, 200);
331
+ }
332
+
333
+ // ── SSE live stream ───────────────────────────────────────────────────────────
334
+ const dot = document.getElementById('live-dot');
335
+ const label = document.getElementById('live-label');
336
+
337
+ function connectSSE() {
338
+ const es = new EventSource('/api/stream');
339
+
340
+ es.onopen = () => {
341
+ dot.classList.remove('waiting');
342
+ label.textContent = 'Live';
343
+ };
344
+
345
+ es.onmessage = (event) => {
346
+ try {
347
+ const data = JSON.parse(event.data);
348
+ if (data.base_url && data.portals && data.portals.length) {
349
+ updateBanner(data.base_url, data.timestamp);
350
+ updateGrid(data.portals);
351
+ }
352
+ } catch(e) { /* ignore malformed */ }
353
+ };
354
+
355
+ es.onerror = () => {
356
+ dot.classList.add('waiting');
357
+ label.textContent = 'Reconnecting…';
358
+ es.close();
359
+ // Reconnect after 2 s
360
+ setTimeout(connectSSE, 2000);
361
+ };
362
+ }
363
 
364
+ connectSSE();
 
365
  </script>
366
  </body>
367
  </html>