py-alaska 0.1.0__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,1533 @@
1
+ """
2
+ ╔══════════════════════════════════════════════════════════════════════════════╗
3
+ ║ ALASK v2.0 Project ║
4
+ ║ Task Monitor - HTTP-Based Web Monitoring System ║
5
+ ╠══════════════════════════════════════════════════════════════════════════════╣
6
+ ║ Version : 2.0.0 ║
7
+ ║ Date : 2026-01-30 ║
8
+ ╠══════════════════════════════════════════════════════════════════════════════╣
9
+ ║ Classes: ║
10
+ ║ - MonitorHandler : HTTP request handler for REST API and web UI ║
11
+ ║ - TaskMonitor : Threaded HTTP server wrapper for task monitoring ║
12
+ ║ ║
13
+ ║ API Endpoints: ║
14
+ ║ - GET /api/status : Task status and RMI statistics ║
15
+ ║ - GET /api/health : Health check endpoint ║
16
+ ║ - GET /api/clear : Clear all statistics ║
17
+ ║ - GET /api/cpu : CPU usage (requires psutil) ║
18
+ ║ - GET /api/config : Get task configuration ║
19
+ ║ - POST /api/config : Update task configuration ║
20
+ ║ - GET /api/performance/system : System-wide performance stats ║
21
+ ║ - GET /api/performance/tasks : All tasks performance stats ║
22
+ ║ - GET /api/performance/tasks/{id} : Specific task performance stats ║
23
+ ║ - GET /api/performance/tasks/{id}/methods : Task methods performance ║
24
+ ╚══════════════════════════════════════════════════════════════════════════════╝
25
+ """
26
+
27
+ from __future__ import annotations
28
+ from typing import TYPE_CHECKING
29
+ from http.server import HTTPServer, BaseHTTPRequestHandler
30
+ import threading
31
+ import json
32
+ import time
33
+ import queue
34
+ import traceback
35
+ import os
36
+ from datetime import datetime
37
+
38
+ try:
39
+ import psutil
40
+ HAS_PSUTIL = True
41
+ except ImportError:
42
+ HAS_PSUTIL = False
43
+
44
+ if TYPE_CHECKING:
45
+ from .task_manager import TaskManager
46
+
47
+
48
+ class MonitorHandler(BaseHTTPRequestHandler):
49
+ manager: TaskManager = None
50
+ start_time: float = 0
51
+ gconfig = None
52
+
53
+ def log_message(self, format, *args): pass
54
+
55
+ def do_GET(self):
56
+ routes = {
57
+ '/api/status': self._json,
58
+ '/api/health': self._health,
59
+ '/api/clear': self._clear,
60
+ '/api/cpu': self._cpu,
61
+ '/api/config': self._config,
62
+ '/api/gconfig': self._gconfig,
63
+ '/api/logo': self._logo,
64
+ '/api/rmi_methods': self._rmi_methods,
65
+ '/api/measurement': self._get_measurement,
66
+ '/api/smblock': self._smblock,
67
+ '/api/performance/system': self._perf_system,
68
+ '/api/performance/tasks': self._perf_tasks,
69
+ }
70
+ # Check static routes first
71
+ if self.path in routes:
72
+ routes[self.path]()
73
+ return
74
+ # Dynamic routes for /api/performance/tasks/{id} and /api/performance/tasks/{id}/methods
75
+ if self.path.startswith('/api/performance/tasks/'):
76
+ parts = self.path.split('/')
77
+ if len(parts) == 5: # /api/performance/tasks/{id}
78
+ self._perf_task_by_id(parts[4])
79
+ elif len(parts) == 6 and parts[5] == 'methods': # /api/performance/tasks/{id}/methods
80
+ self._perf_task_methods(parts[4])
81
+ else:
82
+ self._send_json({'error': 'Invalid path'})
83
+ return
84
+ self._html()
85
+
86
+ def do_POST(self):
87
+ if self.path == '/api/config':
88
+ self._update_config()
89
+ elif self.path == '/api/gconfig':
90
+ self._update_gconfig()
91
+ elif self.path == '/api/stop':
92
+ self._stop_all()
93
+ elif self.path == '/api/measurement':
94
+ self._set_measurement()
95
+
96
+ def _config(self):
97
+ data = []
98
+ for tid, ti in self.manager._tasks.items():
99
+ vars = {}
100
+ if ti.job_class:
101
+ keys = [k for k, v in ti.injection.items() if not (isinstance(v, str) and (v.startswith("task:") or v.startswith("smblock:")))]
102
+ if keys:
103
+ try:
104
+ resp_q = self.manager._mgr.Queue()
105
+ ti.request_q.put({'m': '__get_config__', 'a': (keys,), 'kw': {}, 'rq': resp_q})
106
+ resp = resp_q.get(timeout=0.2) # 200ms timeout for faster loading
107
+ vars = resp.get('r') or {k: ti.injection[k] for k in keys}
108
+ except queue.Empty:
109
+ vars = {k: ti.injection[k] for k in keys} # Timeout fallback (use injection)
110
+ except Exception as e:
111
+ print(f"[Monitor] Config read error for '{tid}': {e}")
112
+ vars = {k: ti.injection[k] for k in keys}
113
+ # Filter out non-JSON-serializable values (e.g., SmBlock proxy objects)
114
+ vars = {k: v for k, v in vars.items() if isinstance(v, (str, int, float, bool, type(None), list, dict))}
115
+ data.append({'id': tid, 'class': ti.task_class_name, 'vars': vars})
116
+ self._send_json(data)
117
+
118
+ def _rmi_methods(self):
119
+ """RMI 메서드별 호출 통계 및 시간 측정 반환"""
120
+ data = []
121
+ for tid, ti in self.manager._tasks.items():
122
+ methods = dict(ti.rmi_methods) if ti.rmi_methods else {}
123
+ timing = dict(ti.rmi_timing) if ti.rmi_timing else {}
124
+ total = sum(methods.values()) if methods else 0
125
+ # Get alive status from worker
126
+ worker = self.manager._workers.get(tid)
127
+ alive = worker.is_alive() if worker else False
128
+
129
+ # Build methods_timing with min/avg/max for each method
130
+ methods_timing = {}
131
+ for m, count in methods.items():
132
+ t = timing.get(m)
133
+ if t:
134
+ ipc_list, func_list = t
135
+ ipc_list = list(ipc_list) if ipc_list else []
136
+ func_list = list(func_list) if func_list else []
137
+ if ipc_list and func_list:
138
+ methods_timing[m] = {
139
+ 'count': count,
140
+ 'ipc': {'min': round(min(ipc_list), 2), 'avg': round(sum(ipc_list)/len(ipc_list), 2), 'max': round(max(ipc_list), 2)},
141
+ 'func': {'min': round(min(func_list), 2), 'avg': round(sum(func_list)/len(func_list), 2), 'max': round(max(func_list), 2)},
142
+ }
143
+ else:
144
+ methods_timing[m] = {'count': count, 'ipc': None, 'func': None}
145
+ else:
146
+ methods_timing[m] = {'count': count, 'ipc': None, 'func': None}
147
+
148
+ data.append({
149
+ 'id': tid,
150
+ 'class': ti.task_class_name,
151
+ 'mode': ti.mode,
152
+ 'alive': alive,
153
+ 'total': total,
154
+ 'methods': methods,
155
+ 'methods_timing': methods_timing
156
+ })
157
+ self._send_json(data)
158
+
159
+ def _get_measurement(self):
160
+ """RMI 메서드 측정 상태 조회"""
161
+ enabled = self.manager._measurement.value if self.manager._measurement else False
162
+ self._send_json({'enabled': enabled})
163
+
164
+ def _set_measurement(self):
165
+ """RMI 메서드 측정 on/off 토글"""
166
+ try:
167
+ length = int(self.headers.get('Content-Length', 0))
168
+ data = json.loads(self.rfile.read(length).decode()) if length > 0 else {}
169
+ enabled = data.get('enabled')
170
+ if enabled is None:
171
+ # Toggle if no value specified
172
+ enabled = not self.manager._measurement.value
173
+ self.manager._measurement.value = bool(enabled)
174
+ self._send_json({'status': 'ok', 'enabled': self.manager._measurement.value})
175
+ except Exception as e:
176
+ self._send_json({'status': 'error', 'message': str(e)})
177
+
178
+ def _smblock(self):
179
+ """SmBlock 공유 메모리 풀 통계 조회"""
180
+ from .task_manager import SmBlockHandler
181
+ data = []
182
+
183
+ # Find which tasks use each smblock
184
+ smblock_users = {} # name -> [task_ids]
185
+ for tid, ti in self.manager._tasks.items():
186
+ for k, v in ti.injection.items():
187
+ if isinstance(v, dict) and "_smblock" in v:
188
+ pool_name = v["_smblock"]
189
+ if pool_name not in smblock_users:
190
+ smblock_users[pool_name] = []
191
+ smblock_users[pool_name].append(f"{tid}.{k}")
192
+
193
+ for name, pool in SmBlockHandler._pools.items():
194
+ try:
195
+ free_count = pool.count()
196
+ used_count = pool.maxsize - free_count
197
+ usage_pct = (used_count / pool.maxsize * 100) if pool.maxsize > 0 else 0
198
+ pool_data = {
199
+ 'name': name,
200
+ 'shape': list(pool.shape),
201
+ 'maxsize': pool.maxsize,
202
+ 'used': used_count,
203
+ 'free': free_count,
204
+ 'usage_pct': round(usage_pct, 1),
205
+ 'item_size': pool.item_size,
206
+ 'total_mb': round(pool.item_size * pool.maxsize / 1024 / 1024, 2),
207
+ 'users': smblock_users.get(name, []),
208
+ }
209
+ data.append(pool_data)
210
+ except Exception as e:
211
+ data.append({'name': name, 'error': str(e)})
212
+
213
+ self._send_json(data)
214
+
215
+ # ─────────────────────────────────────────────────────────────────────────
216
+ # Performance API (PERF-006)
217
+ # ─────────────────────────────────────────────────────────────────────────
218
+
219
+ def _perf_system(self):
220
+ """시스템 전체 성능 통계 반환 (/api/performance/system)"""
221
+ perf = self.manager.performance.get_system_performance()
222
+ self._send_json(perf.to_dict())
223
+
224
+ def _perf_tasks(self):
225
+ """모든 Task 성능 통계 반환 (/api/performance/tasks)"""
226
+ tasks = self.manager.performance.get_all_tasks_performance()
227
+ self._send_json([t.to_dict() for t in tasks])
228
+
229
+ def _perf_task_by_id(self, task_id: str):
230
+ """특정 Task 성능 통계 반환 (/api/performance/tasks/{id})"""
231
+ perf = self.manager.performance.get_task_performance(task_id)
232
+ if perf:
233
+ self._send_json(perf.to_dict())
234
+ else:
235
+ self._send_json({'error': f'Task {task_id} not found'})
236
+
237
+ def _perf_task_methods(self, task_id: str):
238
+ """특정 Task의 메서드별 성능 통계 반환 (/api/performance/tasks/{id}/methods)"""
239
+ methods = self.manager.performance.get_task_methods_performance(task_id)
240
+ self._send_json([m.to_dict() for m in methods])
241
+
242
+ def _update_config(self):
243
+ length = int(self.headers.get('Content-Length', 0))
244
+ data = json.loads(self.rfile.read(length).decode())
245
+ tid, key, value = data.get('task_id'), data.get('key'), data.get('value')
246
+ result = {'status': 'error', 'message': 'Task not found'}
247
+ print(f"[Monitor:_update_config] tid={tid}, key={key}, value={value}, type={type(value)}")
248
+
249
+ if tid in self.manager._tasks:
250
+ ti = self.manager._tasks[tid]
251
+ old_val = ti.injection.get(key)
252
+ print(f"[Monitor:_update_config] old_val={old_val}, type={type(old_val)}")
253
+ if old_val is not None and not (isinstance(old_val, str) and old_val.startswith("task:")):
254
+ try:
255
+ if isinstance(old_val, bool):
256
+ value = value.lower() in ('true', '1', 'yes')
257
+ elif isinstance(old_val, (int, float)):
258
+ # Try int first, fallback to float
259
+ try:
260
+ value = int(value)
261
+ except ValueError:
262
+ value = float(value)
263
+ print(f"[Monitor:_update_config] converted value={value}, type={type(value)}")
264
+ ti.injection[key] = value
265
+ print(f"[Monitor:_update_config] injection updated: {ti.injection.get(key)}")
266
+ resp_q = self.manager._mgr.Queue()
267
+ print(f"[Monitor:_update_config] sending RMI __set_config__ to {tid}")
268
+ ti.request_q.put({'m': '__set_config__', 'a': (key, value), 'kw': {}, 'rq': resp_q})
269
+ try:
270
+ resp = resp_q.get(timeout=2.0)
271
+ print(f"[Monitor:_update_config] RMI response: {resp}")
272
+ result = {'status': 'ok', 'task_id': tid, 'key': key, 'value': value} if resp.get('r') else {'status': 'error', 'message': resp.get('e', 'Unknown')}
273
+ except queue.Empty:
274
+ print(f"[Monitor:_update_config] RMI timeout for {tid}")
275
+ result = {'status': 'error', 'message': f'Timeout waiting for task {tid}'}
276
+ except Exception as e:
277
+ print(f"[Monitor:_update_config] RMI exception: {e}")
278
+ result = {'status': 'error', 'message': f'RMI error: {e}'}
279
+ except Exception as e:
280
+ print(f"[Monitor:_update_config] exception: {e}")
281
+ result = {'status': 'error', 'message': str(e)}
282
+ else:
283
+ print(f"[Monitor:_update_config] task {tid} not found in {list(self.manager._tasks.keys())}")
284
+ self._send_json(result)
285
+
286
+ def _gconfig(self):
287
+ if not self.gconfig:
288
+ self._send_json({'error': 'GConfig not configured', 'data': None})
289
+ return
290
+ try:
291
+ data = self.gconfig.to_dict() if hasattr(self.gconfig, 'to_dict') else self.gconfig._cache._data
292
+ filepath = getattr(self.gconfig, '_filepath', None)
293
+ home_dir = os.path.dirname(filepath) if filepath else None
294
+ log_dir = os.path.join(home_dir, 'log') if home_dir else None
295
+ self._send_json({
296
+ 'data': data,
297
+ 'filepath': filepath,
298
+ 'home_dir': home_dir,
299
+ 'log_dir': log_dir
300
+ })
301
+ except Exception as e:
302
+ self._send_json({'error': str(e), 'data': None})
303
+
304
+ def _update_gconfig(self):
305
+ if not self.gconfig:
306
+ self._send_json({'status': 'error', 'message': 'GConfig not configured'})
307
+ return
308
+ try:
309
+ length = int(self.headers.get('Content-Length', 0))
310
+ data = json.loads(self.rfile.read(length).decode())
311
+ path, value, vtype = data.get('path'), data.get('value'), data.get('vtype')
312
+ if not path:
313
+ self._send_json({'status': 'error', 'message': 'Path required'})
314
+ return
315
+ # Convert value type based on original type (vtype)
316
+ if isinstance(value, str):
317
+ if vtype == 'str':
318
+ pass # Keep as string
319
+ elif vtype == 'bool':
320
+ value = value.lower() in ('true', '1', 'yes')
321
+ elif vtype == 'null':
322
+ value = None if value.lower() in ('null', '') else value
323
+ elif vtype == 'num':
324
+ try: value = int(value)
325
+ except ValueError:
326
+ try: value = float(value)
327
+ except ValueError: pass
328
+ else:
329
+ # Fallback: auto-detect type
330
+ if value.lower() == 'true': value = True
331
+ elif value.lower() == 'false': value = False
332
+ elif value.lower() == 'null': value = None
333
+ else:
334
+ try: value = int(value)
335
+ except ValueError:
336
+ try: value = float(value)
337
+ except ValueError: pass
338
+ self.gconfig.data_set(path, value)
339
+ self._send_json({'status': 'ok', 'path': path, 'value': value})
340
+ except Exception as e:
341
+ self._send_json({'status': 'error', 'message': str(e)})
342
+
343
+ def _stop_all(self):
344
+ print(f"[Monitor:_stop_all] called")
345
+ try:
346
+ # skip_monitor=True to avoid deadlock (HTTP request waiting for server.shutdown)
347
+ print(f"[Monitor:_stop_all] calling manager.stop_all(skip_monitor=True)")
348
+ self.manager.stop_all(skip_monitor=True)
349
+ print(f"[Monitor:_stop_all] manager.stop_all() completed")
350
+ self._send_json({'status': 'ok', 'message': 'All tasks stopped'})
351
+ except Exception as e:
352
+ print(f"[Monitor:_stop_all] exception: {e}")
353
+ self._send_json({'status': 'error', 'message': str(e)})
354
+
355
+ def _logo(self):
356
+ logo_path = os.path.join(os.path.dirname(__file__), 'div_logo.png')
357
+ try:
358
+ with open(logo_path, 'rb') as f:
359
+ data = f.read()
360
+ self.send_response(200)
361
+ self.send_header('Content-Type', 'image/png')
362
+ self.send_header('Cache-Control', 'max-age=86400')
363
+ self.end_headers()
364
+ self.wfile.write(data)
365
+ except FileNotFoundError:
366
+ self.send_response(404)
367
+ self.end_headers()
368
+
369
+ def _cpu(self):
370
+ if not HAS_PSUTIL:
371
+ self._send_json({'error': 'psutil not installed', 'tasks': []})
372
+ return
373
+ tasks = []
374
+ for tid, w in self.manager._workers.items():
375
+ ti = self.manager._tasks.get(tid)
376
+ alive = w.is_alive() if w else False
377
+ rmi_fail = ti.rmi_fail.value if ti and ti.rmi_fail else 0
378
+ restart = ti.restart_cnt.value if ti and ti.restart_cnt else 0
379
+ mode = ti.mode if ti else ''
380
+ task_data = {'id': tid, 'class': ti.task_class_name if ti else '', 'mode': mode, 'alive': alive, 'rmi_fail': rmi_fail, 'restart': restart, 'pid': None, 'cpu': 0, 'memory': 0, 'threads': 0, 'vars': {}}
381
+ try:
382
+ if hasattr(w, 'pid') and w.pid:
383
+ p = psutil.Process(w.pid)
384
+ task_data['pid'] = w.pid
385
+ task_data['cpu'] = p.cpu_percent(interval=0.1)
386
+ task_data['memory'] = p.memory_info().rss / 1024 / 1024
387
+ task_data['threads'] = p.num_threads()
388
+ except psutil.NoSuchProcess:
389
+ pass # Process ended
390
+ except Exception as e:
391
+ print(f"[Monitor] CPU info error for '{tid}': {e}")
392
+ # Add config vars (filtered)
393
+ if ti and ti.job_class:
394
+ keys = [k for k, v in ti.injection.items() if not (isinstance(v, str) and (v.startswith("task:") or v.startswith("smblock:"))) and not (isinstance(v, dict) and "_smblock" in v)]
395
+ if keys:
396
+ try:
397
+ resp_q = self.manager._mgr.Queue()
398
+ ti.request_q.put({'m': '__get_config__', 'a': (keys,), 'kw': {}, 'rq': resp_q})
399
+ resp = resp_q.get(timeout=0.2)
400
+ vars_data = resp.get('r') or {k: ti.injection[k] for k in keys}
401
+ except Exception:
402
+ vars_data = {k: ti.injection[k] for k in keys}
403
+ # Filter non-serializable
404
+ task_data['vars'] = {k: v for k, v in vars_data.items() if isinstance(v, (str, int, float, bool, type(None), list, dict))}
405
+ tasks.append(task_data)
406
+ self._send_json({'system_cpu': psutil.cpu_percent(interval=0.1),
407
+ 'system_memory': psutil.virtual_memory().percent, 'tasks': tasks})
408
+
409
+ def _clear(self):
410
+ self.manager.clear_stats()
411
+ self._send_json({'status': 'cleared'})
412
+
413
+ def _json(self):
414
+ self._send_json(self._status())
415
+
416
+ def _health(self):
417
+ """Health check with full statistics (기존통계 + RMI 메서드 통계)"""
418
+ status = self.manager.get_status()
419
+ uptime = int(time.time() - self.start_time)
420
+
421
+ # Build task stats with rmi_methods
422
+ tasks = []
423
+ for tid, info in status.items():
424
+ tasks.append({
425
+ 'id': tid,
426
+ 'class': info['class'],
427
+ 'mode': info['mode'],
428
+ 'alive': info['alive'],
429
+ 'rmi_rx': info['rmi_rx'],
430
+ 'rmi_tx': info['rmi_tx'],
431
+ 'rmi_fail': info['rmi_fail'],
432
+ 'rmi_avg': info['rmi_avg'],
433
+ 'rmi_methods': info.get('rmi_methods', {}),
434
+ })
435
+
436
+ self._send_json({
437
+ 'status': 'ok',
438
+ 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
439
+ 'uptime_seconds': uptime,
440
+ 'tasks': tasks
441
+ })
442
+
443
+ def _send_json(self, data):
444
+ self.send_response(200)
445
+ self.send_header('Content-Type', 'application/json')
446
+ self.end_headers()
447
+ self.wfile.write(json.dumps(data, indent=2).encode())
448
+
449
+ def _html(self):
450
+ self.send_response(200)
451
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
452
+ self.end_headers()
453
+ self.wfile.write(self._page().encode())
454
+
455
+ def _status(self):
456
+ uptime = int(time.time() - self.start_time)
457
+ h, r = divmod(uptime, 3600)
458
+ mi, s = divmod(r, 60)
459
+ status = self.manager.get_status()
460
+ tasks = []
461
+ running = 0
462
+ for tid, info in status.items():
463
+ if info['alive']: running += 1
464
+ tasks.append({
465
+ 'id': tid, 'class': info['class'], 'mode': info['mode'],
466
+ 'status': 'running' if info['alive'] else 'stopped',
467
+ 'rmi_rx': info['rmi_rx'], 'rmi_tx': info['rmi_tx'], 'rmi_fail': info['rmi_fail'],
468
+ 'rmi_avg': info['rmi_avg'], 'rmi_min': info['rmi_min'], 'rmi_max': info['rmi_max'],
469
+ 'rxq_size': info['rxq_size'], 'txq_size': info['txq_size'], 'restart': info['restart'],
470
+ })
471
+ return {'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
472
+ 'uptime': f'{h}시간 {mi}분 {s}초', 'total': len(tasks), 'running': running, 'tasks': tasks}
473
+
474
+ def _page(self):
475
+ d = self._status()
476
+ # Get app_info from gconfig
477
+ app_name = self.gconfig.data_get("app_info.name", "") if self.gconfig else ""
478
+ app_ver = self.gconfig.data_get("app_info.version", "") if self.gconfig else ""
479
+ app_id = self.gconfig.data_get("app_info.id", "") if self.gconfig else ""
480
+ app_info_text = f"{app_name}:{app_ver} id={app_id}" if app_name else ""
481
+ app_info_html = f'<span class="app-info">{app_info_text}</span>' if app_name else ""
482
+ title_text = f"ALASKA Task Monitor {app_info_text}".strip()
483
+ title_html = f"ALASKA Task Monitor {app_info_html}".strip()
484
+ return f'''<!DOCTYPE html>
485
+ <html><head><meta charset="utf-8"><title>{title_text}</title>
486
+ <style>
487
+ *{{box-sizing:border-box}}
488
+ body{{font-family:sans-serif;margin:0;background:#1a1a1a;color:#e0e0e0;display:flex;flex-direction:column;height:100vh}}
489
+ .header{{display:flex;align-items:center;gap:16px;padding:10px 20px;background:#252525;border-bottom:1px solid #333}}
490
+ h1{{color:#4fc3f7;margin:0;font-size:18px;white-space:nowrap}}
491
+ .app-info{{color:#ffcc80;font-weight:normal}}
492
+ .header-info{{color:#9e9e9e;font-size:12px}}
493
+ .refresh-ctrl{{display:flex;align-items:center;gap:8px;margin-left:auto}}
494
+ .refresh-ctrl label{{color:#9e9e9e;font-size:12px}}
495
+ .refresh-ctrl input[type="checkbox"]{{width:14px;height:14px;cursor:pointer}}
496
+ .refresh-ctrl select{{background:#2d2d2d;color:#e0e0e0;border:1px solid #444;padding:3px 6px;border-radius:4px;cursor:pointer;font-size:11px}}
497
+ .btn-clear{{background:#d32f2f;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px}}
498
+ .btn-clear:hover{{background:#b71c1c}}
499
+ .btn-refresh{{background:#0288d1;color:#fff;border:none;padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px}}
500
+ .btn-refresh:hover{{background:#0277bd}}
501
+ .main-container{{display:flex;flex:1;overflow:hidden}}
502
+ .sidebar{{width:200px;background:#252525;border-right:1px solid #333;display:flex;flex-direction:column;transition:width 0.2s}}
503
+ .sidebar.collapsed{{width:48px}}
504
+ .sidebar-toggle{{background:#333;border:none;color:#9e9e9e;padding:10px;cursor:pointer;display:flex;align-items:center;justify-content:center;border-top:1px solid #333;margin-top:auto}}
505
+ .sidebar-toggle:hover{{background:#444;color:#fff}}
506
+ .sidebar-toggle svg{{width:18px;height:18px}}
507
+ .sidebar.collapsed .sidebar-toggle svg{{transform:rotate(180deg)}}
508
+ .sidebar-nav{{flex:1;overflow-y:auto}}
509
+ .nav-item{{display:flex;align-items:center;gap:12px;padding:12px 16px;color:#9e9e9e;cursor:pointer;border-left:3px solid transparent;transition:all 0.15s}}
510
+ .nav-item:hover{{background:#333;color:#e0e0e0}}
511
+ .nav-item.active{{background:#0288d1;color:#fff;border-left-color:#4fc3f7}}
512
+ .nav-item svg{{width:18px;height:18px;flex-shrink:0}}
513
+ .nav-item span{{white-space:nowrap;overflow:hidden;font-size:13px}}
514
+ .sidebar.collapsed .nav-item span{{display:none}}
515
+ .sidebar.collapsed .nav-item{{justify-content:center;padding:12px}}
516
+ .sidebar-logo{{padding:16px 12px;border-bottom:1px solid #333;display:flex;flex-direction:column;align-items:center;gap:10px}}
517
+ .sidebar-logo img{{height:40px;opacity:0.9}}
518
+ .sidebar.collapsed .sidebar-logo img{{height:28px}}
519
+ .sidebar-logo-text{{text-align:center;line-height:1.4}}
520
+ .sidebar-logo-title{{color:#4fc3f7;font-size:16px;font-weight:bold}}
521
+ .sidebar-logo-sub{{color:#9e9e9e;font-size:12px}}
522
+ .sidebar.collapsed .sidebar-logo-text{{display:none}}
523
+ .content-area{{flex:1;overflow:auto;padding:16px}}
524
+ .panel{{display:none}}
525
+ .panel.active{{display:block}}
526
+ table{{border-collapse:collapse;width:100%;background:#2d2d2d;box-shadow:0 2px 8px rgba(0,0,0,0.5)}}
527
+ th,td{{border:1px solid #444;padding:10px;text-align:left;font-size:13px}}
528
+ th{{background:#0288d1;color:#fff}}
529
+ tr:nth-child(even){{background:#383838}}
530
+ tr:hover{{background:#424242}}
531
+ .running{{color:#4caf50;font-weight:bold}}
532
+ .stopped{{color:#f44336;font-weight:bold}}
533
+ .num{{text-align:right;font-family:monospace;color:#b0bec5}}
534
+ .fail{{color:#f44336}}
535
+ .warn{{color:#ff9800}}
536
+ .cpu-bar{{background:#444;border-radius:4px;height:20px;overflow:hidden}}
537
+ .cpu-bar-fill{{height:100%;transition:width 0.3s}}
538
+ .cpu-low{{background:#4caf50}}
539
+ .cpu-mid{{background:#ff9800}}
540
+ .cpu-high{{background:#f44336}}
541
+ .config-input{{background:#383838;color:#e0e0e0;border:1px solid #555;padding:6px 10px;border-radius:4px;width:150px}}
542
+ .btn-save{{background:#4caf50;color:#fff;border:none;padding:6px 12px;border-radius:4px;cursor:pointer}}
543
+ .btn-save:hover{{background:#388e3c}}
544
+ .msg{{padding:4px 8px;border-radius:4px;font-size:12px}}
545
+ .msg-ok{{background:#4caf50;color:#fff}}
546
+ .msg-err{{background:#f44336;color:#fff}}
547
+ .gconfig-header{{display:flex;align-items:center;gap:12px;margin-bottom:16px;padding:12px;background:#2d2d2d;border-radius:4px}}
548
+ .gconfig-header span{{color:#81d4fa;font-family:monospace}}
549
+ .btn-expand,.btn-collapse{{background:#555;color:#fff;border:none;padding:6px 12px;border-radius:4px;cursor:pointer}}
550
+ .btn-expand:hover,.btn-collapse:hover{{background:#666}}
551
+ .tree-container{{background:#2d2d2d;padding:16px;border-radius:4px;font-family:monospace;font-size:14px;line-height:1.6}}
552
+ .tree-node{{margin-left:20px}}
553
+ .tree-key{{color:#81d4fa}}
554
+ .tree-key-0{{color:#ff7043}}
555
+ .tree-key-1{{color:#42a5f5}}
556
+ .tree-key-2{{color:#66bb6a}}
557
+ .tree-key-3{{color:#ab47bc}}
558
+ .tree-key-4{{color:#ffa726}}
559
+ .tree-val-str{{color:#a5d6a7}}
560
+ .tree-val-num{{color:#ffcc80}}
561
+ .tree-val-bool{{color:#ef9a9a}}
562
+ .tree-val-null{{color:#9e9e9e}}
563
+ .tree-toggle{{cursor:pointer;user-select:none;display:inline-block;width:16px;color:#ff9800}}
564
+ .tree-toggle:hover{{color:#ffcc80}}
565
+ .tree-bracket{{color:#9e9e9e}}
566
+ .tree-collapsed>.tree-node{{display:none}}
567
+ .tree-collapsed>.tree-toggle::before{{content:'▶'}}
568
+ .tree-expanded>.tree-toggle::before{{content:'▼'}}
569
+ .tree-val-edit{{cursor:pointer;padding:2px 4px;border-radius:3px}}
570
+ .tree-val-edit:hover{{background:#444}}
571
+ .tree-edit-input{{background:#383838;color:#e0e0e0;border:1px solid #4fc3f7;padding:2px 6px;border-radius:3px;font-family:monospace;font-size:14px;min-width:100px}}
572
+ .tree-edit-msg{{font-size:11px;margin-left:8px}}
573
+ .gconfig-warning{{margin-left:auto;background:#3e2723;color:#ffcc80;padding:6px 12px;border-radius:4px;font-size:12px}}
574
+ .gconfig-body{{display:flex;gap:16px;height:calc(100vh - 200px);min-height:400px}}
575
+ .gconfig-tree-wrap{{flex:1;min-width:0;overflow-y:auto}}
576
+ .value-setter{{flex:1;min-width:0;background:#2d2d2d;padding:16px;border-radius:4px;height:fit-content;align-self:flex-start}}
577
+ .value-setter h3{{margin:0 0 16px 0;color:#81d4fa;font-size:14px;border-bottom:1px solid #444;padding-bottom:8px}}
578
+ .value-setter-empty{{color:#666;font-size:13px}}
579
+ .setter-row{{margin-bottom:12px}}
580
+ .setter-label{{color:#9e9e9e;font-size:12px;margin-bottom:4px}}
581
+ .setter-path{{color:#81d4fa;font-family:monospace;font-size:13px;word-break:break-all;background:#383838;padding:8px;border-radius:4px}}
582
+ .setter-input{{width:100%;background:#383838;color:#e0e0e0;border:1px solid #555;padding:8px;border-radius:4px;font-family:monospace;font-size:13px;box-sizing:border-box}}
583
+ .setter-input:focus{{border-color:#4fc3f7;outline:none}}
584
+ .setter-type{{color:#9e9e9e;font-size:11px;margin-top:4px}}
585
+ .btn-change{{background:#4caf50;color:#fff;border:none;padding:8px 16px;border-radius:4px;cursor:pointer;width:100%;margin-top:8px;font-size:14px}}
586
+ .btn-change:hover{{background:#388e3c}}
587
+ .setter-msg{{margin-top:8px;padding:6px;border-radius:4px;font-size:12px;text-align:center}}
588
+ .setter-msg-ok{{background:#4caf50;color:#fff}}
589
+ .setter-msg-err{{background:#f44336;color:#fff}}
590
+ .setter-rmi-notice{{background:#1e3a5f;color:#64b5f6;padding:8px 12px;border-radius:4px;font-size:12px;margin-bottom:12px;border-left:3px solid #2196f3}}
591
+ .tree-val-edit.selected{{background:#0288d1;border-radius:3px}}
592
+ </style></head>
593
+ <body>
594
+ <div class="header">
595
+ <h1>{title_html}</h1>
596
+ <span class="header-info" id="info-text">Uptime: {d['uptime']} | Tasks: {d['running']}/{d['total']} | {d['timestamp']}</span>
597
+ <div class="refresh-ctrl">
598
+ <input type="checkbox" id="autoRefresh" checked>
599
+ <label for="autoRefresh">Auto</label>
600
+ <select id="refreshInterval">
601
+ <option value="1">1s</option>
602
+ <option value="2">2s</option>
603
+ <option value="3" selected>3s</option>
604
+ <option value="5">5s</option>
605
+ <option value="10">10s</option>
606
+ </select>
607
+ <button class="btn-refresh" onclick="manualRefresh()">Refresh</button>
608
+ <button class="btn-clear" onclick="clearStats()">Clear</button>
609
+ </div>
610
+ </div>
611
+ <div class="main-container">
612
+ <div class="sidebar" id="sidebar">
613
+ <div class="sidebar-nav">
614
+ <div class="nav-item active" onclick="showPanel('cpu')" data-panel="cpu">
615
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg>
616
+ <span>Task/SM</span>
617
+ </div>
618
+ <div class="nav-item" onclick="showPanel('rmi')" data-panel="rmi">
619
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
620
+ <span>Latency</span>
621
+ </div>
622
+ <div class="nav-item" onclick="showPanel('rmicall')" data-panel="rmicall">
623
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
624
+ <span>Method</span>
625
+ </div>
626
+ <div class="nav-item" onclick="showPanel('gconfig')" data-panel="gconfig">
627
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
628
+ <span>File (Permanent)</span>
629
+ </div>
630
+ </div>
631
+ <button class="sidebar-toggle" onclick="toggleSidebar()" title="Toggle Sidebar">
632
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
633
+ </button>
634
+ </div>
635
+ <div class="content-area">
636
+ <div id="panel-rmi" class="panel">
637
+ <div id="rmi-loading">Loading Latency data...</div>
638
+ <div id="rmi-content" style="display:none">
639
+ <div style="display:flex;padding:6px 12px;margin-bottom:4px;color:#9e9e9e;font-size:12px;border-bottom:1px solid #333">
640
+ <span style="width:28px"></span>
641
+ <span style="flex:1">Task ID</span>
642
+ <span style="width:100px">Class</span>
643
+ <span style="width:70px">Mode</span>
644
+ <span style="width:70px">Status</span>
645
+ <span style="width:140px;text-align:center">Latency (min/avg/max)</span>
646
+ </div>
647
+ <div id="rmi-tree" class="tree-container" style="font-size:13px"></div>
648
+ </div>
649
+ </div>
650
+ <div id="panel-rmicall" class="panel active">
651
+ <div id="rmicall-loading">Loading RMI method stats...</div>
652
+ <div id="rmicall-content" style="display:none">
653
+ <div style="margin-bottom:12px;display:flex;align-items:center;gap:16px">
654
+ <span style="color:#9e9e9e;font-size:12px" id="rmicall-interval">Interval: -</span>
655
+ <button id="measurement-toggle" class="btn-refresh" onclick="toggleMeasurement()" style="padding:4px 12px">
656
+ <span id="measurement-status">OFF</span>
657
+ </button>
658
+ <span style="color:#666;font-size:11px">measurement: config._monitor.measurement</span>
659
+ </div>
660
+ <div style="display:flex;padding:6px 12px;margin-bottom:4px;color:#9e9e9e;font-size:12px;border-bottom:1px solid #333">
661
+ <span style="width:28px"></span>
662
+ <span style="flex:1">Task ID</span>
663
+ <span style="width:120px">Class</span>
664
+ <span style="width:70px">Mode</span>
665
+ <span style="width:70px">Status</span>
666
+ <span style="width:80px;text-align:right">Total</span>
667
+ <span style="width:80px;text-align:right">Rate</span>
668
+ </div>
669
+ <div id="rmicall-tree" class="tree-container" style="font-size:13px"></div>
670
+ </div>
671
+ </div>
672
+ <div id="panel-cpu" class="panel">
673
+ <div id="cpu-loading">Loading CPU data...</div>
674
+ <div id="cpu-content" style="display:none">
675
+ <div id="application-section" style="margin-bottom:16px">
676
+ <div style="margin-bottom:8px;color:#9e9e9e;font-size:13px;font-weight:bold">Application</div>
677
+ <div id="appinfo-content" class="tree-container" style="font-size:13px;padding:12px"></div>
678
+ </div>
679
+ <div id="smblock-section" style="margin-bottom:16px">
680
+ <div style="margin-bottom:8px;color:#9e9e9e;font-size:13px;font-weight:bold">SmBlock Pools</div>
681
+ <div id="smblock-tree" class="tree-container" style="font-size:13px"></div>
682
+ </div>
683
+ <div style="display:flex;gap:16px;margin-bottom:16px">
684
+ <div style="flex:2;min-width:0">
685
+ <div style="background:#1e3a5f;border-radius:4px;overflow:hidden">
686
+ <table id="cpu-table" style="width:100%;border-collapse:collapse;font-size:13px">
687
+ <thead>
688
+ <tr style="background:#0d2137;color:#9e9e9e;font-size:12px">
689
+ <th style="padding:8px 6px;text-align:center;width:40px;color:#ff9800">Status</th>
690
+ <th style="padding:8px 10px;text-align:left">Task ID</th>
691
+ <th style="padding:8px 10px;text-align:left">Class</th>
692
+ <th style="padding:8px 10px;text-align:center;color:#ff9800">Fail/Rst</th>
693
+ <th style="padding:8px 10px;text-align:center;width:130px;color:#ff9800">CPU</th>
694
+ <th style="padding:8px 10px;text-align:right">Memory</th>
695
+ <th style="padding:8px 10px;text-align:right">PID</th>
696
+ </tr>
697
+ </thead>
698
+ <tbody id="cpu-tbody"></tbody>
699
+ </table>
700
+ </div>
701
+ </div>
702
+ <div id="task-vars-panel" style="flex:1;min-width:280px;background:#1e3a5f;border-radius:4px;padding:12px">
703
+ <div style="color:#9e9e9e;font-size:12px;margin-bottom:12px;font-weight:bold">Task Variables</div>
704
+ <div id="task-vars-content" style="color:#666;font-style:italic">Select a task to view variables</div>
705
+ </div>
706
+ </div>
707
+ </div>
708
+ </div>
709
+ <div id="panel-gconfig" class="panel">
710
+ <div id="gconfig-loading">Loading GConfig...</div>
711
+ <div id="gconfig-content" style="display:none">
712
+ <div class="gconfig-header">
713
+ <span id="gconfig-filepath"></span>
714
+ <button class="btn-refresh" onclick="loadGConfigData()">Refresh</button>
715
+ <button class="btn-expand" onclick="expandAllTree()">Expand All</button>
716
+ <button class="btn-collapse" onclick="collapseAllTree()">Collapse All</button>
717
+ </div>
718
+ <div class="gconfig-body">
719
+ <div class="gconfig-tree-wrap">
720
+ <div id="gconfig-tree" class="tree-container"></div>
721
+ </div>
722
+ <div class="value-setter">
723
+ <h3>Value Setter</h3>
724
+ <div id="setter-content">
725
+ <div class="value-setter-empty">트리에서 값을 선택하세요</div>
726
+ </div>
727
+ </div>
728
+ </div>
729
+ </div>
730
+ </div>
731
+ </div>
732
+ </div>
733
+ <script>
734
+ let refreshTimer = null;
735
+ const checkbox = document.getElementById('autoRefresh');
736
+ const intervalSelect = document.getElementById('refreshInterval');
737
+ const savedEnabled = localStorage.getItem('refreshEnabled');
738
+ const savedInterval = localStorage.getItem('refreshInterval');
739
+ if (savedEnabled !== null) checkbox.checked = savedEnabled === 'true';
740
+ if (savedInterval) intervalSelect.value = savedInterval;
741
+
742
+ function updateStats() {{
743
+ fetch('/api/status').then(r => r.json()).then(data => {{
744
+ document.getElementById('rmi-loading').style.display = 'none';
745
+ document.getElementById('rmi-content').style.display = 'block';
746
+
747
+ let html = '';
748
+ data.tasks.forEach((t, idx) => {{
749
+ const treeId = `rmi-tree-${{idx}}`;
750
+ const isExpanded = localStorage.getItem(`rmi-${{t.id}}`) !== 'false';
751
+ const statusColor = t.status === 'running' ? '#4caf50' : t.status === 'stopped' ? '#f44336' : '#ff9800';
752
+
753
+ html += `<div class="tree-node">
754
+ <div class="tree-parent" onclick="toggleRmiTree('${{treeId}}','${{t.id}}')" style="display:flex;align-items:center;padding:10px 12px;background:#1e3a5f;border-radius:4px;margin-bottom:2px;cursor:pointer">
755
+ <span class="tree-arrow" id="arrow-${{treeId}}" style="margin-right:10px;transition:transform 0.2s">${{isExpanded ? '▼' : '▶'}}</span>
756
+ <span style="flex:1;font-weight:bold;color:#81d4fa">${{t.id}}</span>
757
+ <span style="width:100px;color:#9e9e9e">${{t.class}}</span>
758
+ <span style="width:70px;color:#9e9e9e">${{t.mode}}</span>
759
+ <span style="width:70px;color:${{statusColor}}">${{t.status}}</span>
760
+ <span style="width:140px;text-align:center;color:#e0e0e0">${{t.rmi_min.toFixed(1)}} / ${{t.rmi_avg.toFixed(1)}} / ${{t.rmi_max.toFixed(1)}}</span>
761
+ </div>
762
+ <div class="tree-children" id="${{treeId}}" style="margin-left:28px;display:${{isExpanded ? 'block' : 'none'}}">
763
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
764
+ <span style="width:120px;color:#ffcc80">RMI Count</span>
765
+ <span style="color:#e0e0e0">Rx: ${{t.rmi_rx.toLocaleString()}} / Tx: ${{t.rmi_tx.toLocaleString()}}</span>
766
+ </div>
767
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
768
+ <span style="width:120px;color:#ffcc80">Failures</span>
769
+ <span><span style="color:#f44336">${{t.rmi_fail.toLocaleString()}}</span> RMI / <span style="color:#ff9800">${{t.restart}}</span> Restart</span>
770
+ </div>
771
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
772
+ <span style="width:120px;color:#ffcc80">Queue Size</span>
773
+ <span style="color:#e0e0e0">RxQ: ${{t.rxq_size}} / TxQ: ${{t.txq_size}}</span>
774
+ </div>
775
+ </div>
776
+ </div>`;
777
+ }});
778
+ document.getElementById('rmi-tree').innerHTML = html;
779
+ document.getElementById('info-text').innerHTML =
780
+ `Uptime: ${{data.uptime}} | Tasks: ${{data.running}}/${{data.total}} running | ${{data.timestamp}}`;
781
+ }}).catch(err => {{
782
+ document.getElementById('rmi-loading').textContent = 'Error: ' + err.message;
783
+ document.getElementById('rmi-loading').style.color = '#f44336';
784
+ }});
785
+ }}
786
+
787
+ function toggleRmiTree(treeId, taskId) {{
788
+ const el = document.getElementById(treeId);
789
+ const arrow = document.getElementById('arrow-' + treeId);
790
+ const isHidden = el.style.display === 'none';
791
+ el.style.display = isHidden ? 'block' : 'none';
792
+ arrow.textContent = isHidden ? '▼' : '▶';
793
+ localStorage.setItem(`rmi-${{taskId}}`, isHidden ? 'true' : 'false');
794
+ }}
795
+
796
+ function refreshActivePanel() {{
797
+ const activePanel = localStorage.getItem('activePanel') || 'cpu';
798
+ if (activePanel === 'cpu') loadCpuData();
799
+ else if (activePanel === 'rmi') updateStats();
800
+ else if (activePanel === 'rmicall') loadRmiCallData();
801
+ // cpu, gconfig: no auto-refresh (static data)
802
+ }}
803
+
804
+ function startRefresh() {{
805
+ stopRefresh();
806
+ if (checkbox.checked) {{
807
+ refreshTimer = setInterval(refreshActivePanel, parseInt(intervalSelect.value) * 1000);
808
+ }}
809
+ }}
810
+
811
+ function stopRefresh() {{
812
+ if (refreshTimer) {{ clearInterval(refreshTimer); refreshTimer = null; }}
813
+ }}
814
+
815
+ checkbox.addEventListener('change', () => {{
816
+ localStorage.setItem('refreshEnabled', checkbox.checked);
817
+ startRefresh();
818
+ }});
819
+
820
+ intervalSelect.addEventListener('change', () => {{
821
+ localStorage.setItem('refreshInterval', intervalSelect.value);
822
+ startRefresh();
823
+ }});
824
+
825
+ startRefresh();
826
+
827
+ function clearStats() {{ fetch('/api/clear').then(() => updateStats()); }}
828
+
829
+ function manualRefresh() {{
830
+ const activePanel = localStorage.getItem('activePanel') || 'cpu';
831
+ if (activePanel === 'cpu') loadCpuData();
832
+ else if (activePanel === 'rmi') updateStats();
833
+ else if (activePanel === 'rmicall') loadRmiCallData();
834
+ else if (activePanel === 'gconfig') loadGConfigData();
835
+ }}
836
+
837
+ function toggleSidebar() {{
838
+ const sidebar = document.getElementById('sidebar');
839
+ sidebar.classList.toggle('collapsed');
840
+ localStorage.setItem('sidebarCollapsed', sidebar.classList.contains('collapsed'));
841
+ }}
842
+
843
+ function showPanel(name) {{
844
+ const panel = document.querySelector(`.panel#panel-${{name}}`);
845
+ const navItem = document.querySelector(`.nav-item[data-panel="${{name}}"]`);
846
+ if (!panel || !navItem) {{
847
+ name = 'cpu'; // Fallback to default panel (Task/SM)
848
+ localStorage.setItem('activePanel', name);
849
+ return showPanel(name);
850
+ }}
851
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
852
+ document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
853
+ panel.classList.add('active');
854
+ navItem.classList.add('active');
855
+ localStorage.setItem('activePanel', name);
856
+ // Load panel data on switch
857
+ if (name === 'rmi') updateStats();
858
+ else if (name === 'rmicall') loadRmiCallData();
859
+ else if (name === 'cpu') loadCpuData();
860
+ else if (name === 'gconfig') loadGConfigData();
861
+ // Restart refresh timer for new panel
862
+ startRefresh();
863
+ }}
864
+
865
+ // Restore sidebar state
866
+ if (localStorage.getItem('sidebarCollapsed') === 'true') {{
867
+ document.getElementById('sidebar').classList.add('collapsed');
868
+ }}
869
+
870
+ const savedPanel = localStorage.getItem('activePanel');
871
+ if (savedPanel) showPanel(savedPanel);
872
+
873
+ let cpuTasksData = []; // Store tasks data for selection
874
+ let selectedTaskId = null;
875
+ let systemCpuData = {{cpu: 0, mem: 0}}; // Store system CPU/Memory data
876
+
877
+ function loadCpuData() {{
878
+ fetch('/api/cpu').then(r => r.json()).then(data => {{
879
+ document.getElementById('cpu-loading').style.display = 'none';
880
+ document.getElementById('cpu-content').style.display = 'block';
881
+ if (data.error) {{
882
+ document.getElementById('cpu-tbody').innerHTML = '<tr><td colspan="7" style="color:#f44336;padding:12px">psutil not installed. Run: pip install psutil</td></tr>';
883
+ return;
884
+ }}
885
+ systemCpuData = {{cpu: data.system_cpu, mem: data.system_memory}};
886
+
887
+ cpuTasksData = data.tasks; // Store for selection
888
+ let html = '';
889
+ data.tasks.forEach((t, idx) => {{
890
+ const cpuClass = t.cpu < 30 ? 'cpu-low' : t.cpu < 70 ? 'cpu-mid' : 'cpu-high';
891
+ const statusColor = t.alive ? '#4caf50' : '#f44336';
892
+ const failColor = t.rmi_fail > 0 ? '#f44336' : '#666';
893
+ const restartColor = t.restart > 0 ? '#ff9800' : '#666';
894
+ const isSelected = selectedTaskId === t.id;
895
+ const rowBg = isSelected ? '#2a4a6f' : (idx % 2 === 0 ? '#1e3a5f' : '#1a3050');
896
+ // Mode icon: Process=CPU chip, Thread=branch
897
+ const modeColor = t.mode === 'process' ? '#64b5f6' : '#ce93d8';
898
+ const modeIcon = t.mode === 'process'
899
+ ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="' + modeColor + '" stroke-width="2" title="Process"><rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg>'
900
+ : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="' + modeColor + '" stroke-width="2" title="Thread"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 009 9"/></svg>';
901
+
902
+ html += `<tr onclick="selectTask('${{t.id}}')" style="cursor:pointer;background:${{rowBg}}"
903
+ onmouseover="this.style.background='#2a4a6f'"
904
+ onmouseout="this.style.background='${{isSelected ? '#2a4a6f' : rowBg}}'">
905
+ <td style="padding:8px 6px;text-align:center"><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${{statusColor}}"></span></td>
906
+ <td style="padding:8px 10px;color:#81d4fa;font-weight:bold"><span style="display:inline-flex;align-items:center;gap:6px">${{t.id}}${{modeIcon}}</span></td>
907
+ <td style="padding:8px 10px;color:#9e9e9e">${{t.class || '-'}}</td>
908
+ <td style="padding:8px 10px;text-align:center;font-size:11px"><span style="color:${{failColor}}">${{t.rmi_fail}}</span>/<span style="color:${{restartColor}}">${{t.restart}}</span></td>
909
+ <td style="padding:8px 10px">
910
+ <div style="display:flex;align-items:center;gap:4px">
911
+ <div class="cpu-bar" style="height:12px;width:70px">
912
+ <div class="cpu-bar-fill ${{cpuClass}}" style="width:${{Math.min(t.cpu, 100)}}%"></div>
913
+ </div>
914
+ <span style="color:#e0e0e0;font-size:11px">${{t.cpu.toFixed(1)}}%</span>
915
+ </div>
916
+ </td>
917
+ <td style="padding:8px 10px;text-align:right;color:#9e9e9e">${{t.memory.toFixed(1)}} MB</td>
918
+ <td style="padding:8px 10px;text-align:right;color:#9e9e9e">${{t.pid || '-'}}</td>
919
+ </tr>`;
920
+ }});
921
+
922
+ document.getElementById('cpu-tbody').innerHTML = html;
923
+ loadSmBlockData();
924
+ loadAppInfoData();
925
+
926
+ // Refresh selected task vars if any
927
+ if (selectedTaskId) {{
928
+ const task = cpuTasksData.find(t => t.id === selectedTaskId);
929
+ if (task) showTaskVars(task);
930
+ }}
931
+ }}).catch(err => {{
932
+ document.getElementById('cpu-loading').textContent = 'Error: ' + err.message;
933
+ document.getElementById('cpu-loading').style.color = '#f44336';
934
+ }});
935
+ }}
936
+
937
+ function selectTask(taskId) {{
938
+ selectedTaskId = taskId;
939
+ const task = cpuTasksData.find(t => t.id === taskId);
940
+ if (task) {{
941
+ showTaskVars(task);
942
+ loadCpuData(); // Refresh to show selection highlight
943
+ }}
944
+ }}
945
+
946
+ function showTaskVars(task) {{
947
+ const container = document.getElementById('task-vars-content');
948
+ const vars = task.vars || {{}};
949
+ const varKeys = Object.keys(vars);
950
+
951
+ if (varKeys.length === 0) {{
952
+ container.innerHTML = `<div style="color:#81d4fa;margin-bottom:8px;font-weight:bold">${{task.id}}</div>
953
+ <div style="color:#666;font-style:italic">No config variables</div>`;
954
+ return;
955
+ }}
956
+
957
+ let html = `<div style="color:#81d4fa;margin-bottom:12px;font-weight:bold">${{task.id}}</div>
958
+ <table style="width:100%;border-collapse:collapse;font-size:12px">
959
+ <thead><tr style="background:#0d2137;color:#9e9e9e">
960
+ <th style="padding:6px 8px;text-align:left">Variable</th>
961
+ <th style="padding:6px 8px;text-align:left">Value</th>
962
+ <th style="padding:6px 8px;text-align:center;width:50px"></th>
963
+ </tr></thead><tbody>`;
964
+
965
+ varKeys.forEach((k, idx) => {{
966
+ const val = vars[k];
967
+ const inputId = `cpu-cfg-${{task.id}}-${{k}}`;
968
+ const isBool = typeof val === 'boolean';
969
+ const rowBg = idx % 2 === 0 ? '#1a3050' : '#1e3a5f';
970
+
971
+ if (isBool) {{
972
+ html += `<tr style="background:${{rowBg}}">
973
+ <td style="padding:6px 8px;color:#ffcc80">${{k}}</td>
974
+ <td style="padding:6px 8px">
975
+ <label style="cursor:pointer;margin-right:8px">
976
+ <input type="radio" name="${{inputId}}" value="true" ${{val ? 'checked' : ''}}> <span style="color:#4caf50">T</span>
977
+ </label>
978
+ <label style="cursor:pointer">
979
+ <input type="radio" name="${{inputId}}" value="false" ${{!val ? 'checked' : ''}}> <span style="color:#f44336">F</span>
980
+ </label>
981
+ </td>
982
+ <td style="padding:6px 8px;text-align:center">
983
+ <button class="btn-save" style="padding:2px 6px;font-size:10px" onclick="updateCpuConfigBool('${{task.id}}','${{k}}','${{inputId}}')">Save</button>
984
+ <span id="msg-${{inputId}}" class="msg" style="font-size:10px"></span>
985
+ </td>
986
+ </tr>`;
987
+ }} else {{
988
+ html += `<tr style="background:${{rowBg}}">
989
+ <td style="padding:6px 8px;color:#ffcc80">${{k}}</td>
990
+ <td style="padding:6px 8px">
991
+ <input type="text" id="${{inputId}}" class="config-input" style="width:100%;padding:3px 6px;font-size:11px" value="${{val}}">
992
+ </td>
993
+ <td style="padding:6px 8px;text-align:center">
994
+ <button class="btn-save" style="padding:2px 6px;font-size:10px" onclick="updateCpuConfig('${{task.id}}','${{k}}','${{inputId}}')">Save</button>
995
+ <span id="msg-${{inputId}}" class="msg" style="font-size:10px"></span>
996
+ </td>
997
+ </tr>`;
998
+ }}
999
+ }});
1000
+ html += `</tbody></table>`;
1001
+ container.innerHTML = html;
1002
+ }}
1003
+
1004
+ function updateCpuConfig(taskId, key, inputId) {{
1005
+ const input = document.getElementById(inputId);
1006
+ const msgEl = document.getElementById('msg-' + inputId);
1007
+ fetch('/api/config', {{
1008
+ method: 'POST',
1009
+ headers: {{'Content-Type': 'application/json'}},
1010
+ body: JSON.stringify({{task_id: taskId, key: key, value: input.value}})
1011
+ }}).then(r => r.json()).then(res => {{
1012
+ msgEl.className = res.status === 'ok' ? 'msg msg-ok' : 'msg msg-err';
1013
+ msgEl.textContent = res.status === 'ok' ? 'OK' : (res.message || 'Error');
1014
+ setTimeout(() => {{ msgEl.textContent = ''; }}, 2000);
1015
+ }});
1016
+ }}
1017
+
1018
+ function updateCpuConfigBool(taskId, key, inputId) {{
1019
+ const radios = document.getElementsByName(inputId);
1020
+ const msgEl = document.getElementById('msg-' + inputId);
1021
+ let newVal = false;
1022
+ radios.forEach(r => {{ if (r.checked) newVal = (r.value === 'true'); }});
1023
+ fetch('/api/config', {{
1024
+ method: 'POST',
1025
+ headers: {{'Content-Type': 'application/json'}},
1026
+ body: JSON.stringify({{task_id: taskId, key: key, value: newVal ? 'true' : 'false'}})
1027
+ }}).then(r => r.json()).then(res => {{
1028
+ msgEl.className = res.status === 'ok' ? 'msg msg-ok' : 'msg msg-err';
1029
+ msgEl.textContent = res.status === 'ok' ? 'OK' : (res.message || 'Error');
1030
+ setTimeout(() => {{ msgEl.textContent = ''; }}, 2000);
1031
+ }});
1032
+ }}
1033
+
1034
+ function loadSmBlockData() {{
1035
+ fetch('/api/smblock').then(r => r.json()).then(data => {{
1036
+ const treeEl = document.getElementById('smblock-tree');
1037
+ if (!treeEl) return;
1038
+ if (data.length === 0) {{
1039
+ treeEl.innerHTML = '<div style="color:#666;padding:8px 0">No SmBlock pools configured</div>';
1040
+ return;
1041
+ }}
1042
+
1043
+ let html = '';
1044
+ data.forEach((s, idx) => {{
1045
+ if (s.error) {{
1046
+ html += `<div style="padding:10px 12px;background:#3e2723;border-radius:4px;margin-bottom:2px">
1047
+ <span style="color:#f44336">${{s.name}}: ${{s.error}}</span>
1048
+ </div>`;
1049
+ return;
1050
+ }}
1051
+
1052
+ const treeId = `smblock-tree-${{idx}}`;
1053
+ const isExpanded = localStorage.getItem(`smblock-${{s.name}}`) !== 'false';
1054
+ const usageClass = s.usage_pct > 80 ? 'fail' : s.usage_pct > 50 ? 'warn' : '';
1055
+ const barColor = s.usage_pct > 80 ? '#f44336' : s.usage_pct > 50 ? '#ff9800' : '#4caf50';
1056
+ const users = s.users || [];
1057
+
1058
+ // Parent row: SmBlock summary
1059
+ html += `<div class="tree-node">
1060
+ <div class="tree-parent" onclick="toggleSmBlockTree('${{treeId}}','${{s.name}}')" style="display:flex;align-items:center;padding:10px 12px;background:#1e3a5f;border-radius:4px;margin-bottom:2px;cursor:pointer">
1061
+ <span class="tree-arrow" id="arrow-${{treeId}}" style="margin-right:10px">${{isExpanded ? '▼' : '▶'}}</span>
1062
+ <span style="width:120px;font-weight:bold;color:#81d4fa">${{s.name}}</span>
1063
+ <span style="width:130px;color:#9e9e9e">${{s.shape.join(' x ')}}</span>
1064
+ <span style="width:80px;color:#9e9e9e">max: ${{s.maxsize}}</span>
1065
+ <span style="width:150px">
1066
+ <div style="display:flex;align-items:center;gap:8px">
1067
+ <div class="cpu-bar" style="flex:1;height:14px">
1068
+ <div class="cpu-bar-fill" style="width:${{s.usage_pct}}%;background:${{barColor}}"></div>
1069
+ </div>
1070
+ <span class="num ${{usageClass}}" style="width:45px">${{s.usage_pct}}%</span>
1071
+ </div>
1072
+ </span>
1073
+ <span style="width:80px;text-align:right;color:#9e9e9e">${{s.total_mb}} MB</span>
1074
+ </div>
1075
+ <div class="tree-children" id="${{treeId}}" style="margin-left:28px;display:${{isExpanded ? 'block' : 'none'}}">
1076
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
1077
+ <span style="width:100px;color:#ffcc80">Shape</span>
1078
+ <span style="color:#e0e0e0">${{s.shape.join(' x ')}} (H x W x C)</span>
1079
+ </div>
1080
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
1081
+ <span style="width:100px;color:#ffcc80">MaxSize</span>
1082
+ <span style="color:#e0e0e0">${{s.maxsize}} blocks</span>
1083
+ </div>
1084
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
1085
+ <span style="width:100px;color:#ffcc80">Item Size</span>
1086
+ <span style="color:#e0e0e0">${{s.item_size.toLocaleString()}} bytes (${{(s.item_size / 1024 / 1024).toFixed(2)}} MB)</span>
1087
+ </div>
1088
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
1089
+ <span style="width:100px;color:#ffcc80">Total Size</span>
1090
+ <span style="color:#e0e0e0">${{s.total_mb}} MB</span>
1091
+ </div>
1092
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
1093
+ <span style="width:100px;color:#ffcc80">Used/Free</span>
1094
+ <span><span style="color:#f44336">${{s.used}}</span> / <span style="color:#4caf50">${{s.free}}</span></span>
1095
+ </div>
1096
+ <div style="display:flex;padding:6px 12px;border-bottom:1px solid #333">
1097
+ <span style="width:100px;color:#ffcc80">Users</span>
1098
+ <span style="color:#64b5f6">${{users.length > 0 ? users.join(', ') : '(none)'}}</span>
1099
+ </div>
1100
+ </div>
1101
+ </div>`;
1102
+ }});
1103
+
1104
+ treeEl.innerHTML = html;
1105
+ }}).catch(err => {{
1106
+ const treeEl = document.getElementById('smblock-tree');
1107
+ if (treeEl) treeEl.innerHTML = `<div style="color:#f44336">Error: ${{err.message}}</div>`;
1108
+ }});
1109
+ }}
1110
+
1111
+ function toggleSmBlockTree(treeId, name) {{
1112
+ const el = document.getElementById(treeId);
1113
+ const arrow = document.getElementById('arrow-' + treeId);
1114
+ const isHidden = el.style.display === 'none';
1115
+ el.style.display = isHidden ? 'block' : 'none';
1116
+ arrow.textContent = isHidden ? '▼' : '▶';
1117
+ localStorage.setItem(`smblock-${{name}}`, isHidden ? 'true' : 'false');
1118
+ }}
1119
+
1120
+ function loadAppInfoData() {{
1121
+ fetch('/api/gconfig').then(r => r.json()).then(data => {{
1122
+ const el = document.getElementById('appinfo-content');
1123
+ if (!el) return;
1124
+ const info = data.data?.app_info || {{}};
1125
+ const name = info.name || '-';
1126
+ const version = info.version || '-';
1127
+ const id = info.id || '-';
1128
+ const filepath = data.filepath || '-';
1129
+ const homeDir = data.home_dir || '-';
1130
+ const logDir = data.log_dir || '-';
1131
+
1132
+ const cpuVal = systemCpuData.cpu ? systemCpuData.cpu.toFixed(1) : '-';
1133
+ const memVal = systemCpuData.mem ? systemCpuData.mem.toFixed(1) : '-';
1134
+ el.innerHTML = `
1135
+ <div style="display:flex;flex-wrap:wrap;gap:20px;font-size:13px;margin-bottom:8px">
1136
+ <span><span style="color:#9e9e9e">Name:</span> <strong style="color:#81d4fa">${{name}}</strong></span>
1137
+ <span><span style="color:#9e9e9e">Ver:</span> <span style="color:#e0e0e0">${{version}}</span></span>
1138
+ <span><span style="color:#9e9e9e">ID:</span> <span style="color:#e0e0e0">${{id}}</span></span>
1139
+ <span><span style="color:#9e9e9e">CPU:</span> <strong style="color:#4fc3f7">${{cpuVal}}</strong>%</span>
1140
+ <span><span style="color:#9e9e9e">Memory:</span> <strong style="color:#4fc3f7">${{memVal}}</strong>%</span>
1141
+ <button class="btn-refresh" style="padding:2px 8px;font-size:11px" onclick="loadCpuData()">Refresh</button>
1142
+ </div>
1143
+ <div style="font-size:12px;color:#9e9e9e;line-height:1.6">
1144
+ <div><span style="color:#ffcc80">Config:</span> <span style="color:#666">${{filepath}}</span></div>
1145
+ <div><span style="color:#ffcc80">Home_Dir:</span> <span style="color:#666">${{homeDir}}</span></div>
1146
+ <div><span style="color:#ffcc80">Log_Dir:</span> <span style="color:#666">${{logDir}}</span></div>
1147
+ </div>`;
1148
+ }}).catch(err => {{
1149
+ const el = document.getElementById('appinfo-content');
1150
+ if (el) el.innerHTML = `<div style="color:#f44336">Error: ${{err.message}}</div>`;
1151
+ }});
1152
+ }}
1153
+
1154
+ let rmiCallPrev = {{}}; // taskId:method -> count
1155
+ let rmiCallTime = 0; // last update timestamp
1156
+ let rmiCallCache = null; // cached data for diff check
1157
+
1158
+ function loadRmiCallData() {{
1159
+ fetch('/api/rmi_methods').then(r => r.json()).then(data => {{
1160
+ document.getElementById('rmicall-loading').style.display = 'none';
1161
+ document.getElementById('rmicall-content').style.display = 'block';
1162
+
1163
+ const now = Date.now();
1164
+ const elapsed = rmiCallTime > 0 ? (now - rmiCallTime) / 1000 : 0;
1165
+ rmiCallTime = now;
1166
+
1167
+ document.getElementById('rmicall-interval').textContent = elapsed > 0 ? `Interval: ${{elapsed.toFixed(1)}}s` : 'Interval: -';
1168
+
1169
+ let html = '';
1170
+ let newPrev = {{}};
1171
+
1172
+ data.forEach((task, idx) => {{
1173
+ const methods = Object.entries(task.methods).sort((a, b) => b[1] - a[1]);
1174
+ let taskPrevTotal = 0, taskCurrTotal = 0;
1175
+
1176
+ // Calculate totals
1177
+ methods.forEach(m => {{
1178
+ const key = `${{task.id}}:${{m[0]}}`;
1179
+ const prev = rmiCallPrev[key] || 0;
1180
+ taskPrevTotal += prev;
1181
+ taskCurrTotal += m[1];
1182
+ newPrev[key] = m[1];
1183
+ }});
1184
+
1185
+ const totalDiff = taskCurrTotal - taskPrevTotal;
1186
+ const totalPerSec = elapsed > 0 ? (totalDiff / elapsed) : 0;
1187
+ const treeId = `tree-${{idx}}`;
1188
+ const isExpanded = localStorage.getItem(`rmicall-${{task.id}}`) !== 'false';
1189
+ const statusClass = task.alive ? 'running' : 'stopped';
1190
+ const statusText = task.alive ? 'running' : 'stopped';
1191
+
1192
+ // Parent node: Task/Class/Mode/Status/Sum
1193
+ const hasChildren = methods.length > 0;
1194
+ const arrowHtml = hasChildren ? `<span class="tree-arrow" id="arrow-${{treeId}}" style="margin-right:8px;transition:transform 0.2s">${{isExpanded ? '▼' : '▶'}}</span>` : `<span style="width:20px"></span>`;
1195
+ const clickAttr = hasChildren ? `onclick="toggleTree('${{treeId}}','${{task.id}}')" style="display:flex;align-items:center;padding:8px 12px;background:#1e3a5f;border-radius:4px;margin-bottom:2px;cursor:pointer"` : `style="display:flex;align-items:center;padding:8px 12px;background:#1e3a5f;border-radius:4px;margin-bottom:2px"`;
1196
+
1197
+ html += `<div class="tree-node">
1198
+ <div class="tree-parent" ${{clickAttr}}>
1199
+ ${{arrowHtml}}
1200
+ <span style="flex:1;font-weight:bold">${{task.id}}</span>
1201
+ <span style="width:120px;color:#9e9e9e">${{task.class}}</span>
1202
+ <span style="width:70px;color:#9e9e9e">${{task.mode || '-'}}</span>
1203
+ <span style="width:70px" class="${{statusClass}}">${{statusText}}</span>
1204
+ <span style="width:80px;text-align:right;color:#4fc3f7">${{taskCurrTotal.toLocaleString()}}</span>
1205
+ <span style="width:80px;text-align:right;color:${{totalPerSec > 0 ? '#4caf50' : '#666'}}">${{totalPerSec.toFixed(1)}}/s</span>
1206
+ </div>`;
1207
+
1208
+ if (hasChildren) {{
1209
+ const timing = task.methods_timing || {{}};
1210
+ html += `<div class="tree-children" id="${{treeId}}" style="margin-left:20px;display:${{isExpanded ? 'block' : 'none'}}">`;
1211
+ // Header for method details
1212
+ html += `<div style="display:flex;align-items:center;padding:4px 12px;border-bottom:1px solid #444;color:#9e9e9e;font-size:11px">
1213
+ <span style="width:28px"></span>
1214
+ <span style="flex:1">Method</span>
1215
+ <span style="width:60px;text-align:right">Prev</span>
1216
+ <span style="width:60px;text-align:right">Curr</span>
1217
+ <span style="width:60px;text-align:right">Rate</span>
1218
+ <span style="width:140px;text-align:center;color:#ff9800">IPC (min/avg/max)</span>
1219
+ <span style="width:140px;text-align:center;color:#ff9800">FUNC (min/avg/max)</span>
1220
+ </div>`;
1221
+ methods.forEach(m => {{
1222
+ const key = `${{task.id}}:${{m[0]}}`;
1223
+ const methodName = m[0];
1224
+ const curr = m[1];
1225
+ const prev = rmiCallPrev[key] || 0;
1226
+ const diff = curr - prev;
1227
+ const perSec = elapsed > 0 ? (diff / elapsed) : 0;
1228
+
1229
+ const isOneWay = methodName.startsWith('on_');
1230
+ const wayBadge = isOneWay
1231
+ ? '<span style="background:#ff9800;color:#000;padding:1px 5px;border-radius:3px;font-size:10px;margin-right:6px">1W</span>'
1232
+ : '<span style="background:#2196f3;color:#fff;padding:1px 5px;border-radius:3px;font-size:10px;margin-right:6px">2W</span>';
1233
+
1234
+ // Get timing data for this method
1235
+ const t = timing[methodName];
1236
+ let ipcStr = '-';
1237
+ let funcStr = '-';
1238
+ if (t && t.ipc && t.func) {{
1239
+ ipcStr = `${{t.ipc.min}}/${{t.ipc.avg}}/${{t.ipc.max}}`;
1240
+ funcStr = `${{t.func.min}}/${{t.func.avg}}/${{t.func.max}}`;
1241
+ }}
1242
+
1243
+ html += `<div style="display:flex;align-items:center;padding:4px 12px;border-bottom:1px solid #333">
1244
+ ${{wayBadge}}
1245
+ <span style="flex:1">${{methodName}}</span>
1246
+ <span style="width:60px;text-align:right;color:#9e9e9e">${{prev.toLocaleString()}}</span>
1247
+ <span style="width:60px;text-align:right">${{curr.toLocaleString()}}</span>
1248
+ <span style="width:60px;text-align:right;color:${{perSec > 0 ? '#4caf50' : '#666'}}">${{perSec.toFixed(1)}}/s</span>
1249
+ <span style="width:140px;text-align:center;color:#81d4fa">${{ipcStr}}</span>
1250
+ <span style="width:140px;text-align:center;color:#a5d6a7">${{funcStr}}</span>
1251
+ </div>`;
1252
+ }});
1253
+ html += `</div>`;
1254
+ }}
1255
+ html += `</div>`;
1256
+ }});
1257
+
1258
+ rmiCallPrev = newPrev;
1259
+ document.getElementById('rmicall-tree').innerHTML = html;
1260
+ updateMeasurementStatus();
1261
+ }});
1262
+ }}
1263
+
1264
+ function toggleTree(treeId, taskId) {{
1265
+ const el = document.getElementById(treeId);
1266
+ const arrow = document.getElementById('arrow-' + treeId);
1267
+ const isHidden = el.style.display === 'none';
1268
+ el.style.display = isHidden ? 'block' : 'none';
1269
+ arrow.textContent = isHidden ? '▼' : '▶';
1270
+ localStorage.setItem(`rmicall-${{taskId}}`, isHidden ? 'true' : 'false');
1271
+ }}
1272
+
1273
+ function updateMeasurementStatus() {{
1274
+ fetch('/api/measurement').then(r => r.json()).then(data => {{
1275
+ const btn = document.getElementById('measurement-toggle');
1276
+ const status = document.getElementById('measurement-status');
1277
+ if (data.enabled) {{
1278
+ btn.style.background = '#4caf50';
1279
+ status.textContent = 'ON';
1280
+ }} else {{
1281
+ btn.style.background = '#f44336';
1282
+ status.textContent = 'OFF';
1283
+ }}
1284
+ }});
1285
+ }}
1286
+
1287
+ function toggleMeasurement() {{
1288
+ fetch('/api/measurement', {{method: 'POST', headers: {{'Content-Type': 'application/json'}}, body: '{{}}'}})
1289
+ .then(r => r.json())
1290
+ .then(data => {{
1291
+ if (data.status === 'ok') {{
1292
+ updateMeasurementStatus();
1293
+ }}
1294
+ }});
1295
+ }}
1296
+
1297
+ function loadGConfigData() {{
1298
+ fetch('/api/gconfig').then(r => r.json()).then(data => {{
1299
+ document.getElementById('gconfig-loading').style.display = 'none';
1300
+ document.getElementById('gconfig-content').style.display = 'block';
1301
+ if (data.error) {{
1302
+ document.getElementById('gconfig-tree').innerHTML = `<p style="color:#f44336">${{data.error}}</p>`;
1303
+ return;
1304
+ }}
1305
+ document.getElementById('gconfig-filepath').textContent = data.filepath || 'No file';
1306
+ document.getElementById('gconfig-tree').innerHTML = renderTree(data.data, '');
1307
+ // Reset value setter
1308
+ selectedPath = null;
1309
+ selectedVtype = null;
1310
+ document.getElementById('setter-content').innerHTML = '<div class="value-setter-empty">트리에서 값을 선택하세요</div>';
1311
+ }});
1312
+ }}
1313
+
1314
+ function renderTree(obj, path, depth=0) {{
1315
+ const keyClass = `tree-key tree-key-${{Math.min(depth, 4)}}`;
1316
+ if (obj === null) return `<span class="tree-val-null tree-val-edit" onclick="editGConfig('${{path}}', null, 'null')">null</span>`;
1317
+ if (typeof obj === 'boolean') return `<span class="tree-val-bool tree-val-edit" onclick="editGConfig('${{path}}', ${{obj}}, 'bool')">${{obj}}</span>`;
1318
+ if (typeof obj === 'number') return `<span class="tree-val-num tree-val-edit" onclick="editGConfig('${{path}}', ${{obj}}, 'num')">${{obj}}</span>`;
1319
+ if (typeof obj === 'string') return `<span class="tree-val-str tree-val-edit" onclick="editGConfig('${{path}}', '${{escapeHtml(obj).replace(/'/g, "\\\\'")}}', 'str')">"${{escapeHtml(obj)}}"</span>`;
1320
+ if (Array.isArray(obj)) {{
1321
+ if (obj.length === 0) return '<span class="tree-bracket">[]</span>';
1322
+ const id = 'tree-' + Math.random().toString(36).substr(2, 9);
1323
+ let html = `<span class="tree-expanded" id="${{id}}"><span class="tree-toggle" onclick="toggleTree('${{id}}')" title="Click to collapse"></span><span class="tree-bracket">[</span>`;
1324
+ obj.forEach((v, i) => {{
1325
+ const childPath = path ? `${{path}}[${{i}}]` : `[${{i}}]`;
1326
+ html += `<div class="tree-node"><span class="${{keyClass}}">${{i}}</span>: ${{renderTree(v, childPath, depth+1)}}${{i < obj.length - 1 ? ',' : ''}}</div>`;
1327
+ }});
1328
+ html += '<span class="tree-bracket">]</span></span>';
1329
+ return html;
1330
+ }}
1331
+ if (typeof obj === 'object') {{
1332
+ const keys = Object.keys(obj);
1333
+ if (keys.length === 0) return '<span class="tree-bracket">{{}}</span>';
1334
+ const id = 'tree-' + Math.random().toString(36).substr(2, 9);
1335
+ let html = `<span class="tree-expanded" id="${{id}}"><span class="tree-toggle" onclick="toggleTree('${{id}}')" title="Click to collapse"></span><span class="tree-bracket">{{</span>`;
1336
+ keys.forEach((k, i) => {{
1337
+ const childPath = path ? `${{path}}.${{k}}` : k;
1338
+ html += `<div class="tree-node"><span class="${{keyClass}}">"${{escapeHtml(k)}}"</span>: ${{renderTree(obj[k], childPath, depth+1)}}${{i < keys.length - 1 ? ',' : ''}}</div>`;
1339
+ }});
1340
+ html += '<span class="tree-bracket">}}</span></span>';
1341
+ return html;
1342
+ }}
1343
+ return String(obj);
1344
+ }}
1345
+
1346
+ let selectedPath = null;
1347
+ let selectedVtype = null;
1348
+
1349
+ function formatPath(path) {{
1350
+ // Convert "a.b.c" to "(a).(b).(c)"
1351
+ return path.split('.').map(p => `(${{p}})`).join('.');
1352
+ }}
1353
+
1354
+ function editGConfig(path, value, vtype) {{
1355
+ // Remove previous selection highlight
1356
+ document.querySelectorAll('.tree-val-edit.selected').forEach(el => el.classList.remove('selected'));
1357
+ // Highlight current selection
1358
+ event.target.classList.add('selected');
1359
+
1360
+ selectedPath = path;
1361
+ selectedVtype = vtype;
1362
+ const displayVal = vtype === 'str' ? value : (value === null ? '' : value);
1363
+ const typeLabel = {{'str': 'String', 'num': 'Number', 'bool': 'Boolean', 'null': 'Null'}}[vtype] || vtype;
1364
+ const displayPath = formatPath(path);
1365
+
1366
+ // Check if this is a task_config path (RMI will be triggered)
1367
+ const parts = path.split('.');
1368
+ const isTaskConfig = path.startsWith('task_config.') && parts.length >= 3 && parts[1] !== '_monitor';
1369
+ const rmiNotice = isTaskConfig ? '<div class="setter-rmi-notice">📡 task_config 변경 시 실행중인 프로세스 변수도 RMI로 함께 변경됩니다</div>' : '';
1370
+ const btnText = isTaskConfig ? '파일 + RMI 변경' : '변경';
1371
+
1372
+ document.getElementById('setter-content').innerHTML = `
1373
+ ${{rmiNotice}}
1374
+ <div class="setter-row">
1375
+ <div class="setter-label">Path (data_id)</div>
1376
+ <div class="setter-path">${{escapeHtml(displayPath)}}</div>
1377
+ </div>
1378
+ <div class="setter-row">
1379
+ <div class="setter-label">Value</div>
1380
+ <input type="text" class="setter-input" id="setter-value" value="${{displayVal}}" onkeydown="if(event.key==='Enter')applySetterValue();">
1381
+ <div class="setter-type">Type: ${{typeLabel}}</div>
1382
+ </div>
1383
+ <button class="btn-change" onclick="applySetterValue()">${{btnText}}</button>
1384
+ <div id="setter-msg"></div>
1385
+ `;
1386
+ document.getElementById('setter-value').focus();
1387
+ document.getElementById('setter-value').select();
1388
+ }}
1389
+
1390
+ function applySetterValue() {{
1391
+ if (!selectedPath) return;
1392
+ const inputEl = document.getElementById('setter-value');
1393
+ let value = inputEl.value;
1394
+ if (selectedVtype === 'null' && value === '') value = 'null';
1395
+ const msgEl = document.getElementById('setter-msg');
1396
+
1397
+ // 1. Save to gconfig file
1398
+ fetch('/api/gconfig', {{
1399
+ method: 'POST',
1400
+ headers: {{'Content-Type': 'application/json'}},
1401
+ body: JSON.stringify({{path: selectedPath, value: value, vtype: selectedVtype}})
1402
+ }}).then(r => r.json()).then(res => {{
1403
+ if (res.status === 'ok') {{
1404
+ // 2. If task_config path, also update running process via RMI
1405
+ if (selectedPath.startsWith('task_config.')) {{
1406
+ const parts = selectedPath.split('.');
1407
+ // task_config.process1/aaa.counter -> taskId=process1, key=counter
1408
+ // Note: TaskManager uses "process1/aaa".split('/')[0] as task_id
1409
+ if (parts.length >= 3) {{
1410
+ const taskId = parts[1].split('/')[0];
1411
+ const key = parts.slice(2).join('.');
1412
+ // Skip _monitor section
1413
+ if (taskId !== '_monitor') {{
1414
+ fetch('/api/config', {{
1415
+ method: 'POST',
1416
+ headers: {{'Content-Type': 'application/json'}},
1417
+ body: JSON.stringify({{task_id: taskId, key: key, value: value}})
1418
+ }}).then(r => r.json()).then(rmiRes => {{
1419
+ if (rmiRes.status === 'ok') {{
1420
+ msgEl.className = 'setter-msg setter-msg-ok';
1421
+ msgEl.textContent = '파일+프로세스 변경 완료!';
1422
+ }} else {{
1423
+ msgEl.className = 'setter-msg setter-msg-ok';
1424
+ msgEl.textContent = '파일 변경 완료 (RMI: ' + (rmiRes.message || 'error') + ')';
1425
+ }}
1426
+ setTimeout(() => loadGConfigData(), 500);
1427
+ }});
1428
+ return;
1429
+ }}
1430
+ }}
1431
+ }}
1432
+ msgEl.className = 'setter-msg setter-msg-ok';
1433
+ msgEl.textContent = '변경 완료!';
1434
+ setTimeout(() => loadGConfigData(), 500);
1435
+ }} else {{
1436
+ msgEl.className = 'setter-msg setter-msg-err';
1437
+ msgEl.textContent = res.message || 'Error';
1438
+ }}
1439
+ }});
1440
+ }}
1441
+
1442
+ function saveGConfig(path, vtype, inputEl) {{
1443
+ let value = inputEl.value;
1444
+ if (vtype === 'null' && value === '') value = 'null';
1445
+ fetch('/api/gconfig', {{
1446
+ method: 'POST',
1447
+ headers: {{'Content-Type': 'application/json'}},
1448
+ body: JSON.stringify({{path: path, value: value}})
1449
+ }}).then(r => r.json()).then(res => {{
1450
+ if (res.status === 'ok') {{
1451
+ loadGConfigData();
1452
+ }} else {{
1453
+ const msgEl = document.getElementById('msg-' + path.replace(/\\./g, '-'));
1454
+ if (msgEl) {{ msgEl.textContent = res.message || 'Error'; msgEl.style.color = '#f44336'; }}
1455
+ }}
1456
+ }});
1457
+ }}
1458
+
1459
+ function escapeHtml(str) {{
1460
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1461
+ }}
1462
+
1463
+ function toggleTree(id) {{
1464
+ const el = document.getElementById(id);
1465
+ if (el.classList.contains('tree-expanded')) {{
1466
+ el.classList.remove('tree-expanded');
1467
+ el.classList.add('tree-collapsed');
1468
+ }} else {{
1469
+ el.classList.remove('tree-collapsed');
1470
+ el.classList.add('tree-expanded');
1471
+ }}
1472
+ }}
1473
+
1474
+ function expandAllTree() {{
1475
+ document.querySelectorAll('.tree-collapsed').forEach(el => {{
1476
+ el.classList.remove('tree-collapsed');
1477
+ el.classList.add('tree-expanded');
1478
+ }});
1479
+ }}
1480
+
1481
+ function collapseAllTree() {{
1482
+ document.querySelectorAll('.tree-expanded').forEach(el => {{
1483
+ el.classList.remove('tree-expanded');
1484
+ el.classList.add('tree-collapsed');
1485
+ }});
1486
+ }}
1487
+ </script>
1488
+ </body></html>'''
1489
+
1490
+
1491
+ class TaskMonitor:
1492
+ """웹 기반 TASK 모니터"""
1493
+ __slots__ = ('_manager', '_port', '_server', '_thread', '_running', '_gconfig')
1494
+
1495
+ def __init__(self, manager: TaskManager, port: int = 8080, gconfig=None):
1496
+ self._manager = manager
1497
+ self._port = port
1498
+ self._server = None
1499
+ self._thread = None
1500
+ self._running = False
1501
+ self._gconfig = gconfig
1502
+
1503
+ def start(self):
1504
+ MonitorHandler.manager = self._manager
1505
+ MonitorHandler.start_time = time.time()
1506
+ MonitorHandler.gconfig = self._gconfig
1507
+ self._server = HTTPServer(('0.0.0.0', self._port), MonitorHandler)
1508
+ self._running = True
1509
+ self._thread = threading.Thread(target=self._serve, daemon=True)
1510
+ self._thread.start()
1511
+ print(f"[Monitor] http://localhost:{self._port}")
1512
+
1513
+ def _serve(self):
1514
+ while self._running:
1515
+ self._server.handle_request()
1516
+
1517
+ def stop(self):
1518
+ print("[Monitor:stop] called")
1519
+ self._running = False
1520
+ if self._server:
1521
+ print("[Monitor:stop] calling server.shutdown()...")
1522
+ self._server.shutdown()
1523
+ print("[Monitor:stop] server.shutdown() completed")
1524
+ print("[Monitor] stopped")
1525
+
1526
+ @property
1527
+ def port(self) -> int: return self._port
1528
+
1529
+ @property
1530
+ def url(self) -> str: return f"http://localhost:{self._port}"
1531
+
1532
+ def __enter__(self): self.start(); return self
1533
+ def __exit__(self, *_): self.stop(); return False