Browse Source

Add web dashboard for managing schedules

Added simple vanilla JS dashboard at /dashboard for browser-based schedule management.

Features:
- 🎛️ Web UI accessible at https://letta--switchboard-api.modal.run/dashboard
- 🔑 API key input (stored in browser sessionStorage)
- 📋 View all schedules (one-time and recurring) in tables
- ➕ Create new schedules with simple form
- 📊 View execution results with success/failed status badges
- 🗑️ Delete schedules with confirmation dialog
- 🔄 Refresh buttons to reload data

Implementation:
- Single-page app with vanilla HTML/CSS/JS (~500 lines)
- No build process or external dependencies
- Served directly from Modal app via /dashboard route
- Added dashboard.html to Modal image with .add_local_file()
- API calls use existing REST endpoints

User Experience:
1. Visit https://letta--switchboard-api.modal.run/dashboard
2. Enter Letta API key
3. View and manage all schedules
4. See execution results with error messages
5. No installation required!

Updated:
- app.py: Added /dashboard route and included dashboard.html in image
- dashboard.html: Complete single-page app
- README.md: Added dashboard as Option 1 (easiest), promoted in header
- Landing page: Added link to dashboard in docs section

This makes Switchboard accessible to non-technical users who prefer
a GUI over CLI or cURL commands.

👾 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>
Cameron Pfiffer 4 months ago
parent
commit
e378b7192a
3 changed files with 465 additions and 2 deletions
  1. 15 2
      README.md
  2. 13 0
      app.py
  3. 437 0
      dashboard.html

+ 15 - 2
README.md

@@ -5,13 +5,26 @@
 Send messages to your Letta agents immediately or scheduled for later. Supports natural language scheduling ("in 5 minutes", "every weekday at 9am") and secure cross-agent communication.
 Send messages to your Letta agents immediately or scheduled for later. Supports natural language scheduling ("in 5 minutes", "every weekday at 9am") and secure cross-agent communication.
 
 
 🌐 **Hosted Service:** `https://letta--switchboard-api.modal.run`  
 🌐 **Hosted Service:** `https://letta--switchboard-api.modal.run`  
