Update ashboard, dashboard (~2)
This commit is contained in:
124
dashboard/api.py
124
dashboard/api.py
@@ -303,6 +303,10 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
self.handle_workspace_logs()
|
self.handle_workspace_logs()
|
||||||
elif self.path == '/api/eco/status' or self.path.startswith('/api/eco/status?'):
|
elif self.path == '/api/eco/status' or self.path.startswith('/api/eco/status?'):
|
||||||
self.handle_eco_status()
|
self.handle_eco_status()
|
||||||
|
elif self.path == '/api/eco/sessions' or self.path.startswith('/api/eco/sessions?'):
|
||||||
|
self.handle_eco_sessions()
|
||||||
|
elif self.path.startswith('/api/eco/sessions/content'):
|
||||||
|
self.handle_eco_session_content()
|
||||||
elif self.path.startswith('/api/eco/logs'):
|
elif self.path.startswith('/api/eco/logs'):
|
||||||
self.handle_eco_logs()
|
self.handle_eco_logs()
|
||||||
elif self.path == '/api/eco/doctor':
|
elif self.path == '/api/eco/doctor':
|
||||||
@@ -2016,15 +2020,119 @@ class TaskBoardHandler(SimpleHTTPRequestHandler):
|
|||||||
|
|
||||||
services.append(info)
|
services.append(info)
|
||||||
|
|
||||||
# Active sessions
|
self.send_json({'services': services})
|
||||||
sessions = []
|
except Exception as e:
|
||||||
if ECHO_SESSIONS_FILE.exists():
|
self.send_json({'error': str(e)}, 500)
|
||||||
try:
|
|
||||||
sessions = json.loads(ECHO_SESSIONS_FILE.read_text())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
self.send_json({'services': services, 'sessions': sessions})
|
def _eco_channel_map(self):
|
||||||
|
"""Build channel_id -> {name, platform, is_group} from config.json."""
|
||||||
|
config_file = 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': f'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 ECHO_SESSIONS_FILE.exists():
|
||||||
|
try:
|
||||||
|
raw = json.loads(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 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):
|
||||||
|
# Strip [EXTERNAL CONTENT] wrappers
|
||||||
|
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 = []
|
||||||
|
for block in content:
|
||||||
|
if block.get('type') == 'text':
|
||||||
|
parts.append(block['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:
|
except Exception as e:
|
||||||
self.send_json({'error': str(e)}, 500)
|
self.send_json({'error': str(e)}, 500)
|
||||||
|
|
||||||
|
|||||||
@@ -34,9 +34,9 @@
|
|||||||
margin-top: var(--space-1);
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section */
|
/* Section (collapsible) */
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: var(--space-5);
|
margin-bottom: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
@@ -44,6 +44,20 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: var(--space-3);
|
margin-bottom: var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section.collapsed .section-body { display: none; }
|
||||||
|
.section.collapsed .section-header { margin-bottom: 0; }
|
||||||
|
.section.collapsed .sec-chev { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.sec-chev {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
@@ -132,37 +146,12 @@
|
|||||||
padding: var(--space-1) var(--space-3);
|
padding: var(--space-1) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sessions table */
|
/* Sessions */
|
||||||
.sessions-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-table th,
|
|
||||||
.sessions-table td {
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-table th {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-table td {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-empty {
|
.sessions-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: var(--space-5);
|
padding: var(--space-4);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sessions-card {
|
.sessions-card {
|
||||||
@@ -172,6 +161,160 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-item {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.session-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 6px var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--transition-fast);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-row:hover { background: var(--bg-elevated); }
|
||||||
|
|
||||||
|
.p-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-icon.discord { color: #5865F2; }
|
||||||
|
.p-icon.telegram { color: #26A5E4; }
|
||||||
|
.p-icon.whatsapp { color: #25D366; }
|
||||||
|
.p-icon.unknown { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.s-name {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-tag {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-tag.grup { background: rgba(37, 211, 102, 0.12); color: #25D366; }
|
||||||
|
.s-tag.dm { background: rgba(156, 163, 175, 0.12); color: var(--text-muted); }
|
||||||
|
|
||||||
|
.s-stats {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
justify-content: flex-end;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-time {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-chevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
transition: transform var(--transition-fast);
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-chevron.open { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.session-details {
|
||||||
|
display: none;
|
||||||
|
padding: 4px var(--space-3) var(--space-3);
|
||||||
|
padding-left: calc(18px + var(--space-2) + var(--space-3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-details.open { display: block; }
|
||||||
|
|
||||||
|
.s-detail-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-3);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-detail-row span { white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Conversation messages */
|
||||||
|
.s-messages {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-msg {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-msg:last-child { border-bottom: none; }
|
||||||
|
.s-msg.user { color: var(--text-secondary); }
|
||||||
|
.s-msg.assistant { color: var(--accent); }
|
||||||
|
|
||||||
|
.s-msg-role {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-msg.user .s-msg-role { color: var(--warning); }
|
||||||
|
.s-msg.assistant .s-msg-role { color: var(--accent); }
|
||||||
|
|
||||||
|
.s-msg-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-loading {
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.s-actions .btn {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Log viewer */
|
/* Log viewer */
|
||||||
.log-card {
|
.log-card {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -361,13 +504,7 @@
|
|||||||
.check-name {
|
.check-name {
|
||||||
min-width: auto;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
.sessions-table {
|
.s-stats { display: none; }
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
.sessions-table th:nth-child(3),
|
|
||||||
.sessions-table td:nth-child(3) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -421,83 +558,102 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Services -->
|
<!-- Services -->
|
||||||
<div class="section">
|
<div class="section" id="sec-services">
|
||||||
<div class="section-header">
|
<div class="section-header" onclick="toggleSec('sec-services')">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<i data-lucide="server" style="width:18px;height:18px;"></i>
|
<i data-lucide="server" style="width:18px;height:18px;"></i>
|
||||||
Services
|
Services
|
||||||
</h2>
|
</h2>
|
||||||
|
<svg class="s-chevron sec-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="services-grid" id="servicesGrid">
|
<div class="section-body">
|
||||||
<div style="color:var(--text-muted);">Loading...</div>
|
<div class="services-grid" id="servicesGrid">
|
||||||
|
<div style="color:var(--text-muted);">Loading...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sessions -->
|
<!-- Sessions -->
|
||||||
<div class="section">
|
<div class="section" id="sec-sessions">
|
||||||
<div class="section-header">
|
<div class="section-header" onclick="toggleSec('sec-sessions')">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<i data-lucide="message-square" style="width:18px;height:18px;"></i>
|
<i data-lucide="message-square" style="width:18px;height:18px;"></i>
|
||||||
Sessions
|
Sessions
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-secondary btn-danger" onclick="clearAllSessions()" id="clearAllBtn" style="display:none;">
|
<div style="display:flex;gap:var(--space-2);align-items:center;">
|
||||||
<i data-lucide="trash-2"></i>
|
<button class="btn btn-secondary" onclick="event.stopPropagation();loadSessions()" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2);">
|
||||||
Clear All
|
<i data-lucide="refresh-cw"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary btn-danger" onclick="event.stopPropagation();clearAllSessions()" id="clearAllBtn" style="display:none;font-size:var(--text-xs);padding:var(--space-1) var(--space-2);">
|
||||||
|
<i data-lucide="trash-2"></i> Clear All
|
||||||
|
</button>
|
||||||
|
<svg class="s-chevron sec-chev" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="sessions-card">
|
<div class="section-body">
|
||||||
<div id="sessionsContent">
|
<div class="sessions-card">
|
||||||
<div class="sessions-empty">Loading...</div>
|
<div id="sessionsContent">
|
||||||
|
<div class="sessions-empty">Loading...</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs -->
|
<!-- Logs -->
|
||||||
<div class="section">
|
<div class="section collapsed" id="sec-logs">
|
||||||
<div class="section-header">
|
<div class="section-header" onclick="toggleSec('sec-logs')">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<i data-lucide="scroll-text" style="width:18px;height:18px;"></i>
|
<i data-lucide="scroll-text" style="width:18px;height:18px;"></i>
|
||||||
Logs
|
Logs
|
||||||
</h2>
|
</h2>
|
||||||
|
<svg class="s-chevron sec-chev open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-card">
|
<div class="section-body">
|
||||||
<div class="log-toolbar">
|
<div class="log-card">
|
||||||
<label>Lines:</label>
|
<div class="log-toolbar">
|
||||||
<select id="logLines" onchange="loadLogs()">
|
<label>Lines:</label>
|
||||||
<option value="50">50</option>
|
<select id="logLines" onchange="loadLogs()">
|
||||||
<option value="100" selected>100</option>
|
<option value="50">50</option>
|
||||||
<option value="200">200</option>
|
<option value="100" selected>100</option>
|
||||||
</select>
|
<option value="200">200</option>
|
||||||
<button class="btn btn-secondary" onclick="loadLogs()" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2);">
|
</select>
|
||||||
<i data-lucide="refresh-cw"></i>
|
<button class="btn btn-secondary" onclick="loadLogs()" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2);">
|
||||||
Refresh
|
<i data-lucide="refresh-cw"></i>
|
||||||
</button>
|
Refresh
|
||||||
<div style="margin-left:auto;display:flex;align-items:center;gap:var(--space-2);">
|
</button>
|
||||||
<label style="font-size:var(--text-xs);color:var(--text-muted);">Auto</label>
|
<div style="margin-left:auto;display:flex;align-items:center;gap:var(--space-2);">
|
||||||
<label class="toggle-switch">
|
<label style="font-size:var(--text-xs);color:var(--text-muted);">Auto</label>
|
||||||
<input type="checkbox" id="autoRefreshLogs" onchange="toggleAutoRefreshLogs()">
|
<label class="toggle-switch">
|
||||||
<span class="toggle-slider"></span>
|
<input type="checkbox" id="autoRefreshLogs" onchange="toggleAutoRefreshLogs()">
|
||||||
</label>
|
<span class="toggle-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="log-content" id="logContent">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="log-content" id="logContent">Loading...</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Doctor -->
|
<!-- Doctor -->
|
||||||
<div class="section">
|
<div class="section collapsed" id="sec-doctor">
|
||||||
<div class="section-header">
|
<div class="section-header" onclick="toggleSec('sec-doctor')">
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">
|
||||||
<i data-lucide="stethoscope" style="width:18px;height:18px;"></i>
|
<i data-lucide="stethoscope" style="width:18px;height:18px;"></i>
|
||||||
Doctor
|
Doctor
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-primary" onclick="runDoctor()" id="doctorBtn">
|
<div style="display:flex;gap:var(--space-2);align-items:center;">
|
||||||
<i data-lucide="play"></i>
|
<button class="btn btn-primary" onclick="event.stopPropagation();runDoctor()" id="doctorBtn" style="font-size:var(--text-xs);padding:var(--space-1) var(--space-2);">
|
||||||
Run Doctor
|
<i data-lucide="play"></i> Run
|
||||||
</button>
|
</button>
|
||||||
|
<svg class="s-chevron sec-chev open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="doctor-card" id="doctorCard" style="display:none;">
|
<div class="section-body">
|
||||||
<ul class="check-list" id="doctorChecks"></ul>
|
<div class="doctor-card" id="doctorCard">
|
||||||
|
<ul class="check-list" id="doctorChecks">
|
||||||
|
<li class="check-item" style="color:var(--text-muted);justify-content:center;">Click Run to check</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -546,7 +702,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderServices(data.services);
|
renderServices(data.services);
|
||||||
renderSessions(data.sessions);
|
|
||||||
|
|
||||||
const active = data.services.filter(s => s.active).length;
|
const active = data.services.filter(s => s.active).length;
|
||||||
document.getElementById('statusSummary').textContent =
|
document.getElementById('statusSummary').textContent =
|
||||||
@@ -647,6 +802,78 @@
|
|||||||
|
|
||||||
// ── Sessions ────────────────────────────────────────────
|
// ── Sessions ────────────────────────────────────────────
|
||||||
|
|
||||||
|
const pIcon = {
|
||||||
|
discord: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128c.12-.098.246-.198.373-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>',
|
||||||
|
telegram: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>',
|
||||||
|
whatsapp: '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 0 1-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 0 1-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 0 1 2.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0 0 12.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 0 0 5.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 0 0-3.48-8.413z"/></svg>',
|
||||||
|
unknown: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const d = new Date(iso), now = new Date();
|
||||||
|
const t = d.toLocaleTimeString('ro-RO', {hour:'2-digit', minute:'2-digit'});
|
||||||
|
if (d.toDateString() === now.toDateString()) return t;
|
||||||
|
const yesterday = new Date(now); yesterday.setDate(yesterday.getDate()-1);
|
||||||
|
if (d.toDateString() === yesterday.toDateString()) return 'ieri ' + t;
|
||||||
|
return d.toLocaleDateString('ro-RO', {day:'2-digit', month:'2-digit'}) + ' ' + t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCost(u) { return u ? '$' + u.toFixed(3) : '-'; }
|
||||||
|
function fmtTok(n) { return !n ? '0' : n >= 1000 ? (n/1000).toFixed(1)+'k' : String(n); }
|
||||||
|
function fmtDur(ms) { if (!ms) return '-'; const s = Math.round(ms/1000); return s<60 ? s+'s' : Math.floor(s/60)+'m'+((s%60)?(' '+s%60+'s'):''); }
|
||||||
|
|
||||||
|
let sessionsCache = [];
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/echo/api/eco/sessions');
|
||||||
|
const data = await res.json();
|
||||||
|
sessionsCache = data.sessions || [];
|
||||||
|
renderSessions(sessionsCache);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('sessionsContent').innerHTML =
|
||||||
|
`<div class="sessions-empty" style="color:var(--error);">Error: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSession(i) {
|
||||||
|
const det = document.getElementById('sd-'+i);
|
||||||
|
const chv = document.getElementById('sc-'+i);
|
||||||
|
if (!det) return;
|
||||||
|
const opening = !det.classList.contains('open');
|
||||||
|
det.classList.toggle('open');
|
||||||
|
if (chv) chv.classList.toggle('open');
|
||||||
|
// Load content on first open
|
||||||
|
if (opening && !det.dataset.loaded) {
|
||||||
|
const s = sessionsCache[i];
|
||||||
|
if (s && s.session_id) {
|
||||||
|
det.dataset.loaded = '1';
|
||||||
|
loadSessionContent(s.session_id, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSessionContent(sessionId, idx) {
|
||||||
|
const el = document.getElementById('sm-'+idx);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = '<div class="s-loading"><span class="spinner"></span></div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/echo/api/eco/sessions/content?id=${sessionId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.messages || data.messages.length === 0) {
|
||||||
|
el.innerHTML = '<div class="s-loading">No messages</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = data.messages.map(m =>
|
||||||
|
`<div class="s-msg ${m.role}"><span class="s-msg-role">${m.role === 'user' ? 'USR' : 'ECH'}</span><span class="s-msg-text">${escapeHtml(m.text)}</span></div>`
|
||||||
|
).join('');
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="s-loading" style="color:var(--error);">${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderSessions(sessions) {
|
function renderSessions(sessions) {
|
||||||
const container = document.getElementById('sessionsContent');
|
const container = document.getElementById('sessionsContent');
|
||||||
const clearBtn = document.getElementById('clearAllBtn');
|
const clearBtn = document.getElementById('clearAllBtn');
|
||||||
@@ -659,62 +886,60 @@
|
|||||||
|
|
||||||
clearBtn.style.display = '';
|
clearBtn.style.display = '';
|
||||||
|
|
||||||
// Handle both array and object formats
|
container.innerHTML = sessions.map((s, i) => {
|
||||||
let rows = [];
|
const pl = s.platform || 'unknown';
|
||||||
if (Array.isArray(sessions)) {
|
const ic = pIcon[pl] || pIcon.unknown;
|
||||||
rows = sessions;
|
const nm = s.channel_name || s.channel_id || '-';
|
||||||
} else if (typeof sessions === 'object') {
|
const tag = s.is_group ? '<span class="s-tag grup">GRUP</span>' : '';
|
||||||
rows = Object.entries(sessions).map(([k, v]) => ({
|
const model = (s.model||'?').toUpperCase();
|
||||||
channel: k,
|
|
||||||
...(typeof v === 'object' ? v : {value: v})
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
return `<div class="session-item">
|
||||||
container.innerHTML = '<div class="sessions-empty">No active sessions</div>';
|
<div class="session-row" onclick="toggleSession(${i})">
|
||||||
clearBtn.style.display = 'none';
|
<div class="p-icon ${pl}">${ic}</div>
|
||||||
return;
|
<span class="s-name">${escapeHtml(nm)}</span>
|
||||||
}
|
${tag}
|
||||||
|
<div class="s-stats">
|
||||||
container.innerHTML = `
|
<span>${s.message_count||0}msg</span>
|
||||||
<table class="sessions-table">
|
<span>${model}</span>
|
||||||
<thead>
|
<span>${fmtCost(s.total_cost_usd)}</span>
|
||||||
<tr>
|
</div>
|
||||||
<th>Channel</th>
|
<span class="s-time">${fmtTime(s.last_message_at)}</span>
|
||||||
<th>Platform</th>
|
<svg class="s-chevron" id="sc-${i}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||||
<th>Started</th>
|
</div>
|
||||||
<th></th>
|
<div class="session-details" id="sd-${i}">
|
||||||
</tr>
|
<div class="s-detail-row">
|
||||||
</thead>
|
<span>created ${fmtTime(s.created_at)}</span>
|
||||||
<tbody>
|
<span>${s.message_count||0} msgs</span>
|
||||||
${rows.map(s => `
|
<span>${model}</span>
|
||||||
<tr>
|
<span>${fmtTok(s.total_input_tokens)}/${fmtTok(s.total_output_tokens)} tok</span>
|
||||||
<td>${escapeHtml(s.channel || s.id || '-')}</td>
|
<span>ctx ${fmtTok(s.context_tokens)}</span>
|
||||||
<td>${escapeHtml(s.platform || s.type || '-')}</td>
|
<span>${fmtCost(s.total_cost_usd)}</span>
|
||||||
<td>${s.started || s.timestamp || '-'}</td>
|
<span>${fmtDur(s.duration_ms)}</span>
|
||||||
<td>
|
</div>
|
||||||
<button class="btn btn-secondary btn-danger" style="font-size:var(--text-xs);padding:2px 8px;"
|
<div class="s-messages" id="sm-${i}">
|
||||||
onclick="clearSession('${escapeHtml(s.channel || s.id || '')}')">
|
<div class="s-loading">Click to load messages...</div>
|
||||||
<i data-lucide="x"></i>
|
</div>
|
||||||
</button>
|
<div class="s-actions">
|
||||||
</td>
|
<button class="btn btn-secondary btn-danger" onclick="event.stopPropagation();clearSession('${escapeHtml(s.channel_id||'')}')">
|
||||||
</tr>
|
<i data-lucide="trash-2"></i> Clear
|
||||||
`).join('')}
|
</button>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
`;
|
</div>`;
|
||||||
|
}).join('');
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearSession(channel) {
|
async function clearSession(channelId) {
|
||||||
|
if (!confirm('Clear this session?')) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/echo/api/eco/sessions/clear', {
|
const res = await fetch('/echo/api/eco/sessions/clear', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({channel: channel})
|
body: JSON.stringify({channel: channelId})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) loadStatus();
|
if (data.success) loadSessions();
|
||||||
else alert('Error: ' + data.error);
|
else alert('Error: ' + data.error);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Failed: ' + e.message);
|
alert('Failed: ' + e.message);
|
||||||
@@ -730,7 +955,7 @@
|
|||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) loadStatus();
|
if (data.success) loadSessions();
|
||||||
else alert('Error: ' + data.error);
|
else alert('Error: ' + data.error);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Failed: ' + e.message);
|
alert('Failed: ' + e.message);
|
||||||
@@ -767,11 +992,11 @@
|
|||||||
// ── Doctor ──────────────────────────────────────────────
|
// ── Doctor ──────────────────────────────────────────────
|
||||||
|
|
||||||
async function runDoctor() {
|
async function runDoctor() {
|
||||||
const card = document.getElementById('doctorCard');
|
// Open section if collapsed
|
||||||
|
document.getElementById('sec-doctor').classList.remove('collapsed');
|
||||||
const list = document.getElementById('doctorChecks');
|
const list = document.getElementById('doctorChecks');
|
||||||
const btn = document.getElementById('doctorBtn');
|
const btn = document.getElementById('doctorBtn');
|
||||||
|
|
||||||
card.style.display = '';
|
|
||||||
list.innerHTML = '<li class="check-item"><span class="spinner"></span> Running checks...</li>';
|
list.innerHTML = '<li class="check-item"><span class="spinner"></span> Running checks...</li>';
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
@@ -803,6 +1028,10 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleSec(id) {
|
||||||
|
document.getElementById(id).classList.toggle('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
function refreshAll() {
|
function refreshAll() {
|
||||||
loadStatus();
|
loadStatus();
|
||||||
loadLogs();
|
loadLogs();
|
||||||
@@ -811,10 +1040,11 @@
|
|||||||
// ── Init ────────────────────────────────────────────────
|
// ── Init ────────────────────────────────────────────────
|
||||||
|
|
||||||
loadStatus();
|
loadStatus();
|
||||||
|
loadSessions();
|
||||||
loadLogs();
|
loadLogs();
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
// Auto-refresh status every 10s
|
// Auto-refresh status every 10s (sessions are manual only)
|
||||||
statusRefreshInterval = setInterval(loadStatus, 10000);
|
statusRefreshInterval = setInterval(loadStatus, 10000);
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user