refactor(dashboard): split api.py into handler modules
This commit is contained in:
378
dashboard/handlers/eco.py
Normal file
378
dashboard/handlers/eco.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Echo Core (eco) service + session + doctor endpoints."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import constants
|
||||
|
||||
|
||||
class EcoHandlers:
|
||||
"""Mixin for /api/eco/* endpoints."""
|
||||
|
||||
# ── /api/eco/status ─────────────────────────────────────────
|
||||
def handle_eco_status(self):
|
||||
"""Get status of echo-core services + active sessions."""
|
||||
try:
|
||||
services = []
|
||||
for svc in constants.ECO_SERVICES:
|
||||
info = {'name': svc, 'active': False, 'pid': None, 'uptime': None, 'memory': None}
|
||||
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'is-active', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
info['active'] = result.stdout.strip() == 'active'
|
||||
|
||||
if info['active']:
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'show', '-p', 'MainPID', '--value', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
pid = result.stdout.strip()
|
||||
if pid and pid != '0':
|
||||
info['pid'] = int(pid)
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['systemctl', '--user', 'show', '-p', 'ActiveEnterTimestamp', '--value', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
ts = r.stdout.strip()
|
||||
if ts:
|
||||
start = datetime.strptime(ts, '%a %Y-%m-%d %H:%M:%S %Z')
|
||||
info['uptime'] = int((datetime.utcnow() - start).total_seconds())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
for line in Path(f'/proc/{pid}/status').read_text().splitlines():
|
||||
if line.startswith('VmRSS:'):
|
||||
info['memory'] = line.split(':')[1].strip()
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
services.append(info)
|
||||
|
||||
self.send_json({'services': services})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
# ── sessions ────────────────────────────────────────────────
|
||||
def _eco_channel_map(self):
|
||||
"""Build channel_id -> {name, platform, is_group} from config.json."""
|
||||
config_file = constants.ECHO_CORE_DIR / 'config.json'
|
||||
m = {}
|
||||
try:
|
||||
cfg = json.loads(config_file.read_text())
|
||||
for name, ch in cfg.get('channels', {}).items():
|
||||
m[str(ch['id'])] = {'name': name, 'platform': 'discord'}
|
||||
for name, ch in cfg.get('telegram_channels', {}).items():
|
||||
m[str(ch['id'])] = {'name': name, 'platform': 'telegram'}
|
||||
for name, ch in cfg.get('whatsapp_channels', {}).items():
|
||||
m[str(ch['id'])] = {'name': name, 'platform': 'whatsapp', 'is_group': True}
|
||||
for admin_id in cfg.get('bot', {}).get('admins', []):
|
||||
m.setdefault(str(admin_id), {'name': 'TG DM', 'platform': 'telegram'})
|
||||
wa_owner = cfg.get('whatsapp', {}).get('owner', '')
|
||||
if wa_owner:
|
||||
m.setdefault(f'wa-{wa_owner}', {'name': 'WA Owner', 'platform': 'whatsapp'})
|
||||
except Exception:
|
||||
pass
|
||||
return m
|
||||
|
||||
def _eco_enrich_sessions(self):
|
||||
"""Return enriched sessions list sorted by last_message_at desc."""
|
||||
raw = {}
|
||||
if constants.ECHO_SESSIONS_FILE.exists():
|
||||
try:
|
||||
raw = json.loads(constants.ECHO_SESSIONS_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
cmap = self._eco_channel_map()
|
||||
sessions = []
|
||||
if isinstance(raw, dict):
|
||||
for ch_id, sdata in raw.items():
|
||||
if 'MagicMock' in ch_id:
|
||||
continue
|
||||
entry = dict(sdata) if isinstance(sdata, dict) else {}
|
||||
entry['channel_id'] = ch_id
|
||||
if ch_id in cmap:
|
||||
entry['platform'] = cmap[ch_id]['platform']
|
||||
entry['channel_name'] = cmap[ch_id]['name']
|
||||
entry['is_group'] = cmap[ch_id].get('is_group', False)
|
||||
elif ch_id.startswith('wa-') or '@g.us' in ch_id or '@s.whatsapp.net' in ch_id:
|
||||
entry['platform'] = 'whatsapp'
|
||||
entry['is_group'] = '@g.us' in ch_id
|
||||
entry['channel_name'] = ('WA Grup' if entry['is_group'] else 'WA DM')
|
||||
elif ch_id.isdigit() and len(ch_id) >= 17:
|
||||
entry['platform'] = 'discord'
|
||||
entry['channel_name'] = 'Discord #' + ch_id[-6:]
|
||||
elif ch_id.isdigit():
|
||||
entry['platform'] = 'telegram'
|
||||
entry['channel_name'] = 'TG ' + ch_id
|
||||
else:
|
||||
entry['platform'] = 'unknown'
|
||||
entry['channel_name'] = ch_id[:20]
|
||||
sessions.append(entry)
|
||||
sessions.sort(key=lambda s: s.get('last_message_at', ''), reverse=True)
|
||||
return sessions
|
||||
|
||||
def handle_eco_sessions(self):
|
||||
"""Return enriched sessions list."""
|
||||
try:
|
||||
self.send_json({'sessions': self._eco_enrich_sessions()})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_session_content(self):
|
||||
"""Return conversation messages from a Claude session transcript."""
|
||||
try:
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
session_id = params.get('id', [''])[0]
|
||||
if not session_id or '/' in session_id or '..' in session_id:
|
||||
self.send_json({'error': 'Invalid session id'}, 400)
|
||||
return
|
||||
|
||||
transcript = Path.home() / '.claude' / 'projects' / '-home-moltbot-echo-core' / f'{session_id}.jsonl'
|
||||
if not transcript.exists():
|
||||
self.send_json({'messages': [], 'error': 'Transcript not found'})
|
||||
return
|
||||
|
||||
messages = []
|
||||
for line in transcript.read_text().splitlines():
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
t = d.get('type', '')
|
||||
if t == 'user':
|
||||
msg = d.get('message', {})
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, str):
|
||||
text = content.replace('[EXTERNAL CONTENT]\n', '').replace('\n[END EXTERNAL CONTENT]', '').strip()
|
||||
if text:
|
||||
messages.append({'role': 'user', 'text': text[:2000]})
|
||||
elif t == 'assistant':
|
||||
msg = d.get('message', {})
|
||||
content = msg.get('content', '')
|
||||
if isinstance(content, list):
|
||||
parts = [block['text'] for block in content if block.get('type') == 'text']
|
||||
text = '\n'.join(parts).strip()
|
||||
if text:
|
||||
messages.append({'role': 'assistant', 'text': text[:2000]})
|
||||
elif isinstance(content, str) and content.strip():
|
||||
messages.append({'role': 'assistant', 'text': content[:2000]})
|
||||
|
||||
self.send_json({'messages': messages})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_sessions_clear(self):
|
||||
"""Clear active sessions (all or specific channel)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
channel = data.get('channel', None)
|
||||
|
||||
if not constants.ECHO_SESSIONS_FILE.exists():
|
||||
self.send_json({'success': True, 'message': 'No sessions file'})
|
||||
return
|
||||
|
||||
if channel:
|
||||
sessions = json.loads(constants.ECHO_SESSIONS_FILE.read_text())
|
||||
if isinstance(sessions, list):
|
||||
sessions = [s for s in sessions if s.get('channel') != channel]
|
||||
elif isinstance(sessions, dict):
|
||||
sessions.pop(channel, None)
|
||||
constants.ECHO_SESSIONS_FILE.write_text(json.dumps(sessions, indent=2))
|
||||
self.send_json({'success': True, 'message': f'Cleared session: {channel}'})
|
||||
else:
|
||||
if isinstance(json.loads(constants.ECHO_SESSIONS_FILE.read_text()), list):
|
||||
constants.ECHO_SESSIONS_FILE.write_text('[]')
|
||||
else:
|
||||
constants.ECHO_SESSIONS_FILE.write_text('{}')
|
||||
self.send_json({'success': True, 'message': 'All sessions cleared'})
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
# ── logs + doctor ───────────────────────────────────────────
|
||||
def handle_eco_logs(self):
|
||||
"""Return last N lines from echo-core.log."""
|
||||
try:
|
||||
params = parse_qs(urlparse(self.path).query)
|
||||
lines = min(int(params.get('lines', ['100'])[0]), 500)
|
||||
|
||||
if not constants.ECHO_LOG_FILE.exists():
|
||||
self.send_json({'lines': ['(log file not found)']})
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
['tail', '-n', str(lines), str(constants.ECHO_LOG_FILE)],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
self.send_json({'lines': result.stdout.splitlines()})
|
||||
except Exception as e:
|
||||
self.send_json({'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_doctor(self):
|
||||
"""Run health checks on the echo-core ecosystem."""
|
||||
checks = []
|
||||
|
||||
# 1. Services
|
||||
for svc in constants.ECO_SERVICES:
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['systemctl', '--user', 'is-active', svc],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
active = r.stdout.strip() == 'active'
|
||||
checks.append({
|
||||
'name': f'Service: {svc}',
|
||||
'pass': active,
|
||||
'detail': 'active' if active else r.stdout.strip(),
|
||||
})
|
||||
except Exception as e:
|
||||
checks.append({'name': f'Service: {svc}', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 2. Disk space
|
||||
try:
|
||||
st = shutil.disk_usage('/')
|
||||
pct_free = (st.free / st.total) * 100
|
||||
checks.append({
|
||||
'name': 'Disk space',
|
||||
'pass': pct_free > 5,
|
||||
'detail': f'{pct_free:.1f}% free ({st.free // (1024**3)} GB)',
|
||||
})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Disk space', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 3. Log file
|
||||
try:
|
||||
if constants.ECHO_LOG_FILE.exists():
|
||||
size_mb = constants.ECHO_LOG_FILE.stat().st_size / (1024 * 1024)
|
||||
checks.append({
|
||||
'name': 'Log file',
|
||||
'pass': size_mb < 100,
|
||||
'detail': f'{size_mb:.1f} MB',
|
||||
})
|
||||
else:
|
||||
checks.append({'name': 'Log file', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Log file', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 4. Sessions file
|
||||
try:
|
||||
if constants.ECHO_SESSIONS_FILE.exists():
|
||||
data = json.loads(constants.ECHO_SESSIONS_FILE.read_text())
|
||||
count = len(data) if isinstance(data, list) else len(data.keys()) if isinstance(data, dict) else 0
|
||||
checks.append({'name': 'Sessions file', 'pass': True, 'detail': f'{count} active'})
|
||||
else:
|
||||
checks.append({'name': 'Sessions file', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Sessions file', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 5. Config
|
||||
config_file = constants.ECHO_CORE_DIR / 'config.json'
|
||||
try:
|
||||
if config_file.exists():
|
||||
json.loads(config_file.read_text())
|
||||
checks.append({'name': 'Config', 'pass': True, 'detail': 'Valid JSON'})
|
||||
else:
|
||||
checks.append({'name': 'Config', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Config', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 6. WhatsApp bridge log
|
||||
wa_log = constants.ECHO_CORE_DIR / 'logs' / 'whatsapp-bridge.log'
|
||||
try:
|
||||
if wa_log.exists():
|
||||
r = subprocess.run(['tail', '-1', str(wa_log)], capture_output=True, text=True, timeout=5)
|
||||
last = r.stdout.strip()
|
||||
has_error = 'error' in last.lower() or 'fatal' in last.lower()
|
||||
checks.append({
|
||||
'name': 'WhatsApp bridge log',
|
||||
'pass': not has_error,
|
||||
'detail': last[:80] if last else 'Empty',
|
||||
})
|
||||
else:
|
||||
checks.append({'name': 'WhatsApp bridge log', 'pass': False, 'detail': 'Not found'})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'WhatsApp bridge log', 'pass': False, 'detail': str(e)})
|
||||
|
||||
# 7. Claude CLI
|
||||
try:
|
||||
r = subprocess.run(['which', 'claude'], capture_output=True, text=True, timeout=5)
|
||||
found = r.returncode == 0
|
||||
checks.append({
|
||||
'name': 'Claude CLI',
|
||||
'pass': found,
|
||||
'detail': r.stdout.strip() if found else 'Not in PATH',
|
||||
})
|
||||
except Exception as e:
|
||||
checks.append({'name': 'Claude CLI', 'pass': False, 'detail': str(e)})
|
||||
|
||||
self.send_json({'checks': checks})
|
||||
|
||||
# ── service control ─────────────────────────────────────────
|
||||
def handle_eco_restart(self):
|
||||
"""Restart an echo-core service (not the taskboard itself)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
svc = data.get('service', '')
|
||||
|
||||
if svc not in constants.ECO_SERVICES:
|
||||
self.send_json({'success': False, 'error': f'Unknown service: {svc}'}, 400)
|
||||
return
|
||||
if svc == 'echo-taskboard':
|
||||
self.send_json({'success': False, 'error': 'Cannot restart taskboard from itself'}, 400)
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'restart', svc],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.send_json({'success': True, 'message': f'{svc} restarted'})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': result.stderr.strip()}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_stop(self):
|
||||
"""Stop an echo-core service (not the taskboard itself)."""
|
||||
try:
|
||||
data = self._read_post_json()
|
||||
svc = data.get('service', '')
|
||||
|
||||
if svc not in constants.ECO_SERVICES:
|
||||
self.send_json({'success': False, 'error': f'Unknown service: {svc}'}, 400)
|
||||
return
|
||||
if svc == 'echo-taskboard':
|
||||
self.send_json({'success': False, 'error': 'Cannot stop taskboard from itself'}, 400)
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
['systemctl', '--user', 'stop', svc],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
self.send_json({'success': True, 'message': f'{svc} stopped'})
|
||||
else:
|
||||
self.send_json({'success': False, 'error': result.stderr.strip()}, 500)
|
||||
except Exception as e:
|
||||
self.send_json({'success': False, 'error': str(e)}, 500)
|
||||
|
||||
def handle_eco_restart_taskboard(self):
|
||||
"""Restart the taskboard itself. Sends response then exits; systemd restarts."""
|
||||
import threading
|
||||
self.send_json({'success': True, 'message': 'Restarting taskboard in 1s...'})
|
||||
|
||||
def _exit():
|
||||
import time
|
||||
time.sleep(1)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_exit, daemon=True).start()
|
||||
Reference in New Issue
Block a user