Compare commits
3 Commits
feat/dark-
...
24f077cf25
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f077cf25 | |||
| 4bc0555974 | |||
| add7f6bf6c |
568
public/app.js
568
public/app.js
@@ -58,54 +58,32 @@ function initTheme() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ NAVIGATION ============
|
// ============ STATE ============
|
||||||
const navLinks = document.querySelectorAll('.nav-link');
|
|
||||||
const pages = document.querySelectorAll('.page');
|
|
||||||
|
|
||||||
navLinks.forEach(link => {
|
|
||||||
link.addEventListener('click', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const targetPage = link.dataset.page;
|
|
||||||
|
|
||||||
// Update active nav link
|
|
||||||
navLinks.forEach(l => l.classList.remove('active'));
|
|
||||||
link.classList.add('active');
|
|
||||||
|
|
||||||
// Show target page
|
|
||||||
pages.forEach(page => {
|
|
||||||
page.classList.remove('active');
|
|
||||||
if (page.id === `page-${targetPage}`) {
|
|
||||||
page.classList.add('active');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load page data
|
|
||||||
if (targetPage === 'wiki') loadWiki();
|
|
||||||
if (targetPage === 'agents') loadAgents();
|
|
||||||
if (targetPage === 'usage') loadUsage();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============ TASK DASHBOARD ============
|
|
||||||
const COLUMNS = {
|
const COLUMNS = {
|
||||||
'Backlog': { title: '📋 Backlog', tasks: [] },
|
Backlog: { title: '📋 Backlog', tasks: [] },
|
||||||
'Todo': { title: '📝 Todo', tasks: [] },
|
Todo: { title: '📝 Todo', tasks: [] },
|
||||||
'In Progress': { title: '🔄 In Progress', tasks: [] },
|
'In Progress': { title: '🔄 In Progress', tasks: [] },
|
||||||
'Review': { title: '👀 Review', tasks: [] },
|
Review: { title: '👀 Review', tasks: [] },
|
||||||
'Done': { title: '✅ Done', tasks: [] }
|
Done: { title: '✅ Done', tasks: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let wikiPages = [];
|
||||||
|
let currentWikiPage = null;
|
||||||
|
let allAgents = [];
|
||||||
|
let usageStats = null;
|
||||||
|
let providerChart = null;
|
||||||
|
let agentChart = null;
|
||||||
|
|
||||||
|
// ============ TASK DASHBOARD ============
|
||||||
async function loadTasks() {
|
async function loadTasks() {
|
||||||
const res = await fetch('/api/tasks');
|
const res = await fetch('/api/tasks');
|
||||||
const tasks = await res.json();
|
const tasks = await res.json();
|
||||||
|
|
||||||
// Reset columns
|
Object.keys(COLUMNS).forEach((status) => {
|
||||||
Object.keys(COLUMNS).forEach(status => {
|
|
||||||
COLUMNS[status].tasks = [];
|
COLUMNS[status].tasks = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group tasks by status
|
tasks.forEach((task) => {
|
||||||
tasks.forEach(task => {
|
|
||||||
if (COLUMNS[task.status]) {
|
if (COLUMNS[task.status]) {
|
||||||
COLUMNS[task.status].tasks.push(task);
|
COLUMNS[task.status].tasks.push(task);
|
||||||
}
|
}
|
||||||
@@ -116,6 +94,8 @@ async function loadTasks() {
|
|||||||
|
|
||||||
function renderBoard() {
|
function renderBoard() {
|
||||||
const board = document.getElementById('board');
|
const board = document.getElementById('board');
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
board.innerHTML = '';
|
board.innerHTML = '';
|
||||||
|
|
||||||
Object.entries(COLUMNS).forEach(([status, column]) => {
|
Object.entries(COLUMNS).forEach(([status, column]) => {
|
||||||
@@ -132,7 +112,7 @@ function renderBoard() {
|
|||||||
|
|
||||||
const cardsEl = columnEl.querySelector('.cards');
|
const cardsEl = columnEl.querySelector('.cards');
|
||||||
|
|
||||||
column.tasks.forEach(task => {
|
column.tasks.forEach((task) => {
|
||||||
const cardEl = document.createElement('div');
|
const cardEl = document.createElement('div');
|
||||||
cardEl.className = 'card';
|
cardEl.className = 'card';
|
||||||
cardEl.innerHTML = `
|
cardEl.innerHTML = `
|
||||||
@@ -142,20 +122,19 @@ function renderBoard() {
|
|||||||
</div>
|
</div>
|
||||||
<p class="card-desc">${escapeHtml(task.description || '')}</p>
|
<p class="card-desc">${escapeHtml(task.description || '')}</p>
|
||||||
<p class="meta assignee">${task.assignee || 'Unassigned'}</p>
|
<p class="meta assignee">${task.assignee || 'Unassigned'}</p>
|
||||||
<p class="meta tags">${task.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p>
|
<p class="meta tags">${task.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p>
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
|
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
|
||||||
Mark Complete
|
Mark Complete
|
||||||
</label>
|
</label>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Checkbox handler
|
|
||||||
const checkbox = cardEl.querySelector('.card-check');
|
const checkbox = cardEl.querySelector('.card-check');
|
||||||
checkbox.addEventListener('change', async () => {
|
checkbox.addEventListener('change', async () => {
|
||||||
await fetch(`/api/tasks/${task.id}`, {
|
await fetch(`/api/tasks/${task.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ status: 'Done' })
|
body: JSON.stringify({ status: 'Done' }),
|
||||||
});
|
});
|
||||||
loadTasks();
|
loadTasks();
|
||||||
});
|
});
|
||||||
@@ -167,31 +146,6 @@ function renderBoard() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Task form
|
|
||||||
document.getElementById('task-form').addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const formData = new FormData(e.target);
|
|
||||||
const task = {
|
|
||||||
title: formData.get('title'),
|
|
||||||
description: formData.get('description'),
|
|
||||||
assignee: formData.get('assignee'),
|
|
||||||
priority: formData.get('priority'),
|
|
||||||
status: formData.get('status') || 'Backlog',
|
|
||||||
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
|
|
||||||
};
|
|
||||||
|
|
||||||
await fetch('/api/tasks', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(task)
|
|
||||||
});
|
|
||||||
|
|
||||||
e.target.reset();
|
|
||||||
loadTasks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate agent dropdown
|
|
||||||
async function populateAgentDropdown() {
|
async function populateAgentDropdown() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/agents');
|
const res = await fetch('/api/agents');
|
||||||
@@ -200,13 +154,11 @@ async function populateAgentDropdown() {
|
|||||||
const select = document.getElementById('assignee');
|
const select = document.getElementById('assignee');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
|
|
||||||
// Keep the first option ("Select agent...")
|
|
||||||
const firstOption = select.options[0];
|
const firstOption = select.options[0];
|
||||||
select.innerHTML = '';
|
select.innerHTML = '';
|
||||||
select.appendChild(firstOption);
|
select.appendChild(firstOption);
|
||||||
|
|
||||||
// Add agent options
|
agents.forEach((agent) => {
|
||||||
agents.forEach(agent => {
|
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = agent.name;
|
option.value = agent.name;
|
||||||
option.textContent = agent.name;
|
option.textContent = agent.name;
|
||||||
@@ -217,11 +169,39 @@ async function populateAgentDropdown() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ WIKI ============
|
function initTasksPage() {
|
||||||
let wikiPages = [];
|
const taskForm = document.getElementById('task-form');
|
||||||
let currentWikiPage = null;
|
if (!taskForm) return;
|
||||||
let isEditingWiki = false;
|
|
||||||
|
|
||||||
|
taskForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const tagsValue = formData.get('tags');
|
||||||
|
const task = {
|
||||||
|
title: formData.get('title'),
|
||||||
|
description: formData.get('description'),
|
||||||
|
assignee: formData.get('assignee'),
|
||||||
|
priority: formData.get('priority'),
|
||||||
|
status: formData.get('status') || 'Backlog',
|
||||||
|
tags: (tagsValue || '').split(',').map((t) => t.trim()).filter((t) => t),
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(task),
|
||||||
|
});
|
||||||
|
|
||||||
|
e.target.reset();
|
||||||
|
loadTasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
populateAgentDropdown();
|
||||||
|
loadTasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ WIKI ============
|
||||||
async function loadWiki() {
|
async function loadWiki() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/wiki');
|
const res = await fetch('/api/wiki');
|
||||||
@@ -234,15 +214,17 @@ async function loadWiki() {
|
|||||||
|
|
||||||
function renderWikiList(filter = '') {
|
function renderWikiList(filter = '') {
|
||||||
const wikiList = document.getElementById('wiki-list');
|
const wikiList = document.getElementById('wiki-list');
|
||||||
|
if (!wikiList) return;
|
||||||
|
|
||||||
wikiList.innerHTML = '';
|
wikiList.innerHTML = '';
|
||||||
|
|
||||||
const filtered = filter
|
const filtered = filter
|
||||||
? wikiPages.filter(p => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase()))
|
? wikiPages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase()))
|
||||||
: wikiPages;
|
: wikiPages;
|
||||||
|
|
||||||
filtered.forEach(page => {
|
filtered.forEach((page) => {
|
||||||
const itemEl = document.createElement('div');
|
const itemEl = document.createElement('div');
|
||||||
itemEl.className = 'wiki-item' + (currentWikiPage && currentWikiPage.filename === page.filename ? ' active' : '');
|
itemEl.className = `wiki-item${currentWikiPage && currentWikiPage.filename === page.filename ? ' active' : ''}`;
|
||||||
itemEl.innerHTML = `
|
itemEl.innerHTML = `
|
||||||
<h4 class="wiki-title">${escapeHtml(page.title)}</h4>
|
<h4 class="wiki-title">${escapeHtml(page.title)}</h4>
|
||||||
<p class="wiki-date">${new Date(page.modified).toLocaleDateString()}</p>
|
<p class="wiki-date">${new Date(page.modified).toLocaleDateString()}</p>
|
||||||
@@ -253,13 +235,54 @@ function renderWikiList(filter = '') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wiki search
|
async function selectWikiPage(filename) {
|
||||||
document.getElementById('wiki-search').addEventListener('input', (e) => {
|
try {
|
||||||
renderWikiList(e.target.value);
|
const res = await fetch(`/api/wiki/${filename}`);
|
||||||
});
|
if (!res.ok) throw new Error('Page not found');
|
||||||
|
|
||||||
// New wiki page
|
currentWikiPage = await res.json();
|
||||||
document.getElementById('wiki-new-btn').addEventListener('click', async () => {
|
|
||||||
|
const titleEl = document.getElementById('wiki-page-title');
|
||||||
|
const actionsEl = document.getElementById('wiki-page-actions');
|
||||||
|
const contentEl = document.getElementById('wiki-content');
|
||||||
|
const editorEl = document.getElementById('wiki-editor');
|
||||||
|
const searchEl = document.getElementById('wiki-search');
|
||||||
|
|
||||||
|
if (!titleEl || !actionsEl || !contentEl || !editorEl || !searchEl) return;
|
||||||
|
|
||||||
|
titleEl.textContent = currentWikiPage.metadata.title || filename;
|
||||||
|
actionsEl.style.display = 'flex';
|
||||||
|
|
||||||
|
contentEl.style.display = 'block';
|
||||||
|
editorEl.style.display = 'none';
|
||||||
|
|
||||||
|
if (typeof marked !== 'undefined') {
|
||||||
|
contentEl.innerHTML = marked.parse(currentWikiPage.content);
|
||||||
|
} else {
|
||||||
|
contentEl.innerHTML = `<pre>${escapeHtml(currentWikiPage.content)}</pre>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderWikiList(searchEl.value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load wiki page:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initWikiPage() {
|
||||||
|
const searchInput = document.getElementById('wiki-search');
|
||||||
|
const newBtn = document.getElementById('wiki-new-btn');
|
||||||
|
const editBtn = document.getElementById('wiki-edit-btn');
|
||||||
|
const saveBtn = document.getElementById('wiki-save-btn');
|
||||||
|
const cancelBtn = document.getElementById('wiki-cancel-btn');
|
||||||
|
const deleteBtn = document.getElementById('wiki-delete-btn');
|
||||||
|
|
||||||
|
if (!searchInput || !newBtn || !editBtn || !saveBtn || !cancelBtn || !deleteBtn) return;
|
||||||
|
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
renderWikiList(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
newBtn.addEventListener('click', async () => {
|
||||||
const title = prompt('Enter page title:');
|
const title = prompt('Enter page title:');
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
@@ -267,7 +290,7 @@ document.getElementById('wiki-new-btn').addEventListener('click', async () => {
|
|||||||
const res = await fetch('/api/wiki', {
|
const res = await fetch('/api/wiki', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ title })
|
body: JSON.stringify({ title }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -278,103 +301,87 @@ document.getElementById('wiki-new-btn').addEventListener('click', async () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to create wiki page:', err);
|
console.error('Failed to create wiki page:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function selectWikiPage(filename) {
|
editBtn.addEventListener('click', () => {
|
||||||
try {
|
if (!currentWikiPage) return;
|
||||||
const res = await fetch(`/api/wiki/${filename}`);
|
|
||||||
if (!res.ok) throw new Error('Page not found');
|
|
||||||
|
|
||||||
currentWikiPage = await res.json();
|
|
||||||
|
|
||||||
// Update UI
|
|
||||||
document.getElementById('wiki-page-title').textContent = currentWikiPage.metadata.title || filename;
|
|
||||||
document.getElementById('wiki-page-actions').style.display = 'flex';
|
|
||||||
|
|
||||||
// Render markdown
|
|
||||||
const contentEl = document.getElementById('wiki-content');
|
const contentEl = document.getElementById('wiki-content');
|
||||||
contentEl.style.display = 'block';
|
const editorEl = document.getElementById('wiki-editor');
|
||||||
document.getElementById('wiki-editor').style.display = 'none';
|
const titleEl = document.getElementById('wiki-edit-title');
|
||||||
|
const editContentEl = document.getElementById('wiki-edit-content');
|
||||||
|
if (!contentEl || !editorEl || !titleEl || !editContentEl) return;
|
||||||
|
|
||||||
if (typeof marked !== 'undefined') {
|
contentEl.style.display = 'none';
|
||||||
contentEl.innerHTML = marked.parse(currentWikiPage.content);
|
editorEl.style.display = 'block';
|
||||||
} else {
|
titleEl.value = currentWikiPage.metadata.title || '';
|
||||||
contentEl.innerHTML = `<pre>${escapeHtml(currentWikiPage.content)}</pre>`;
|
editContentEl.value = currentWikiPage.content;
|
||||||
}
|
});
|
||||||
|
|
||||||
// Update list selection
|
saveBtn.addEventListener('click', async () => {
|
||||||
renderWikiList(document.getElementById('wiki-search').value);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load wiki page:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit wiki page
|
|
||||||
document.getElementById('wiki-edit-btn').addEventListener('click', () => {
|
|
||||||
if (!currentWikiPage) return;
|
if (!currentWikiPage) return;
|
||||||
|
|
||||||
isEditingWiki = true;
|
const editContentEl = document.getElementById('wiki-edit-content');
|
||||||
document.getElementById('wiki-content').style.display = 'none';
|
if (!editContentEl) return;
|
||||||
document.getElementById('wiki-editor').style.display = 'block';
|
|
||||||
document.getElementById('wiki-edit-title').value = currentWikiPage.metadata.title || '';
|
|
||||||
document.getElementById('wiki-edit-content').value = currentWikiPage.content;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save wiki page
|
|
||||||
document.getElementById('wiki-save-btn').addEventListener('click', async () => {
|
|
||||||
if (!currentWikiPage) return;
|
|
||||||
|
|
||||||
const content = document.getElementById('wiki-edit-content').value;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
|
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content })
|
body: JSON.stringify({ content: editContentEl.value }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
isEditingWiki = false;
|
|
||||||
await selectWikiPage(currentWikiPage.filename);
|
await selectWikiPage(currentWikiPage.filename);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save wiki page:', err);
|
console.error('Failed to save wiki page:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cancel edit
|
cancelBtn.addEventListener('click', () => {
|
||||||
document.getElementById('wiki-cancel-btn').addEventListener('click', () => {
|
const editorEl = document.getElementById('wiki-editor');
|
||||||
isEditingWiki = false;
|
const contentEl = document.getElementById('wiki-content');
|
||||||
document.getElementById('wiki-editor').style.display = 'none';
|
if (!editorEl || !contentEl) return;
|
||||||
document.getElementById('wiki-content').style.display = 'block';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete wiki page
|
editorEl.style.display = 'none';
|
||||||
document.getElementById('wiki-delete-btn').addEventListener('click', async () => {
|
contentEl.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteBtn.addEventListener('click', async () => {
|
||||||
if (!currentWikiPage) return;
|
if (!currentWikiPage) return;
|
||||||
|
|
||||||
if (!confirm(`Delete "${currentWikiPage.metadata.title || currentWikiPage.filename}"?`)) return;
|
if (!confirm(`Delete "${currentWikiPage.metadata.title || currentWikiPage.filename}"?`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
|
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
currentWikiPage = null;
|
currentWikiPage = null;
|
||||||
document.getElementById('wiki-page-title').textContent = 'Select a page';
|
|
||||||
document.getElementById('wiki-page-actions').style.display = 'none';
|
const pageTitle = document.getElementById('wiki-page-title');
|
||||||
document.getElementById('wiki-content').innerHTML = '<div class="wiki-placeholder"><p>📚 Select a wiki page from the sidebar or create a new one.</p></div>';
|
const pageActions = document.getElementById('wiki-page-actions');
|
||||||
|
const wikiContent = document.getElementById('wiki-content');
|
||||||
|
|
||||||
|
if (pageTitle) pageTitle.textContent = 'Select a page';
|
||||||
|
if (pageActions) pageActions.style.display = 'none';
|
||||||
|
if (wikiContent) {
|
||||||
|
wikiContent.innerHTML = '<div class="wiki-placeholder"><p>📚 Select a wiki page from the sidebar or create a new one.</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
await loadWiki();
|
await loadWiki();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete wiki page:', err);
|
console.error('Failed to delete wiki page:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadWiki();
|
||||||
|
}
|
||||||
|
|
||||||
// ============ AGENTS ============
|
// ============ AGENTS ============
|
||||||
let allAgents = [];
|
|
||||||
|
|
||||||
async function loadAgents() {
|
async function loadAgents() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/agents');
|
const res = await fetch('/api/agents');
|
||||||
@@ -387,22 +394,24 @@ async function loadAgents() {
|
|||||||
|
|
||||||
function renderAgents(filter = '', statusFilter = '') {
|
function renderAgents(filter = '', statusFilter = '') {
|
||||||
const grid = document.getElementById('agents-grid');
|
const grid = document.getElementById('agents-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
let filtered = allAgents;
|
let filtered = allAgents;
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
filtered = filtered.filter(a =>
|
filtered = filtered.filter((a) =>
|
||||||
a.name.toLowerCase().includes(filter.toLowerCase()) ||
|
a.name.toLowerCase().includes(filter.toLowerCase()) ||
|
||||||
(a.currentTask && a.currentTask.toLowerCase().includes(filter.toLowerCase()))
|
(a.currentTask && a.currentTask.toLowerCase().includes(filter.toLowerCase()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
filtered = filtered.filter(a => a.status === statusFilter);
|
filtered = filtered.filter((a) => a.status === statusFilter);
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.forEach(agent => {
|
filtered.forEach((agent) => {
|
||||||
const cardEl = document.createElement('div');
|
const cardEl = document.createElement('div');
|
||||||
cardEl.className = 'agent-card';
|
cardEl.className = 'agent-card';
|
||||||
|
|
||||||
@@ -424,14 +433,14 @@ function renderAgents(filter = '', statusFilter = '') {
|
|||||||
<div class="agent-section">
|
<div class="agent-section">
|
||||||
<h4>🛠️ Tools</h4>
|
<h4>🛠️ Tools</h4>
|
||||||
<div class="agent-tools">
|
<div class="agent-tools">
|
||||||
${agent.tools.length ? agent.tools.slice(0, 5).map(tool => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('') : '<span class="no-data">No tools</span>'}
|
${agent.tools.length ? agent.tools.slice(0, 5).map((tool) => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('') : '<span class="no-data">No tools</span>'}
|
||||||
${agent.tools.length > 5 ? `<span class="more-tag">+${agent.tools.length - 5} more</span>` : ''}
|
${agent.tools.length > 5 ? `<span class="more-tag">+${agent.tools.length - 5} more</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="agent-section">
|
<div class="agent-section">
|
||||||
<h4>📄 Recent Files</h4>
|
<h4>📄 Recent Files</h4>
|
||||||
<div class="agent-files">
|
<div class="agent-files">
|
||||||
${agent.files.length ? agent.files.slice(0, 5).map(file => `<span class="file-tag">${escapeHtml(file)}</span>`).join('') : '<span class="no-data">No files</span>'}
|
${agent.files.length ? agent.files.slice(0, 5).map((file) => `<span class="file-tag">${escapeHtml(file)}</span>`).join('') : '<span class="no-data">No files</span>'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,34 +450,20 @@ function renderAgents(filter = '', statusFilter = '') {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Details button
|
|
||||||
cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent));
|
cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent));
|
||||||
|
|
||||||
// Assign button
|
|
||||||
cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name));
|
cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name));
|
||||||
|
|
||||||
grid.appendChild(cardEl);
|
grid.appendChild(cardEl);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agent search
|
|
||||||
document.getElementById('agent-search').addEventListener('input', (e) => {
|
|
||||||
const statusFilter = document.getElementById('agent-status-filter').value;
|
|
||||||
renderAgents(e.target.value, statusFilter);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Agent status filter
|
|
||||||
document.getElementById('agent-status-filter').addEventListener('change', (e) => {
|
|
||||||
const searchFilter = document.getElementById('agent-search').value;
|
|
||||||
renderAgents(searchFilter, e.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Agent details modal
|
|
||||||
function showAgentDetails(agent) {
|
function showAgentDetails(agent) {
|
||||||
const modal = document.getElementById('agent-modal');
|
const modal = document.getElementById('agent-modal');
|
||||||
const body = document.getElementById('modal-agent-body');
|
const body = document.getElementById('modal-agent-body');
|
||||||
|
const title = document.getElementById('modal-agent-name');
|
||||||
|
if (!modal || !body || !title) return;
|
||||||
|
|
||||||
document.getElementById('modal-agent-name').textContent = agent.name;
|
title.textContent = agent.name;
|
||||||
|
|
||||||
body.innerHTML = `
|
body.innerHTML = `
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
@@ -487,7 +482,7 @@ function showAgentDetails(agent) {
|
|||||||
<h4>Active Tasks</h4>
|
<h4>Active Tasks</h4>
|
||||||
<ul class="task-list">
|
<ul class="task-list">
|
||||||
${agent.activeTasks.length
|
${agent.activeTasks.length
|
||||||
? agent.activeTasks.map(t => `<li><strong>${escapeHtml(t.title)}</strong> <span class="badge priority-${t.priority}">${t.priority}</span></li>`).join('')
|
? agent.activeTasks.map((t) => `<li><strong>${escapeHtml(t.title)}</strong> <span class="badge priority-${t.priority}">${t.priority}</span></li>`).join('')
|
||||||
: '<li class="no-data">No active tasks</li>'}
|
: '<li class="no-data">No active tasks</li>'}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,66 +490,83 @@ function showAgentDetails(agent) {
|
|||||||
<h4>Recently Completed</h4>
|
<h4>Recently Completed</h4>
|
||||||
<ul class="task-list">
|
<ul class="task-list">
|
||||||
${agent.completedTasks.length
|
${agent.completedTasks.length
|
||||||
? agent.completedTasks.map(t => `<li>${escapeHtml(t.title)}</li>`).join('')
|
? agent.completedTasks.map((t) => `<li>${escapeHtml(t.title)}</li>`).join('')
|
||||||
: '<li class="no-data">No completed tasks</li>'}
|
: '<li class="no-data">No completed tasks</li>'}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h4>Tools</h4>
|
<h4>Tools</h4>
|
||||||
<div class="tag-list">${agent.tools.length ? agent.tools.map(t => `<span class="tool-tag">${escapeHtml(t)}</span>`).join('') : '<span class="no-data">No tools</span>'}</div>
|
<div class="tag-list">${agent.tools.length ? agent.tools.map((t) => `<span class="tool-tag">${escapeHtml(t)}</span>`).join('') : '<span class="no-data">No tools</span>'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h4>Capabilities</h4>
|
<h4>Capabilities</h4>
|
||||||
<div class="tag-list">${agent.capabilities.length ? agent.capabilities.map(c => `<span class="capability-tag">${escapeHtml(c)}</span>`).join('') : '<span class="no-data">No capabilities defined</span>'}</div>
|
<div class="tag-list">${agent.capabilities.length ? agent.capabilities.map((c) => `<span class="capability-tag">${escapeHtml(c)}</span>`).join('') : '<span class="no-data">No capabilities defined</span>'}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close agent modal
|
|
||||||
document.getElementById('modal-close').addEventListener('click', () => {
|
|
||||||
document.getElementById('agent-modal').classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assign task modal
|
|
||||||
async function showAssignModal(agentName) {
|
async function showAssignModal(agentName) {
|
||||||
const modal = document.getElementById('assign-modal');
|
const modal = document.getElementById('assign-modal');
|
||||||
document.getElementById('assign-agent-name').textContent = agentName;
|
const agentNameEl = document.getElementById('assign-agent-name');
|
||||||
|
const select = document.getElementById('assign-task-select');
|
||||||
|
if (!modal || !agentNameEl || !select) return;
|
||||||
|
|
||||||
|
agentNameEl.textContent = agentName;
|
||||||
|
|
||||||
// Load unassigned tasks
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/tasks');
|
const res = await fetch('/api/tasks');
|
||||||
const tasks = await res.json();
|
const tasks = await res.json();
|
||||||
const unassignedTasks = tasks.filter(t => t.status !== 'Done' && (!t.assignee || t.assignee === ''));
|
const unassignedTasks = tasks.filter((t) => t.status !== 'Done' && (!t.assignee || t.assignee === ''));
|
||||||
|
|
||||||
const select = document.getElementById('assign-task-select');
|
|
||||||
select.innerHTML = '<option value="">Select a task...</option>';
|
select.innerHTML = '<option value="">Select a task...</option>';
|
||||||
|
|
||||||
unassignedTasks.forEach(task => {
|
unassignedTasks.forEach((task) => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = task.id;
|
option.value = task.id;
|
||||||
option.textContent = `${task.title} (${task.priority})`;
|
option.textContent = `${task.title} (${task.priority})`;
|
||||||
select.appendChild(option);
|
select.appendChild(option);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store agent name for assignment
|
|
||||||
select.dataset.agent = agentName;
|
select.dataset.agent = agentName;
|
||||||
|
|
||||||
modal.classList.add('active');
|
modal.classList.add('active');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load tasks for assignment:', err);
|
console.error('Failed to load tasks for assignment:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close assign modal
|
function initAgentsPage() {
|
||||||
document.getElementById('assign-modal-close').addEventListener('click', () => {
|
const searchInput = document.getElementById('agent-search');
|
||||||
document.getElementById('assign-modal').classList.remove('active');
|
const statusFilter = document.getElementById('agent-status-filter');
|
||||||
});
|
const closeBtn = document.getElementById('modal-close');
|
||||||
|
const assignCloseBtn = document.getElementById('assign-modal-close');
|
||||||
|
const confirmAssignBtn = document.getElementById('confirm-assign-btn');
|
||||||
|
|
||||||
// Confirm assignment
|
if (!searchInput || !statusFilter || !closeBtn || !assignCloseBtn || !confirmAssignBtn) return;
|
||||||
document.getElementById('confirm-assign-btn').addEventListener('click', async () => {
|
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
renderAgents(e.target.value, statusFilter.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
statusFilter.addEventListener('change', (e) => {
|
||||||
|
renderAgents(searchInput.value, e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', () => {
|
||||||
|
const modal = document.getElementById('agent-modal');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
assignCloseBtn.addEventListener('click', () => {
|
||||||
|
const modal = document.getElementById('assign-modal');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmAssignBtn.addEventListener('click', async () => {
|
||||||
const select = document.getElementById('assign-task-select');
|
const select = document.getElementById('assign-task-select');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
const taskId = select.value;
|
const taskId = select.value;
|
||||||
const agentName = select.dataset.agent;
|
const agentName = select.dataset.agent;
|
||||||
|
|
||||||
@@ -567,40 +579,37 @@ document.getElementById('confirm-assign-btn').addEventListener('click', async ()
|
|||||||
const res = await fetch(`/api/agents/${agentName}/assign`, {
|
const res = await fetch(`/api/agents/${agentName}/assign`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ taskId: parseInt(taskId) })
|
body: JSON.stringify({ taskId: Number.parseInt(taskId, 10) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
document.getElementById('assign-modal').classList.remove('active');
|
const modal = document.getElementById('assign-modal');
|
||||||
|
if (modal) modal.classList.remove('active');
|
||||||
await loadAgents();
|
await loadAgents();
|
||||||
await loadTasks();
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to assign task:', err);
|
console.error('Failed to assign task:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadAgents();
|
||||||
|
}
|
||||||
|
|
||||||
// ============ USAGE ============
|
// ============ USAGE ============
|
||||||
let usageStats = null;
|
|
||||||
let providerChart = null;
|
|
||||||
let agentChart = null;
|
|
||||||
|
|
||||||
async function loadUsage() {
|
async function loadUsage() {
|
||||||
const from = document.getElementById('usage-from').value;
|
const from = document.getElementById('usage-from')?.value;
|
||||||
const to = document.getElementById('usage-to').value;
|
const to = document.getElementById('usage-to')?.value;
|
||||||
|
|
||||||
let statsUrl = '/api/usage/stats';
|
let statsUrl = '/api/usage/stats';
|
||||||
const params = [];
|
const params = [];
|
||||||
if (from) params.push(`from=${from}`);
|
if (from) params.push(`from=${from}`);
|
||||||
if (to) params.push(`to=${to}`);
|
if (to) params.push(`to=${to}`);
|
||||||
if (params.length) statsUrl += '?' + params.join('&');
|
if (params.length) statsUrl += `?${params.join('&')}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load stats
|
|
||||||
const statsRes = await fetch(statsUrl);
|
const statsRes = await fetch(statsUrl);
|
||||||
usageStats = await statsRes.json();
|
usageStats = await statsRes.json();
|
||||||
|
|
||||||
// Load basic usage info
|
|
||||||
const usageRes = await fetch('/api/usage');
|
const usageRes = await fetch('/api/usage');
|
||||||
const usageData = await usageRes.json();
|
const usageData = await usageRes.json();
|
||||||
|
|
||||||
@@ -613,24 +622,33 @@ async function loadUsage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderUsageStats() {
|
function renderUsageStats() {
|
||||||
document.getElementById('stat-requests').textContent = usageStats.totalRequests.toLocaleString();
|
const requestsEl = document.getElementById('stat-requests');
|
||||||
document.getElementById('stat-tokens').textContent = usageStats.totalTokens.toLocaleString();
|
const tokensEl = document.getElementById('stat-tokens');
|
||||||
document.getElementById('stat-cost').textContent = '$' + usageStats.totalCost.toFixed(2);
|
const costEl = document.getElementById('stat-cost');
|
||||||
|
|
||||||
|
if (!requestsEl || !tokensEl || !costEl || !usageStats) return;
|
||||||
|
|
||||||
|
requestsEl.textContent = usageStats.totalRequests.toLocaleString();
|
||||||
|
tokensEl.textContent = usageStats.totalTokens.toLocaleString();
|
||||||
|
costEl.textContent = `$${usageStats.totalCost.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsageCharts() {
|
function renderUsageCharts() {
|
||||||
const rootStyles = getComputedStyle(document.documentElement);
|
if (!usageStats || typeof Chart === 'undefined') return;
|
||||||
const themeForeground = rootStyles.getPropertyValue('--fg').trim() || '#e0e0e0';
|
|
||||||
const themeBorder = rootStyles.getPropertyValue('--border').trim() || '#444';
|
|
||||||
const themePrimary = rootStyles.getPropertyValue('--primary').trim() || '#3498db';
|
|
||||||
|
|
||||||
// Provider chart
|
const providerCanvas = document.getElementById('chart-provider');
|
||||||
const providerCtx = document.getElementById('chart-provider').getContext('2d');
|
const agentCanvas = document.getElementById('chart-agent');
|
||||||
|
if (!providerCanvas || !agentCanvas) return;
|
||||||
|
|
||||||
|
const providerCtx = providerCanvas.getContext('2d');
|
||||||
|
const agentCtx = agentCanvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!providerCtx || !agentCtx) return;
|
||||||
|
|
||||||
if (providerChart) providerChart.destroy();
|
if (providerChart) providerChart.destroy();
|
||||||
|
|
||||||
const providerLabels = Object.keys(usageStats.byProvider);
|
const providerLabels = Object.keys(usageStats.byProvider);
|
||||||
const providerData = providerLabels.map(p => usageStats.byProvider[p].requests);
|
const providerData = providerLabels.map((p) => usageStats.byProvider[p].requests);
|
||||||
|
|
||||||
providerChart = new Chart(providerCtx, {
|
providerChart = new Chart(providerCtx, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
@@ -638,29 +656,24 @@ function renderUsageCharts() {
|
|||||||
labels: providerLabels,
|
labels: providerLabels,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: providerData,
|
data: providerData,
|
||||||
backgroundColor: [
|
backgroundColor: ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9c27b0', '#00bcd4'],
|
||||||
'#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9c27b0', '#00bcd4'
|
}],
|
||||||
]
|
|
||||||
}]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom',
|
position: 'bottom',
|
||||||
labels: { color: themeForeground }
|
labels: { color: '#e0e0e0' },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agent chart
|
|
||||||
const agentCtx = document.getElementById('chart-agent').getContext('2d');
|
|
||||||
|
|
||||||
if (agentChart) agentChart.destroy();
|
if (agentChart) agentChart.destroy();
|
||||||
|
|
||||||
const agentLabels = Object.keys(usageStats.byAgent);
|
const agentLabels = Object.keys(usageStats.byAgent);
|
||||||
const agentData = agentLabels.map(a => usageStats.byAgent[a].requests);
|
const agentData = agentLabels.map((a) => usageStats.byAgent[a].requests);
|
||||||
|
|
||||||
agentChart = new Chart(agentCtx, {
|
agentChart = new Chart(agentCtx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
@@ -669,36 +682,34 @@ function renderUsageCharts() {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Requests',
|
label: 'Requests',
|
||||||
data: agentData,
|
data: agentData,
|
||||||
backgroundColor: themePrimary
|
backgroundColor: '#3498db',
|
||||||
}]
|
}],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
plugins: {
|
plugins: { legend: { display: false } },
|
||||||
legend: {
|
|
||||||
display: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: themeForeground },
|
ticks: { color: '#e0e0e0' },
|
||||||
grid: { color: themeBorder }
|
grid: { color: '#444' },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: { color: themeForeground },
|
ticks: { color: '#e0e0e0' },
|
||||||
grid: { color: themeBorder }
|
grid: { color: '#444' },
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProviderDetails(usageData) {
|
function renderProviderDetails(usageData) {
|
||||||
const grid = document.getElementById('provider-grid');
|
const grid = document.getElementById('provider-grid');
|
||||||
|
if (!grid || !usageData || !usageStats) return;
|
||||||
|
|
||||||
grid.innerHTML = '';
|
grid.innerHTML = '';
|
||||||
|
|
||||||
usageData.providers.forEach(provider => {
|
usageData.providers.forEach((provider) => {
|
||||||
const providerEl = document.createElement('div');
|
const providerEl = document.createElement('div');
|
||||||
providerEl.className = 'provider-card';
|
providerEl.className = 'provider-card';
|
||||||
|
|
||||||
@@ -722,7 +733,7 @@ function renderProviderDetails(usageData) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="model-list">
|
<div class="model-list">
|
||||||
<h5>Models</h5>
|
<h5>Models</h5>
|
||||||
${provider.models.map(model => `
|
${provider.models.map((model) => `
|
||||||
<div class="model-item">
|
<div class="model-item">
|
||||||
<span class="model-name">${escapeHtml(model.name)}</span>
|
<span class="model-name">${escapeHtml(model.name)}</span>
|
||||||
<span class="model-type">${escapeHtml(model.type)}</span>
|
<span class="model-type">${escapeHtml(model.type)}</span>
|
||||||
@@ -735,28 +746,44 @@ function renderProviderDetails(usageData) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply date filter
|
function initUsagePage() {
|
||||||
document.getElementById('usage-apply-filter').addEventListener('click', loadUsage);
|
const fromInput = document.getElementById('usage-from');
|
||||||
|
const toInput = document.getElementById('usage-to');
|
||||||
|
const applyBtn = document.getElementById('usage-apply-filter');
|
||||||
|
const exportJsonBtn = document.getElementById('export-json');
|
||||||
|
const exportCsvBtn = document.getElementById('export-csv');
|
||||||
|
|
||||||
// Export JSON
|
if (!fromInput || !toInput || !applyBtn || !exportJsonBtn || !exportCsvBtn) return;
|
||||||
document.getElementById('export-json').addEventListener('click', () => {
|
|
||||||
const from = document.getElementById('usage-from').value;
|
const to = new Date();
|
||||||
const to = document.getElementById('usage-to').value;
|
const from = new Date();
|
||||||
|
from.setDate(from.getDate() - 30);
|
||||||
|
|
||||||
|
fromInput.value = from.toISOString().split('T')[0];
|
||||||
|
toInput.value = to.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
applyBtn.addEventListener('click', loadUsage);
|
||||||
|
|
||||||
|
exportJsonBtn.addEventListener('click', () => {
|
||||||
|
const fromValue = fromInput.value;
|
||||||
|
const toValue = toInput.value;
|
||||||
let url = '/api/usage/export?format=json';
|
let url = '/api/usage/export?format=json';
|
||||||
if (from) url += `&from=${from}`;
|
if (fromValue) url += `&from=${fromValue}`;
|
||||||
if (to) url += `&to=${to}`;
|
if (toValue) url += `&to=${toValue}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Export CSV
|
exportCsvBtn.addEventListener('click', () => {
|
||||||
document.getElementById('export-csv').addEventListener('click', () => {
|
const fromValue = fromInput.value;
|
||||||
const from = document.getElementById('usage-from').value;
|
const toValue = toInput.value;
|
||||||
const to = document.getElementById('usage-to').value;
|
|
||||||
let url = '/api/usage/export?format=csv';
|
let url = '/api/usage/export?format=csv';
|
||||||
if (from) url += `&from=${from}`;
|
if (fromValue) url += `&from=${fromValue}`;
|
||||||
if (to) url += `&to=${to}`;
|
if (toValue) url += `&to=${toValue}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadUsage();
|
||||||
|
}
|
||||||
|
|
||||||
// ============ HELPERS ============
|
// ============ HELPERS ============
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -766,31 +793,28 @@ function escapeHtml(text) {
|
|||||||
'<': '<',
|
'<': '<',
|
||||||
'>': '>',
|
'>': '>',
|
||||||
'"': '"',
|
'"': '"',
|
||||||
"'": '''
|
"'": ''',
|
||||||
};
|
};
|
||||||
return text.replace(/[&<>"']/g, m => map[m]);
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ INITIALIZATION ============
|
function setupModalBackdropClose() {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.querySelectorAll('.modal').forEach((modal) => {
|
||||||
initTheme();
|
|
||||||
populateAgentDropdown();
|
|
||||||
loadTasks();
|
|
||||||
|
|
||||||
// Set default date range (last 30 days)
|
|
||||||
const to = new Date();
|
|
||||||
const from = new Date();
|
|
||||||
from.setDate(from.getDate() - 30);
|
|
||||||
|
|
||||||
document.getElementById('usage-from').value = from.toISOString().split('T')[0];
|
|
||||||
document.getElementById('usage-to').value = to.toISOString().split('T')[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modals on outside click
|
|
||||||
document.querySelectorAll('.modal').forEach(modal => {
|
|
||||||
modal.addEventListener('click', (e) => {
|
modal.addEventListener('click', (e) => {
|
||||||
if (e.target === modal) {
|
if (e.target === modal) {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ INITIALIZATION ============
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initTheme();
|
||||||
|
setupModalBackdropClose();
|
||||||
|
|
||||||
|
if (CURRENT_PAGE === 'tasks') initTasksPage();
|
||||||
|
if (CURRENT_PAGE === 'wiki') initWikiPage();
|
||||||
|
if (CURRENT_PAGE === 'agents') initAgentsPage();
|
||||||
|
if (CURRENT_PAGE === 'usage') initUsagePage();
|
||||||
});
|
});
|
||||||
|
|||||||
55
server.js
55
server.js
@@ -53,9 +53,62 @@ db.serialize(() => {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
const VIEWS_DIR = path.join(__dirname, 'views');
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
|
||||||
|
|
||||||
|
function renderTemplate(template, vars = {}) {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||||
|
const value = vars[key];
|
||||||
|
return value === undefined || value === null ? '' : String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(viewName, activeTab, pageTitle) {
|
||||||
|
const layoutPath = path.join(VIEWS_DIR, 'layout.html');
|
||||||
|
const viewPath = path.join(VIEWS_DIR, `${viewName}.html`);
|
||||||
|
const layout = fs.readFileSync(layoutPath, 'utf8');
|
||||||
|
const content = fs.readFileSync(viewPath, 'utf8');
|
||||||
|
|
||||||
|
return renderTemplate(layout, {
|
||||||
|
pageTitle,
|
||||||
|
pageName: viewName,
|
||||||
|
content,
|
||||||
|
tasksActive: activeTab === 'tasks' ? 'active' : '',
|
||||||
|
wikiActive: activeTab === 'wiki' ? 'active' : '',
|
||||||
|
agentsActive: activeTab === 'agents' ? 'active' : '',
|
||||||
|
usageActive: activeTab === 'usage' ? 'active' : '',
|
||||||
|
markedScript: viewName === 'wiki'
|
||||||
|
? '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>'
|
||||||
|
: '',
|
||||||
|
chartScript: viewName === 'usage'
|
||||||
|
? '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SERVER-RENDERED PAGES ============
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.redirect('/tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/tasks', (req, res) => {
|
||||||
|
res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/wiki', (req, res) => {
|
||||||
|
res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/agents', (req, res) => {
|
||||||
|
res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/usage', (req, res) => {
|
||||||
|
res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage'));
|
||||||
|
});
|
||||||
|
|
||||||
function normalizeTask(row) {
|
function normalizeTask(row) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
40
views/agents.html
Normal file
40
views/agents.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<section id="page-agents" class="page active">
|
||||||
|
<div class="agents-header">
|
||||||
|
<h2>Agent Fleet</h2>
|
||||||
|
<div class="agents-controls">
|
||||||
|
<input type="text" id="agent-search" placeholder="Search agents..." />
|
||||||
|
<select id="agent-status-filter">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="busy">Busy</option>
|
||||||
|
<option value="idle">Idle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="agents-grid" class="agents-grid"></div>
|
||||||
|
|
||||||
|
<div id="agent-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-agent-name">Agent Details</h3>
|
||||||
|
<button class="modal-close" id="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-agent-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="assign-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Assign Task to <span id="assign-agent-name"></span></h3>
|
||||||
|
<button class="modal-close" id="assign-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<select id="assign-task-select">
|
||||||
|
<option value="">Select a task...</option>
|
||||||
|
</select>
|
||||||
|
<button id="confirm-assign-btn" class="btn-primary">Assign Task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
30
views/layout.html
Normal file
30
views/layout.html
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{{pageTitle}}</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
{{markedScript}}
|
||||||
|
{{chartScript}}
|
||||||
|
</head>
|
||||||
|
<body data-page="{{pageName}}">
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="/tasks" class="nav-link {{tasksActive}}">Tasks</a>
|
||||||
|
<a href="/wiki" class="nav-link {{wikiActive}}">Wiki</a>
|
||||||
|
<a href="/agents" class="nav-link {{agentsActive}}">Agents</a>
|
||||||
|
<a href="/usage" class="nav-link {{usageActive}}">Usage</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{{content}}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
views/tasks.html
Normal file
58
views/tasks.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<section id="page-tasks" class="page active">
|
||||||
|
<div class="composer">
|
||||||
|
<h2>Create Task</h2>
|
||||||
|
<form id="task-form">
|
||||||
|
<input id="title" name="title" placeholder="Task title" required />
|
||||||
|
<select id="assignee" name="assignee">
|
||||||
|
<option value="">Select agent...</option>
|
||||||
|
</select>
|
||||||
|
<select id="priority" name="priority">
|
||||||
|
<option>Low</option>
|
||||||
|
<option selected>Medium</option>
|
||||||
|
<option>High</option>
|
||||||
|
<option>Critical</option>
|
||||||
|
</select>
|
||||||
|
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
|
||||||
|
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
|
||||||
|
<button type="submit">Create Task</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="board">
|
||||||
|
<div class="column" data-status="Backlog">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📋 Backlog</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Todo">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📝 Todo</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="In Progress">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>🔄 In Progress</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Review">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>👀 Review</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Done">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>✅ Done</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
49
views/usage.html
Normal file
49
views/usage.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<section id="page-usage" class="page active">
|
||||||
|
<div class="usage-header">
|
||||||
|
<h2>API Usage & Statistics</h2>
|
||||||
|
<div class="usage-controls">
|
||||||
|
<div class="date-range">
|
||||||
|
<label>From:</label>
|
||||||
|
<input type="date" id="usage-from" />
|
||||||
|
<label>To:</label>
|
||||||
|
<input type="date" id="usage-to" />
|
||||||
|
<button id="usage-apply-filter" class="btn-secondary">Apply</button>
|
||||||
|
</div>
|
||||||
|
<div class="export-actions">
|
||||||
|
<button id="export-json" class="btn-secondary">Export JSON</button>
|
||||||
|
<button id="export-csv" class="btn-secondary">Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Total Requests</h4>
|
||||||
|
<div class="stat-value" id="stat-requests">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Total Tokens</h4>
|
||||||
|
<div class="stat-value" id="stat-tokens">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Estimated Cost</h4>
|
||||||
|
<div class="stat-value" id="stat-cost">$0.00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-charts">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Usage by Provider</h4>
|
||||||
|
<canvas id="chart-provider"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Usage by Agent</h4>
|
||||||
|
<canvas id="chart-agent"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="usage-data" class="usage-details">
|
||||||
|
<h3>Provider Details</h3>
|
||||||
|
<div class="usage-grid" id="provider-grid"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
35
views/wiki.html
Normal file
35
views/wiki.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<section id="page-wiki" class="page active">
|
||||||
|
<div class="wiki-container">
|
||||||
|
<div class="wiki-sidebar">
|
||||||
|
<div class="wiki-actions">
|
||||||
|
<button id="wiki-new-btn" class="btn-primary">+ New Page</button>
|
||||||
|
</div>
|
||||||
|
<div class="wiki-search">
|
||||||
|
<input type="text" id="wiki-search" placeholder="Search wiki..." />
|
||||||
|
</div>
|
||||||
|
<div id="wiki-list" class="wiki-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="wiki-main">
|
||||||
|
<div class="wiki-toolbar">
|
||||||
|
<div class="wiki-page-title" id="wiki-page-title">Select a page</div>
|
||||||
|
<div class="wiki-page-actions" id="wiki-page-actions" style="display: none;">
|
||||||
|
<button id="wiki-edit-btn" class="btn-secondary">Edit</button>
|
||||||
|
<button id="wiki-delete-btn" class="btn-danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wiki-content" class="wiki-content">
|
||||||
|
<div class="wiki-placeholder">
|
||||||
|
<p>📚 Select a wiki page from the sidebar or create a new one.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="wiki-editor" class="wiki-editor" style="display: none;">
|
||||||
|
<input type="text" id="wiki-edit-title" placeholder="Page title" />
|
||||||
|
<textarea id="wiki-edit-content" placeholder="Markdown content..."></textarea>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button id="wiki-save-btn" class="btn-primary">Save</button>
|
||||||
|
<button id="wiki-cancel-btn" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user