+🎛️ **Web Dashboard:** [`/dashboard`](https://letta--switchboard-api.modal.run/dashboard)  
 💻 **CLI:** [`letta-switchboard`](cli/)  
 💻 **CLI:** [`letta-switchboard`](cli/)  
 🔒 **Security:** End-to-end encryption, API key isolation  
 🔒 **Security:** End-to-end encryption, API key isolation  
 📖 **Docs:** [CLI Guide](cli/README.md) | [API Reference](#api-usage)
 📖 **Docs:** [CLI Guide](cli/README.md) | [API Reference](#api-usage)
 
 
 ## Quick Start
 ## Quick Start
 
 
-### Option 1: Using cURL (No Installation Required)
+### Option 1: Web Dashboard (Easiest)
+
+Visit the dashboard in your browser: **[https://letta--switchboard-api.modal.run/dashboard](https://letta--switchboard-api.modal.run/dashboard)**
+
+1. Enter your Letta API key
+2. View all your schedules
+3. Create new schedules with a simple form
+4. View execution results and errors
+5. Delete schedules with one click
+
+No installation required - just visit the URL!
+
+### Option 2: Using cURL (No Installation Required)
 
 
 Send a message right now with just cURL:
 Send a message right now with just cURL:
 
 
@@ -65,7 +78,7 @@ curl https://letta--switchboard-api.modal.run/results \
 
 
 **Pro tip:** Use the CLI for natural language scheduling - it's much easier than writing ISO timestamps and cron expressions!
 **Pro tip:** Use the CLI for natural language scheduling - it's much easier than writing ISO timestamps and cron expressions!
 
 
-### Option 2: Using the CLI (Recommended)
+### Option 3: Using the CLI (Advanced)
 
 
 The CLI makes natural language scheduling much easier:
 The CLI makes natural language scheduling much easier:
 
 

+ 13 - 0
app.py

@@ -37,6 +37,7 @@ image = (
     .pip_install_from_requirements("requirements.txt")
     .pip_install_from_requirements("requirements.txt")
     .env({"LETTA_SWITCHBOARD_DEV_MODE": dev_mode_enabled})  # Must come before add_local_*
     .env({"LETTA_SWITCHBOARD_DEV_MODE": dev_mode_enabled})  # Must come before add_local_*
     .add_local_python_source("models", "scheduler", "letta_executor", "crypto_utils")
     .add_local_python_source("models", "scheduler", "letta_executor", "crypto_utils")
+    .add_local_file("dashboard.html", "/root/dashboard.html")
 )
 )
 
 
 volume = modal.Volume.from_name("letta-switchboard-volume", create_if_missing=True)
 volume = modal.Volume.from_name("letta-switchboard-volume", create_if_missing=True)
@@ -419,6 +420,7 @@ go build -o letta-switchboard
             </div>
             </div>
             
             
             <h2>Documentation & Support</h2>
             <h2>Documentation & Support</h2>
+            <p>🎛️ <a href="/dashboard"><strong>Web Dashboard</strong></a> - Manage your schedules in your browser</p>
             <p>📖 Full documentation: <a href="https://github.com/cpfiffer/letta-switchboard">github.com/cpfiffer/letta-switchboard</a></p>
             <p>📖 Full documentation: <a href="https://github.com/cpfiffer/letta-switchboard">github.com/cpfiffer/letta-switchboard</a></p>
             <p>🐛 Issues & support: <a href="https://github.com/cpfiffer/letta-switchboard/issues">GitHub Issues</a></p>
             <p>🐛 Issues & support: <a href="https://github.com/cpfiffer/letta-switchboard/issues">GitHub Issues</a></p>
             <p>💬 API response: <a href="/?json">View as JSON</a></p>
             <p>💬 API response: <a href="/?json">View as JSON</a></p>
@@ -430,6 +432,17 @@ go build -o letta-switchboard
     return info
     return info
 
 
 
 
+@web_app.get("/dashboard")
+async def dashboard():
+    """Dashboard UI for managing schedules."""
+    try:
+        with open("/root/dashboard.html", "r") as f:
+            html_content = f.read()
+        return HTMLResponse(content=html_content)
+    except FileNotFoundError:
+        raise HTTPException(status_code=404, detail="Dashboard not found")
+
+
 @web_app.post("/schedules/recurring")
 @web_app.post("/schedules/recurring")
 async def create_recurring_schedule(schedule: RecurringScheduleCreate, credentials: HTTPAuthorizationCredentials = Security(security)):
 async def create_recurring_schedule(schedule: RecurringScheduleCreate, credentials: HTTPAuthorizationCredentials = Security(security)):
     api_key = credentials.credentials
     api_key = credentials.credentials

+ 437 - 0
dashboard.html

@@ -0,0 +1,437 @@
+<!DOCTYPE html>
+<html>
+<head>
+    <title>Switchboard Dashboard</title>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #f5f5f5;
+            padding: 20px;
+        }
+        .container { max-width: 1200px; margin: 0 auto; }
+        h1 { color: #2563eb; margin-bottom: 20px; }
+        h2 { color: #1e40af; margin: 30px 0 15px; font-size: 1.5em; }
+        
+        /* API Key Section */
+        #api-key-section {
+            background: white;
+            padding: 20px;
+            border-radius: 8px;
+            margin-bottom: 20px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+        }
+        #api-key-section input {
+            width: 100%;
+            padding: 10px;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+            font-size: 14px;
+        }
+        
+        /* Buttons */
+        button {
+            background: #2563eb;
+            color: white;
+            border: none;
+            padding: 10px 20px;
+            border-radius: 4px;
+            cursor: pointer;
+            font-size: 14px;
+            margin-top: 10px;
+        }
+        button:hover { background: #1d4ed8; }
+        button.danger { background: #dc2626; }
+        button.danger:hover { background: #b91c1c; }
+        
+        /* Tabs */
+        .tabs {
+            display: flex;
+            gap: 10px;
+            margin-bottom: 20px;
+        }
+        .tab {
+            padding: 10px 20px;
+            background: white;
+            border: none;
+            border-radius: 8px 8px 0 0;
+            cursor: pointer;
+        }
+        .tab.active { background: #2563eb; color: white; }
+        
+        /* Content */
+        .content {
+            background: white;
+            padding: 20px;
+            border-radius: 8px;
+            box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+            display: none;
+        }
+        .content.active { display: block; }
+        
+        /* Tables */
+        table {
+            width: 100%;
+            border-collapse: collapse;
+            margin-top: 15px;
+        }
+        th, td {
+            padding: 12px;
+            text-align: left;
+            border-bottom: 1px solid #e5e7eb;
+        }
+        th { background: #f9fafb; font-weight: 600; }
+        tr:hover { background: #f9fafb; }
+        
+        /* Forms */
+        .form-group {
+            margin-bottom: 15px;
+        }
+        label {
+            display: block;
+            margin-bottom: 5px;
+            font-weight: 500;
+        }
+        input[type="text"], input[type="datetime-local"], textarea {
+            width: 100%;
+            padding: 8px;
+            border: 1px solid #ddd;
+            border-radius: 4px;
+        }
+        textarea { min-height: 80px; }
+        
+        /* Status badges */
+        .badge {
+            padding: 4px 8px;
+            border-radius: 4px;
+            font-size: 12px;
+            font-weight: 500;
+        }
+        .badge.success { background: #d1fae5; color: #065f46; }
+        .badge.failed { background: #fee2e2; color: #991b1b; }
+        
+        .hidden { display: none; }
+        .error { color: #dc2626; margin-top: 10px; }
+        .empty { text-align: center; padding: 40px; color: #6b7280; }
+    </style>
+</head>
+<body>
+    <div class="container">
+        <h1>🔀 Switchboard Dashboard</h1>
+        
+        <!-- API Key Input -->
+        <div id="api-key-section">
+            <label for="api-key-input">Letta API Key:</label>
+            <input type="password" id="api-key-input" placeholder="sk-let-...">
+            <button onclick="saveApiKey()">Save Key</button>
+            <button onclick="clearApiKey()" class="danger">Clear Key</button>
+            <div id="api-key-status"></div>
+        </div>
+        
+        <div id="main-content" class="hidden">
+            <!-- Tabs -->
+            <div class="tabs">
+                <button class="tab active" onclick="showTab('schedules')">Schedules</button>
+                <button class="tab" onclick="showTab('create')">Create New</button>
+                <button class="tab" onclick="showTab('results')">Results</button>
+            </div>
+            
+            <!-- Schedules Tab -->
+            <div id="schedules-tab" class="content active">
+                <h2>One-Time Schedules</h2>
+                <table id="onetime-table">
+                    <thead>
+                        <tr>
+                            <th>ID</th>
+                            <th>Agent ID</th>
+                            <th>Execute At</th>
+                            <th>Message</th>
+                            <th>Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody></tbody>
+                </table>
+                
+                <h2>Recurring Schedules</h2>
+                <table id="recurring-table">
+                    <thead>
+                        <tr>
+                            <th>ID</th>
+                            <th>Agent ID</th>
+                            <th>Cron</th>
+                            <th>Message</th>
+                            <th>Last Run</th>
+                            <th>Actions</th>
+                        </tr>
+                    </thead>
+                    <tbody></tbody>
+                </table>
+                
+                <button onclick="loadSchedules()" style="margin-top: 20px;">Refresh</button>
+            </div>
+            
+            <!-- Create Tab -->
+            <div id="create-tab" class="content">
+                <h2>Create Schedule</h2>
+                
+                <div class="form-group">
+                    <label>Schedule Type:</label>
+                    <select id="schedule-type" onchange="toggleScheduleType()">
+                        <option value="onetime">One-Time</option>
+                        <option value="recurring">Recurring</option>
+                    </select>
+                </div>
+                
+                <div class="form-group">
+                    <label for="agent-id">Agent ID:</label>
+                    <input type="text" id="agent-id" placeholder="agent-xxx">
+                </div>
+                
+                <div class="form-group">
+                    <label for="message">Message:</label>
+                    <textarea id="message" placeholder="Your message to the agent"></textarea>
+                </div>
+                
+                <div id="onetime-fields">
+                    <div class="form-group">
+                        <label for="execute-at">Execute At (ISO 8601):</label>
+                        <input type="datetime-local" id="execute-at">
+                        <small style="display:block;margin-top:5px;color:#6b7280;">Or use: 2025-11-18T10:00:00Z</small>
+                    </div>
+                </div>
+                
+                <div id="recurring-fields" class="hidden">
+                    <div class="form-group">
+                        <label for="cron">Cron Expression:</label>
+                        <input type="text" id="cron" placeholder="0 9 * * 1-5">
+                        <small style="display:block;margin-top:5px;color:#6b7280;">Examples: "0 9 * * *" (daily at 9am), "*/5 * * * *" (every 5 min)</small>
+                    </div>
+                </div>
+                
+                <button onclick="createSchedule()">Create Schedule</button>
+                <div id="create-error" class="error"></div>
+            </div>
+            
+            <!-- Results Tab -->
+            <div id="results-tab" class="content">
+                <h2>Execution Results</h2>
+                <table id="results-table">
+                    <thead>
+                        <tr>
+                            <th>Schedule ID</th>
+                            <th>Type</th>
+                            <th>Status</th>
+                            <th>Agent ID</th>
+                            <th>Message</th>
+                            <th>Run ID / Error</th>
+                            <th>Executed At</th>
+                        </tr>
+                    </thead>
+                    <tbody></tbody>
+                </table>
+                
+                <button onclick="loadResults()" style="margin-top: 20px;">Refresh</button>
+            </div>
+        </div>
+    </div>
+    
+    <script>
+        const API_BASE = window.location.origin;
+        let API_KEY = sessionStorage.getItem('letta_api_key') || '';
+        
+        // Initialize
+        if (API_KEY) {
+            document.getElementById('api-key-input').value = API_KEY;
+            document.getElementById('main-content').classList.remove('hidden');
+            loadSchedules();
+            loadResults();
+        }
+        
+        function saveApiKey() {
+            API_KEY = document.getElementById('api-key-input').value.trim();
+            if (!API_KEY) {
+                alert('Please enter an API key');
+                return;
+            }
+            sessionStorage.setItem('letta_api_key', API_KEY);
+            document.getElementById('main-content').classList.remove('hidden');
+            document.getElementById('api-key-status').innerHTML = '<p style="color:green;margin-top:10px;">✓ API key saved</p>';
+            loadSchedules();
+            loadResults();
+        }
+        
+        function clearApiKey() {
+            sessionStorage.removeItem('letta_api_key');
+            API_KEY = '';
+            document.getElementById('api-key-input').value = '';
+            document.getElementById('main-content').classList.add('hidden');
+            document.getElementById('api-key-status').innerHTML = '<p style="color:red;margin-top:10px;">API key cleared</p>';
+        }
+        
+        function showTab(tab) {
+            document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
+            document.querySelectorAll('.content').forEach(c => c.classList.remove('active'));
+            event.target.classList.add('active');
+            document.getElementById(`${tab}-tab`).classList.add('active');
+        }
+        
+        function toggleScheduleType() {
+            const type = document.getElementById('schedule-type').value;
+            document.getElementById('onetime-fields').classList.toggle('hidden', type !== 'onetime');
+            document.getElementById('recurring-fields').classList.toggle('hidden', type !== 'recurring');
+        }
+        
+        async function loadSchedules() {
+            try {
+                const [onetime, recurring] = await Promise.all([
+                    fetch(`${API_BASE}/schedules/one-time`, {
+                        headers: { 'Authorization': `Bearer ${API_KEY}` }
+                    }).then(r => r.json()),
+                    fetch(`${API_BASE}/schedules/recurring`, {
+                        headers: { 'Authorization': `Bearer ${API_KEY}` }
+                    }).then(r => r.json())
+                ]);
+                
+                // One-time schedules
+                const onetimeBody = document.querySelector('#onetime-table tbody');
+                onetimeBody.innerHTML = onetime.length ? onetime.map(s => `
+                    <tr>
+                        <td>${s.id.substring(0, 8)}...</td>
+                        <td>${s.agent_id}</td>
+                        <td>${new Date(s.execute_at).toLocaleString()}</td>
+                        <td>${truncate(s.message, 50)}</td>
+                        <td><button class="danger" onclick="deleteSchedule('one-time', '${s.id}')">Delete</button></td>
+                    </tr>
+                `).join('') : '<tr><td colspan="5" class="empty">No one-time schedules</td></tr>';
+                
+                // Recurring schedules
+                const recurringBody = document.querySelector('#recurring-table tbody');
+                recurringBody.innerHTML = recurring.length ? recurring.map(s => `
+                    <tr>
+                        <td>${s.id.substring(0, 8)}...</td>
+                        <td>${s.agent_id}</td>
+                        <td>${s.cron}</td>
+                        <td>${truncate(s.message, 50)}</td>
+                        <td>${s.last_run ? new Date(s.last_run).toLocaleString() : 'Never'}</td>
+                        <td><button class="danger" onclick="deleteSchedule('recurring', '${s.id}')">Delete</button></td>
+                    </tr>
+                `).join('') : '<tr><td colspan="6" class="empty">No recurring schedules</td></tr>';
+            } catch (error) {
+                alert('Failed to load schedules: ' + error.message);
+            }
+        }
+        
+        async function loadResults() {
+            try {
+                const results = await fetch(`${API_BASE}/results`, {
+                    headers: { 'Authorization': `Bearer ${API_KEY}` }
+                }).then(r => r.json());
+                
+                const resultsBody = document.querySelector('#results-table tbody');
+                resultsBody.innerHTML = results.length ? results.map(r => `
+                    <tr>
+                        <td>${r.schedule_id.substring(0, 8)}...</td>
+                        <td>${r.schedule_type}</td>
+                        <td><span class="badge ${r.status}">${r.status}</span></td>
+                        <td>${r.agent_id}</td>
+                        <td>${truncate(r.message, 30)}</td>
+                        <td>${r.run_id || r.error || 'N/A'}</td>
+                        <td>${new Date(r.executed_at).toLocaleString()}</td>
+                    </tr>
+                `).join('') : '<tr><td colspan="7" class="empty">No execution results</td></tr>';
+            } catch (error) {
+                alert('Failed to load results: ' + error.message);
+            }
+        }
+        
+        async function createSchedule() {
+            const type = document.getElementById('schedule-type').value;
+            const agentId = document.getElementById('agent-id').value.trim();
+            const message = document.getElementById('message').value.trim();
+            const errorDiv = document.getElementById('create-error');
+            
+            errorDiv.textContent = '';
+            
+            if (!agentId || !message) {
+                errorDiv.textContent = 'Please fill in all fields';
+                return;
+            }
+            
+            try {
+                const payload = {
+                    agent_id: agentId,
+                    message: message,
+                    role: 'user'
+                };
+                
+                if (type === 'onetime') {
+                    const executeAt = document.getElementById('execute-at').value;
+                    if (!executeAt) {
+                        errorDiv.textContent = 'Please specify execution time';
+                        return;
+                    }
+                    // Convert datetime-local to ISO 8601
+                    payload.execute_at = new Date(executeAt).toISOString();
+                } else {
+                    const cron = document.getElementById('cron').value.trim();
+                    if (!cron) {
+                        errorDiv.textContent = 'Please specify cron expression';
+                        return;
+                    }
+                    payload.cron = cron;
+                }
+                
+                const endpoint = type === 'onetime' ? '/schedules/one-time' : '/schedules/recurring';
+                const response = await fetch(`${API_BASE}${endpoint}`, {
+                    method: 'POST',
+                    headers: {
+                        'Authorization': `Bearer ${API_KEY}`,
+                        'Content-Type': 'application/json'
+                    },
+                    body: JSON.stringify(payload)
+                });
+                
+                if (!response.ok) {
+                    const error = await response.json();
+                    throw new Error(error.detail || 'Failed to create schedule');
+                }
+                
+                alert('Schedule created successfully!');
+                document.getElementById('agent-id').value = '';
+                document.getElementById('message').value = '';
+                document.getElementById('execute-at').value = '';
+                document.getElementById('cron').value = '';
+                
+                showTab('schedules');
+                loadSchedules();
+            } catch (error) {
+                errorDiv.textContent = 'Error: ' + error.message;
+            }
+        }
+        
+        async function deleteSchedule(type, id) {
+            if (!confirm('Are you sure you want to delete this schedule?')) return;
+            
+            try {
+                const response = await fetch(`${API_BASE}/schedules/${type}/${id}`, {
+                    method: 'DELETE',
+                    headers: { 'Authorization': `Bearer ${API_KEY}` }
+                });
+                
+                if (!response.ok) throw new Error('Failed to delete');
+                
+                alert('Schedule deleted successfully!');
+                loadSchedules();
+            } catch (error) {
+                alert('Error deleting schedule: ' + error.message);
+            }
+        }
+        
+        function truncate(str, len) {
+            return str.length > len ? str.substring(0, len) + '...' : str;
+        }
+    </script>
+</body>
+</html>