|
@@ -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>
|