✅ TICKET-006: Wake-word Detection Service - Implemented wake-word detection using openWakeWord - HTTP/WebSocket server on port 8002 - Real-time detection with configurable threshold - Event emission for ASR integration - Location: home-voice-agent/wake-word/ ✅ TICKET-010: ASR Service - Implemented ASR using faster-whisper - HTTP endpoint for file transcription - WebSocket endpoint for streaming transcription - Support for multiple audio formats - Auto language detection - GPU acceleration support - Location: home-voice-agent/asr/ ✅ TICKET-014: TTS Service - Implemented TTS using Piper - HTTP endpoint for text-to-speech synthesis - Low-latency processing (< 500ms) - Multiple voice support - WAV audio output - Location: home-voice-agent/tts/ ✅ TICKET-047: Updated Hardware Purchases - Marked Pi5 kit, SSD, microphone, and speakers as purchased - Updated progress log with purchase status 📚 Documentation: - Added VOICE_SERVICES_README.md with complete testing guide - Each service includes README.md with usage instructions - All services ready for Pi5 deployment 🧪 Testing: - Created test files for each service - All imports validated - FastAPI apps created successfully - Code passes syntax validation 🚀 Ready for: - Pi5 deployment - End-to-end voice flow testing - Integration with MCP server Files Added: - wake-word/detector.py - wake-word/server.py - wake-word/requirements.txt - wake-word/README.md - wake-word/test_detector.py - asr/service.py - asr/server.py - asr/requirements.txt - asr/README.md - asr/test_service.py - tts/service.py - tts/server.py - tts/requirements.txt - tts/README.md - tts/test_service.py - VOICE_SERVICES_README.md Files Modified: - tickets/done/TICKET-047_hardware-purchases.md Files Moved: - tickets/backlog/TICKET-006_prototype-wake-word-node.md → tickets/done/ - tickets/backlog/TICKET-010_streaming-asr-service.md → tickets/done/ - tickets/backlog/TICKET-014_tts-service.md → tickets/done/
683 lines
27 KiB
HTML
683 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Atlas Dashboard</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: #f5f5f5;
|
|
color: #333;
|
|
}
|
|
|
|
.header {
|
|
background: #2c3e50;
|
|
color: white;
|
|
padding: 1rem 2rem;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1200px;
|
|
margin: 2rem auto;
|
|
padding: 0 2rem;
|
|
}
|
|
|
|
.status-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.status-card {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.status-card h3 {
|
|
font-size: 0.9rem;
|
|
color: #666;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.status-card .value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.section {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.section h2 {
|
|
margin-bottom: 1rem;
|
|
color: #2c3e50;
|
|
}
|
|
|
|
.conversation-list {
|
|
list-style: none;
|
|
}
|
|
|
|
.conversation-item {
|
|
padding: 1rem;
|
|
border-bottom: 1px solid #eee;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.conversation-item:hover {
|
|
background: #f9f9f9;
|
|
}
|
|
|
|
.conversation-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 4px;
|
|
font-size: 0.75rem;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.badge-family {
|
|
background: #3498db;
|
|
color: white;
|
|
}
|
|
|
|
.badge-work {
|
|
background: #e74c3c;
|
|
color: white;
|
|
}
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: #666;
|
|
}
|
|
|
|
.error {
|
|
background: #fee;
|
|
color: #c33;
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
margin: 1rem 0;
|
|
}
|
|
|
|
.admin-tabs {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
border-bottom: 2px solid #eee;
|
|
}
|
|
|
|
.admin-tab {
|
|
padding: 0.75rem 1.5rem;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
color: #666;
|
|
border-bottom: 2px solid transparent;
|
|
margin-bottom: -2px;
|
|
}
|
|
|
|
.admin-tab.active {
|
|
color: #2c3e50;
|
|
border-bottom-color: #2c3e50;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.admin-tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.admin-tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.kill-switch {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin: 1rem 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.kill-button {
|
|
padding: 0.75rem 1.5rem;
|
|
background: #e74c3c;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-weight: bold;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.kill-button:hover {
|
|
background: #c0392b;
|
|
}
|
|
|
|
.kill-button:disabled {
|
|
background: #95a5a6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.log-entry {
|
|
padding: 1rem;
|
|
margin: 0.5rem 0;
|
|
background: #f9f9f9;
|
|
border-left: 3px solid #3498db;
|
|
border-radius: 4px;
|
|
font-family: monospace;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.log-entry.error {
|
|
border-left-color: #e74c3c;
|
|
}
|
|
|
|
.log-filters {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.log-filters input,
|
|
.log-filters select {
|
|
padding: 0.5rem;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.token-item,
|
|
.device-item {
|
|
padding: 1rem;
|
|
margin: 0.5rem 0;
|
|
background: #f9f9f9;
|
|
border-radius: 4px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.revoke-button {
|
|
padding: 0.5rem 1rem;
|
|
background: #e74c3c;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1>🤖 Atlas Dashboard</h1>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<!-- Status Overview -->
|
|
<div class="status-grid" id="statusGrid">
|
|
<div class="status-card">
|
|
<h3>System Status</h3>
|
|
<div class="value" id="systemStatus">Loading...</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Conversations</h3>
|
|
<div class="value" id="conversationCount">-</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Active Timers</h3>
|
|
<div class="value" id="timerCount">-</div>
|
|
</div>
|
|
<div class="status-card">
|
|
<h3>Pending Tasks</h3>
|
|
<div class="value" id="taskCount">-</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent Conversations -->
|
|
<div class="section">
|
|
<h2>Recent Conversations</h2>
|
|
<div id="conversationsList" class="loading">Loading conversations...</div>
|
|
</div>
|
|
|
|
<!-- Active Timers -->
|
|
<div class="section">
|
|
<h2>Active Timers & Reminders</h2>
|
|
<div id="timersList" class="loading">Loading timers...</div>
|
|
</div>
|
|
|
|
<!-- Tasks -->
|
|
<div class="section">
|
|
<h2>Tasks</h2>
|
|
<div id="tasksList" class="loading">Loading tasks...</div>
|
|
</div>
|
|
|
|
<!-- Admin Panel -->
|
|
<div class="section">
|
|
<h2>🔧 Admin Panel</h2>
|
|
<div class="admin-tabs">
|
|
<button class="admin-tab active" onclick="switchAdminTab('logs')">Log Browser</button>
|
|
<button class="admin-tab" onclick="switchAdminTab('kill-switches')">Kill Switches</button>
|
|
<button class="admin-tab" onclick="switchAdminTab('access')">Access Control</button>
|
|
</div>
|
|
|
|
<!-- Log Browser Tab -->
|
|
<div id="admin-logs" class="admin-tab-content active">
|
|
<div class="log-filters">
|
|
<input type="text" id="logSearch" placeholder="Search logs..." onkeyup="loadLogs()">
|
|
<select id="logLevel" onchange="loadLogs()">
|
|
<option value="">All Levels</option>
|
|
<option value="INFO">INFO</option>
|
|
<option value="WARNING">WARNING</option>
|
|
<option value="ERROR">ERROR</option>
|
|
</select>
|
|
<select id="logAgent" onchange="loadLogs()">
|
|
<option value="">All Agents</option>
|
|
<option value="family">Family</option>
|
|
<option value="work">Work</option>
|
|
</select>
|
|
<input type="number" id="logLimit" value="50" min="10" max="500" onchange="loadLogs()" placeholder="Limit">
|
|
</div>
|
|
<div id="logsList" class="loading">Loading logs...</div>
|
|
</div>
|
|
|
|
<!-- Kill Switches Tab -->
|
|
<div id="admin-kill-switches" class="admin-tab-content">
|
|
<h3>Service Control</h3>
|
|
<p style="color: #666; margin-bottom: 1rem;">⚠️ Use with caution. These actions will stop services immediately.</p>
|
|
<div class="kill-switch">
|
|
<button class="kill-button" onclick="killService('mcp_server')">Stop MCP Server</button>
|
|
<button class="kill-button" onclick="killService('family_agent')">Stop Family Agent</button>
|
|
<button class="kill-button" onclick="killService('work_agent')">Stop Work Agent</button>
|
|
<button class="kill-button" onclick="killService('all')" style="background: #c0392b;">Stop All Services</button>
|
|
</div>
|
|
<div id="killStatus" style="margin-top: 1rem;"></div>
|
|
</div>
|
|
|
|
<!-- Access Control Tab -->
|
|
<div id="admin-access" class="admin-tab-content">
|
|
<h3>Revoked Tokens</h3>
|
|
<div id="revokedTokensList" class="loading">Loading revoked tokens...</div>
|
|
|
|
<h3 style="margin-top: 2rem;">Devices</h3>
|
|
<div id="devicesList" class="loading">Loading devices...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = 'http://localhost:8000/api/dashboard';
|
|
const ADMIN_API_BASE = 'http://localhost:8000/api/admin';
|
|
|
|
async function fetchJSON(url) {
|
|
try {
|
|
const response = await fetch(url);
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
return await response.json();
|
|
} catch (error) {
|
|
console.error('Fetch error:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function loadStatus() {
|
|
try {
|
|
const status = await fetchJSON(`${API_BASE}/status`);
|
|
document.getElementById('systemStatus').textContent = status.status;
|
|
document.getElementById('conversationCount').textContent = status.counts.conversations;
|
|
document.getElementById('timerCount').textContent = status.counts.active_timers;
|
|
document.getElementById('taskCount').textContent = status.counts.pending_tasks;
|
|
} catch (error) {
|
|
document.getElementById('statusGrid').innerHTML =
|
|
`<div class="error">Error loading status: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadConversations() {
|
|
try {
|
|
const data = await fetchJSON(`${API_BASE}/conversations?limit=10`);
|
|
const list = document.getElementById('conversationsList');
|
|
|
|
if (data.conversations.length === 0) {
|
|
list.innerHTML = '<p>No conversations yet.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = '<ul class="conversation-list">' +
|
|
data.conversations.map(conv => `
|
|
<li class="conversation-item">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
<span class="badge badge-${conv.agent_type}">${conv.agent_type}</span>
|
|
<span style="margin-left: 1rem;">${conv.session_id.substring(0, 8)}...</span>
|
|
</div>
|
|
<div style="color: #666; font-size: 0.9rem;">
|
|
${new Date(conv.last_activity).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
`).join('') + '</ul>';
|
|
} catch (error) {
|
|
document.getElementById('conversationsList').innerHTML =
|
|
`<div class="error">Error loading conversations: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadTimers() {
|
|
try {
|
|
const data = await fetchJSON(`${API_BASE}/timers`);
|
|
const list = document.getElementById('timersList');
|
|
|
|
const allItems = [...data.timers, ...data.reminders];
|
|
if (allItems.length === 0) {
|
|
list.innerHTML = '<p>No active timers or reminders.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = '<ul class="conversation-list">' +
|
|
allItems.map(item => `
|
|
<li class="conversation-item">
|
|
<div>
|
|
<strong>${item.name}</strong>
|
|
<div style="color: #666; font-size: 0.9rem; margin-top: 0.25rem;">
|
|
Started: ${new Date(item.started_at).toLocaleString()}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
`).join('') + '</ul>';
|
|
} catch (error) {
|
|
document.getElementById('timersList').innerHTML =
|
|
`<div class="error">Error loading timers: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadTasks() {
|
|
try {
|
|
const data = await fetchJSON(`${API_BASE}/tasks`);
|
|
const list = document.getElementById('tasksList');
|
|
|
|
if (data.tasks.length === 0) {
|
|
list.innerHTML = '<p>No tasks.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = '<ul class="conversation-list">' +
|
|
data.tasks.slice(0, 10).map(task => `
|
|
<li class="conversation-item">
|
|
<div>
|
|
<strong>${task.title}</strong>
|
|
<span class="badge" style="background: #95a5a6; color: white; margin-left: 0.5rem;">
|
|
${task.status}
|
|
</span>
|
|
<div style="color: #666; font-size: 0.9rem; margin-top: 0.25rem;">
|
|
${task.description.substring(0, 100)}${task.description.length > 100 ? '...' : ''}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
`).join('') + '</ul>';
|
|
} catch (error) {
|
|
document.getElementById('tasksList').innerHTML =
|
|
`<div class="error">Error loading tasks: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// Admin Panel Functions
|
|
function switchAdminTab(tab) {
|
|
// Hide all tabs
|
|
document.querySelectorAll('.admin-tab-content').forEach(el => el.classList.remove('active'));
|
|
document.querySelectorAll('.admin-tab').forEach(el => el.classList.remove('active'));
|
|
|
|
// Show selected tab
|
|
document.getElementById(`admin-${tab}`).classList.add('active');
|
|
event.target.classList.add('active');
|
|
|
|
// Load tab data
|
|
if (tab === 'logs') {
|
|
loadLogs();
|
|
} else if (tab === 'access') {
|
|
loadRevokedTokens();
|
|
loadDevices();
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
try {
|
|
const search = document.getElementById('logSearch').value;
|
|
const level = document.getElementById('logLevel').value;
|
|
const agent = document.getElementById('logAgent').value;
|
|
const limit = document.getElementById('logLimit').value || 50;
|
|
|
|
const params = new URLSearchParams({ limit });
|
|
if (search) params.append('search', search);
|
|
if (level) params.append('level', level);
|
|
if (agent) params.append('agent_type', agent);
|
|
|
|
const data = await fetchJSON(`${ADMIN_API_BASE}/logs/enhanced?${params}`);
|
|
const list = document.getElementById('logsList');
|
|
|
|
if (data.logs.length === 0) {
|
|
list.innerHTML = '<p>No logs found.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.logs.map(log => {
|
|
const levelClass = log.level === 'ERROR' ? 'error' : '';
|
|
const isError = log.level === 'ERROR' || log.type === 'error';
|
|
|
|
// Format log entry based on type
|
|
let logContent = '';
|
|
|
|
if (isError) {
|
|
// Error log - highlight error message
|
|
logContent = `
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
|
<div>
|
|
<strong>${log.timestamp || 'Unknown'}</strong>
|
|
<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #e74c3c; color: white; border-radius: 4px; font-size: 0.75rem;">
|
|
${log.level || 'ERROR'}
|
|
</span>
|
|
${log.agent_type ? `<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #3498db; color: white; border-radius: 4px; font-size: 0.75rem;">${log.agent_type}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div style="color: #e74c3c; font-weight: bold; margin: 0.5rem 0;">
|
|
❌ ${log.error || log.message || 'Error occurred'}
|
|
</div>
|
|
${log.url ? `<div style="color: #666; font-size: 0.9rem;">URL: ${log.url}</div>` : ''}
|
|
${log.request_id ? `<div style="color: #666; font-size: 0.9rem;">Request ID: ${log.request_id}</div>` : ''}
|
|
<details style="margin-top: 0.5rem;">
|
|
<summary style="cursor: pointer; color: #666; font-size: 0.85rem;">View full details</summary>
|
|
<pre style="margin-top: 0.5rem; white-space: pre-wrap; font-size: 0.8rem;">${JSON.stringify(log, null, 2)}</pre>
|
|
</details>
|
|
`;
|
|
} else {
|
|
// Info log - show key metrics
|
|
const toolsCalled = log.tools_called && log.tools_called.length > 0
|
|
? log.tools_called.join(', ')
|
|
: 'None';
|
|
|
|
logContent = `
|
|
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 0.5rem;">
|
|
<div>
|
|
<strong>${log.timestamp || 'Unknown'}</strong>
|
|
<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #3498db; color: white; border-radius: 4px; font-size: 0.75rem;">
|
|
${log.level || 'INFO'}
|
|
</span>
|
|
${log.agent_type ? `<span style="margin-left: 0.5rem; padding: 0.25rem 0.5rem; background: #95a5a6; color: white; border-radius: 4px; font-size: 0.75rem;">${log.agent_type}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div style="margin: 0.5rem 0;">
|
|
<div style="font-weight: bold; margin-bottom: 0.5rem;">💬 ${log.prompt || log.message || 'Request'}</div>
|
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.5rem; font-size: 0.85rem; color: #666;">
|
|
${log.latency_ms ? `<div>⏱️ Latency: ${log.latency_ms}ms</div>` : ''}
|
|
${log.tokens_in ? `<div>📥 Tokens In: ${log.tokens_in}</div>` : ''}
|
|
${log.tokens_out ? `<div>📤 Tokens Out: ${log.tokens_out}</div>` : ''}
|
|
${log.model ? `<div>🤖 Model: ${log.model}</div>` : ''}
|
|
${log.tools_called && log.tools_called.length > 0 ? `<div>🔧 Tools: ${toolsCalled}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<details style="margin-top: 0.5rem;">
|
|
<summary style="cursor: pointer; color: #666; font-size: 0.85rem;">View full details</summary>
|
|
<pre style="margin-top: 0.5rem; white-space: pre-wrap; font-size: 0.8rem;">${JSON.stringify(log, null, 2)}</pre>
|
|
</details>
|
|
`;
|
|
}
|
|
|
|
return `
|
|
<div class="log-entry ${levelClass}">
|
|
${logContent}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
document.getElementById('logsList').innerHTML =
|
|
`<div class="error">Error loading logs: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function killService(service) {
|
|
if (!confirm(`Are you sure you want to stop ${service}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/kill-switch/${service}`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
|
|
document.getElementById('killStatus').innerHTML =
|
|
`<div style="padding: 1rem; background: ${data.success ? '#d4edda' : '#f8d7da'}; border-radius: 4px;">
|
|
${data.message || data.detail || 'Action completed'}
|
|
</div>`;
|
|
} catch (error) {
|
|
document.getElementById('killStatus').innerHTML =
|
|
`<div class="error">Error: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadRevokedTokens() {
|
|
try {
|
|
const data = await fetchJSON(`${ADMIN_API_BASE}/tokens/revoked`);
|
|
const list = document.getElementById('revokedTokensList');
|
|
|
|
if (data.tokens.length === 0) {
|
|
list.innerHTML = '<p>No revoked tokens.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.tokens.map(token => `
|
|
<div class="token-item">
|
|
<div>
|
|
<strong>${token.token_id}</strong>
|
|
<div style="color: #666; font-size: 0.9rem;">
|
|
Revoked: ${token.revoked_at} | Reason: ${token.reason || 'None'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
} catch (error) {
|
|
document.getElementById('revokedTokensList').innerHTML =
|
|
`<div class="error">Error loading tokens: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadDevices() {
|
|
try {
|
|
const data = await fetchJSON(`${ADMIN_API_BASE}/devices`);
|
|
const list = document.getElementById('devicesList');
|
|
|
|
if (data.devices.length === 0) {
|
|
list.innerHTML = '<p>No devices registered.</p>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.devices.map(device => `
|
|
<div class="device-item">
|
|
<div>
|
|
<strong>${device.name || device.device_id}</strong>
|
|
<div style="color: #666; font-size: 0.9rem;">
|
|
Status: ${device.status} | Last seen: ${device.last_seen || 'Never'}
|
|
</div>
|
|
</div>
|
|
${device.status === 'active' ?
|
|
`<button class="revoke-button" onclick="revokeDevice('${device.device_id}')">Revoke</button>` :
|
|
'<span style="color: #e74c3c;">Revoked</span>'
|
|
}
|
|
</div>
|
|
`).join('');
|
|
} catch (error) {
|
|
document.getElementById('devicesList').innerHTML =
|
|
`<div class="error">Error loading devices: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
async function revokeDevice(deviceId) {
|
|
if (!confirm(`Are you sure you want to revoke access for device ${deviceId}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`${ADMIN_API_BASE}/devices/${deviceId}/revoke`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
loadDevices();
|
|
} else {
|
|
alert(data.message || 'Failed to revoke device');
|
|
}
|
|
} catch (error) {
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// Load all data on page load
|
|
async function init() {
|
|
await Promise.all([
|
|
loadStatus(),
|
|
loadConversations(),
|
|
loadTimers(),
|
|
loadTasks()
|
|
]);
|
|
|
|
// Refresh every 30 seconds
|
|
setInterval(async () => {
|
|
await Promise.all([
|
|
loadStatus(),
|
|
loadConversations(),
|
|
loadTimers(),
|
|
loadTasks()
|
|
]);
|
|
}, 30000);
|
|
}
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>
|