1096 lines
34 KiB
JavaScript
1096 lines
34 KiB
JavaScript
// ============ THEME ============
|
|
const THEME_STORAGE_KEY = 'agentdash-theme';
|
|
const themeToggleBtn = document.getElementById('theme-toggle');
|
|
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
|
|
|
|
function getSystemTheme() {
|
|
return systemThemeMedia.matches ? 'dark' : 'light';
|
|
}
|
|
|
|
function getSavedTheme() {
|
|
try {
|
|
const saved = localStorage.getItem(THEME_STORAGE_KEY);
|
|
return saved === 'light' || saved === 'dark' ? saved : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function setSavedTheme(theme) {
|
|
try {
|
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
} catch {
|
|
// Ignore localStorage errors (privacy mode, quota, etc.).
|
|
}
|
|
}
|
|
|
|
function updateThemeToggleLabel() {
|
|
if (!themeToggleBtn) return;
|
|
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
const nextTheme = isDarkTheme ? 'light' : 'dark';
|
|
themeToggleBtn.textContent = isDarkTheme ? 'Light Mode' : 'Dark Mode';
|
|
themeToggleBtn.setAttribute('aria-label', `Switch to ${nextTheme} mode`);
|
|
}
|
|
|
|
function applyTheme(theme, { persist = false } = {}) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
if (persist) setSavedTheme(theme);
|
|
updateThemeToggleLabel();
|
|
if (usageStats) renderUsageCharts();
|
|
}
|
|
|
|
function initTheme() {
|
|
const savedTheme = getSavedTheme();
|
|
applyTheme(savedTheme || getSystemTheme());
|
|
|
|
if (themeToggleBtn) {
|
|
themeToggleBtn.addEventListener('click', () => {
|
|
const currentTheme = document.documentElement.getAttribute('data-theme') || getSystemTheme();
|
|
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
applyTheme(nextTheme, { persist: true });
|
|
});
|
|
}
|
|
|
|
systemThemeMedia.addEventListener('change', (event) => {
|
|
if (!getSavedTheme()) {
|
|
applyTheme(event.matches ? 'dark' : 'light');
|
|
}
|
|
});
|
|
}
|
|
|
|
// ============ STATE ============
|
|
const COLUMNS = {
|
|
Backlog: { title: '📋 Backlog', tasks: [] },
|
|
Todo: { title: '📝 Todo', tasks: [] },
|
|
'In Progress': { title: '🔄 In Progress', tasks: [] },
|
|
Review: { title: '👀 Review', 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() {
|
|
const res = await fetch('/api/tasks');
|
|
const tasks = await res.json();
|
|
|
|
Object.keys(COLUMNS).forEach((status) => {
|
|
COLUMNS[status].tasks = [];
|
|
});
|
|
|
|
tasks.forEach((task) => {
|
|
if (COLUMNS[task.status]) {
|
|
COLUMNS[task.status].tasks.push(task);
|
|
}
|
|
});
|
|
|
|
renderBoard();
|
|
}
|
|
|
|
function renderBoard() {
|
|
const board = document.getElementById('board');
|
|
if (!board) return;
|
|
|
|
board.innerHTML = '';
|
|
|
|
Object.entries(COLUMNS).forEach(([status, column]) => {
|
|
const columnEl = document.createElement('div');
|
|
columnEl.className = 'column';
|
|
|
|
columnEl.innerHTML = `
|
|
<div class="column-header">
|
|
<h3>${column.title}</h3>
|
|
<span class="column-count">${column.tasks.length}</span>
|
|
</div>
|
|
<div class="cards"></div>
|
|
`;
|
|
|
|
const cardsEl = columnEl.querySelector('.cards');
|
|
|
|
column.tasks.forEach((task) => {
|
|
const cardEl = document.createElement('div');
|
|
cardEl.className = 'card';
|
|
cardEl.innerHTML = `
|
|
<div class="card-head">
|
|
<h3 class="card-title">${escapeHtml(task.title)}</h3>
|
|
<span class="badge priority-${task.priority}">${task.priority}</span>
|
|
</div>
|
|
<p class="card-desc">${escapeHtml(task.description || '')}</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>
|
|
<label>
|
|
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
|
|
Mark Complete
|
|
</label>
|
|
`;
|
|
|
|
const checkbox = cardEl.querySelector('.card-check');
|
|
checkbox.addEventListener('change', async () => {
|
|
await fetch(`/api/tasks/${task.id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ status: 'Done' }),
|
|
});
|
|
loadTasks();
|
|
});
|
|
|
|
cardsEl.appendChild(cardEl);
|
|
});
|
|
|
|
board.appendChild(columnEl);
|
|
});
|
|
}
|
|
|
|
async function populateAgentDropdown() {
|
|
try {
|
|
const res = await fetch('/api/agents');
|
|
const agents = await res.json();
|
|
|
|
const select = document.getElementById('assignee');
|
|
if (!select) return;
|
|
|
|
const firstOption = select.options[0];
|
|
select.innerHTML = '';
|
|
select.appendChild(firstOption);
|
|
|
|
agents.forEach((agent) => {
|
|
const option = document.createElement('option');
|
|
option.value = agent.name;
|
|
option.textContent = agent.name;
|
|
select.appendChild(option);
|
|
});
|
|
} catch (err) {
|
|
console.error('Failed to load agents for dropdown:', err);
|
|
}
|
|
}
|
|
|
|
function initTasksPage() {
|
|
const taskForm = document.getElementById('task-form');
|
|
if (!taskForm) return;
|
|
|
|
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() {
|
|
try {
|
|
const res = await fetch('/api/wiki');
|
|
wikiPages = await res.json();
|
|
renderWikiList();
|
|
} catch (err) {
|
|
console.error('Failed to load wiki:', err);
|
|
}
|
|
}
|
|
|
|
function renderWikiList(filter = '') {
|
|
const wikiList = document.getElementById('wiki-list');
|
|
if (!wikiList) return;
|
|
|
|
wikiList.innerHTML = '';
|
|
|
|
const filtered = filter
|
|
? wikiPages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase()))
|
|
: wikiPages;
|
|
|
|
filtered.forEach((page) => {
|
|
const itemEl = document.createElement('div');
|
|
itemEl.className = `wiki-item${currentWikiPage && currentWikiPage.filename === page.filename ? ' active' : ''}`;
|
|
itemEl.innerHTML = `
|
|
<h4 class="wiki-title">${escapeHtml(page.title)}</h4>
|
|
<p class="wiki-date">${new Date(page.modified).toLocaleDateString()}</p>
|
|
`;
|
|
|
|
itemEl.addEventListener('click', () => selectWikiPage(page.filename));
|
|
wikiList.appendChild(itemEl);
|
|
});
|
|
}
|
|
|
|
async function selectWikiPage(filename) {
|
|
try {
|
|
const res = await fetch(`/api/wiki/${filename}`);
|
|
if (!res.ok) throw new Error('Page not found');
|
|
|
|
currentWikiPage = await res.json();
|
|
|
|
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:');
|
|
if (!title) return;
|
|
|
|
try {
|
|
const res = await fetch('/api/wiki', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
await loadWiki();
|
|
selectWikiPage(data.filename);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create wiki page:', err);
|
|
}
|
|
});
|
|
|
|
editBtn.addEventListener('click', () => {
|
|
if (!currentWikiPage) return;
|
|
|
|
const contentEl = document.getElementById('wiki-content');
|
|
const editorEl = document.getElementById('wiki-editor');
|
|
const titleEl = document.getElementById('wiki-edit-title');
|
|
const editContentEl = document.getElementById('wiki-edit-content');
|
|
if (!contentEl || !editorEl || !titleEl || !editContentEl) return;
|
|
|
|
contentEl.style.display = 'none';
|
|
editorEl.style.display = 'block';
|
|
titleEl.value = currentWikiPage.metadata.title || '';
|
|
editContentEl.value = currentWikiPage.content;
|
|
});
|
|
|
|
saveBtn.addEventListener('click', async () => {
|
|
if (!currentWikiPage) return;
|
|
|
|
const editContentEl = document.getElementById('wiki-edit-content');
|
|
if (!editContentEl) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: editContentEl.value }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
await selectWikiPage(currentWikiPage.filename);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save wiki page:', err);
|
|
}
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
const editorEl = document.getElementById('wiki-editor');
|
|
const contentEl = document.getElementById('wiki-content');
|
|
if (!editorEl || !contentEl) return;
|
|
|
|
editorEl.style.display = 'none';
|
|
contentEl.style.display = 'block';
|
|
});
|
|
|
|
deleteBtn.addEventListener('click', async () => {
|
|
if (!currentWikiPage) return;
|
|
|
|
if (!confirm(`Delete "${currentWikiPage.metadata.title || currentWikiPage.filename}"?`)) return;
|
|
|
|
try {
|
|
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (res.ok) {
|
|
currentWikiPage = null;
|
|
|
|
const pageTitle = document.getElementById('wiki-page-title');
|
|
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();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete wiki page:', err);
|
|
}
|
|
});
|
|
|
|
loadWiki();
|
|
}
|
|
|
|
// ============ AGENTS ============
|
|
async function loadAgents() {
|
|
try {
|
|
const res = await fetch('/api/agents');
|
|
allAgents = await res.json();
|
|
renderAgents();
|
|
} catch (err) {
|
|
console.error('Failed to load agents:', err);
|
|
}
|
|
}
|
|
|
|
function renderAgents(filter = '', statusFilter = '') {
|
|
const grid = document.getElementById('agents-grid');
|
|
if (!grid) return;
|
|
|
|
grid.innerHTML = '';
|
|
|
|
let filtered = allAgents;
|
|
|
|
if (filter) {
|
|
filtered = filtered.filter((a) =>
|
|
a.name.toLowerCase().includes(filter.toLowerCase()) ||
|
|
(a.currentTask && a.currentTask.toLowerCase().includes(filter.toLowerCase()))
|
|
);
|
|
}
|
|
|
|
if (statusFilter) {
|
|
filtered = filtered.filter((a) => a.status === statusFilter);
|
|
}
|
|
|
|
filtered.forEach((agent) => {
|
|
const cardEl = document.createElement('div');
|
|
cardEl.className = 'agent-card';
|
|
|
|
const statusClass = `status-${agent.status}`;
|
|
|
|
cardEl.innerHTML = `
|
|
<div class="agent-header">
|
|
<h3 class="agent-name">${escapeHtml(agent.name)}</h3>
|
|
<span class="agent-status ${statusClass}">${agent.status}</span>
|
|
</div>
|
|
<div class="agent-workload">
|
|
<span class="workload-badge">📋 ${agent.workload} active task${agent.workload !== 1 ? 's' : ''}</span>
|
|
</div>
|
|
<div class="agent-body">
|
|
<div class="agent-section">
|
|
<h4>📋 Current Task</h4>
|
|
<p class="agent-task">${agent.currentTask || 'No active task'}</p>
|
|
</div>
|
|
<div class="agent-section">
|
|
<h4>🛠️ Tools</h4>
|
|
<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 > 5 ? `<span class="more-tag">+${agent.tools.length - 5} more</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div class="agent-section">
|
|
<h4>📄 Recent Files</h4>
|
|
<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>'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="agent-actions">
|
|
<button class="btn-secondary agent-details-btn" data-agent="${escapeHtml(agent.name)}">Details</button>
|
|
<button class="btn-primary agent-assign-btn" data-agent="${escapeHtml(agent.name)}">Assign Task</button>
|
|
</div>
|
|
`;
|
|
|
|
cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent));
|
|
cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name));
|
|
|
|
grid.appendChild(cardEl);
|
|
});
|
|
}
|
|
|
|
function showAgentDetails(agent) {
|
|
const modal = document.getElementById('agent-modal');
|
|
const body = document.getElementById('modal-agent-body');
|
|
const title = document.getElementById('modal-agent-name');
|
|
if (!modal || !body || !title) return;
|
|
|
|
title.textContent = agent.name;
|
|
|
|
body.innerHTML = `
|
|
<div class="detail-section">
|
|
<h4>Status</h4>
|
|
<p><span class="agent-status status-${agent.status}">${agent.status}</span></p>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Workload</h4>
|
|
<p>${agent.workload} active task${agent.workload !== 1 ? 's' : ''}</p>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Current Task</h4>
|
|
<p>${agent.currentTask || 'No active task'}</p>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Active Tasks</h4>
|
|
<ul class="task-list">
|
|
${agent.activeTasks.length
|
|
? 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>'}
|
|
</ul>
|
|
</div>
|
|
<div class="detail-section">
|
|
<h4>Recently Completed</h4>
|
|
<ul class="task-list">
|
|
${agent.completedTasks.length
|
|
? agent.completedTasks.map((t) => `<li>${escapeHtml(t.title)}</li>`).join('')
|
|
: '<li class="no-data">No completed tasks</li>'}
|
|
</ul>
|
|
</div>
|
|
<div class="detail-section">
|
|
<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>
|
|
<div class="detail-section">
|
|
<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>
|
|
`;
|
|
|
|
modal.classList.add('active');
|
|
}
|
|
|
|
async function showAssignModal(agentName) {
|
|
const modal = document.getElementById('assign-modal');
|
|
const agentNameEl = document.getElementById('assign-agent-name');
|
|
const select = document.getElementById('assign-task-select');
|
|
if (!modal || !agentNameEl || !select) return;
|
|
|
|
agentNameEl.textContent = agentName;
|
|
|
|
try {
|
|
const res = await fetch('/api/tasks');
|
|
const tasks = await res.json();
|
|
const unassignedTasks = tasks.filter((t) => t.status !== 'Done' && (!t.assignee || t.assignee === ''));
|
|
|
|
select.innerHTML = '<option value="">Select a task...</option>';
|
|
|
|
unassignedTasks.forEach((task) => {
|
|
const option = document.createElement('option');
|
|
option.value = task.id;
|
|
option.textContent = `${task.title} (${task.priority})`;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
select.dataset.agent = agentName;
|
|
modal.classList.add('active');
|
|
} catch (err) {
|
|
console.error('Failed to load tasks for assignment:', err);
|
|
}
|
|
}
|
|
|
|
function initAgentsPage() {
|
|
const searchInput = document.getElementById('agent-search');
|
|
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');
|
|
|
|
if (!searchInput || !statusFilter || !closeBtn || !assignCloseBtn || !confirmAssignBtn) return;
|
|
|
|
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');
|
|
if (!select) return;
|
|
|
|
const taskId = select.value;
|
|
const agentName = select.dataset.agent;
|
|
|
|
if (!taskId) {
|
|
alert('Please select a task');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/agents/${agentName}/assign`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ taskId: Number.parseInt(taskId, 10) }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const modal = document.getElementById('assign-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
await loadAgents();
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to assign task:', err);
|
|
}
|
|
});
|
|
|
|
loadAgents();
|
|
}
|
|
|
|
// ============ USAGE ============
|
|
async function loadUsage() {
|
|
// Try real usage first, fallback to tracked usage
|
|
try {
|
|
const from = document.getElementById("usage-from")?.value;
|
|
const to = document.getElementById("usage-to")?.value;
|
|
let url = "/api/usage/real";
|
|
const params = [];
|
|
if (from) params.push(`from=${from}`);
|
|
if (to) params.push(`to=${to}`);
|
|
if (params.length) url += `?${params.join("&")}`;
|
|
const realRes = await fetch(url);
|
|
if (realRes.ok) {
|
|
const realData = await realRes.json();
|
|
usageStats = {
|
|
totalRequests: Object.values(realData.models || {}).reduce((sum, m) => sum + (m.requests || 0), 0),
|
|
totalTokens: realData.totals?.total || 0,
|
|
totalCost: realData.totals?.cost || 0,
|
|
agents: realData.agents,
|
|
models: realData.models
|
|
};
|
|
renderUsageStats();
|
|
if (typeof renderRealUsageCharts === 'function') {
|
|
renderRealUsageCharts(realData);
|
|
}
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
console.warn("Real usage not available, falling back");
|
|
}
|
|
|
|
const from = document.getElementById('usage-from')?.value;
|
|
const to = document.getElementById('usage-to')?.value;
|
|
|
|
let statsUrl = '/api/usage/stats';
|
|
const params = [];
|
|
if (from) params.push(`from=${from}`);
|
|
if (to) params.push(`to=${to}`);
|
|
if (params.length) statsUrl += `?${params.join('&')}`;
|
|
|
|
try {
|
|
const statsRes = await fetch(statsUrl);
|
|
usageStats = await statsRes.json();
|
|
|
|
const usageRes = await fetch('/api/usage');
|
|
const usageData = await usageRes.json();
|
|
|
|
renderUsageStats();
|
|
renderUsageCharts();
|
|
renderProviderDetails(usageData);
|
|
} catch (err) {
|
|
console.error('Failed to load usage:', err);
|
|
}
|
|
}
|
|
|
|
function renderRealUsageCharts(data) {
|
|
if (typeof Chart === 'undefined') return;
|
|
|
|
// Agent chart
|
|
const agentCanvas = document.getElementById('chart-agent');
|
|
if (agentCanvas && data.agents) {
|
|
const agents = Object.entries(data.agents)
|
|
.filter(([_, v]) => v.total > 0)
|
|
.sort((a, b) => b[1].total - a[1].total)
|
|
.slice(0, 10);
|
|
|
|
new Chart(agentCanvas, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: agents.map(([k]) => k),
|
|
datasets: [{
|
|
label: 'Tokens',
|
|
data: agents.map(([_, v]) => v.total),
|
|
backgroundColor: 'rgba(54, 162, 235, 0.6)'
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
indexAxis: 'y'
|
|
}
|
|
});
|
|
}
|
|
|
|
// Provider grid
|
|
const grid = document.getElementById('provider-grid');
|
|
if (grid && data.models) {
|
|
grid.innerHTML = Object.entries(data.models)
|
|
.map(([model, stats]) => `
|
|
<div class="provider-card">
|
|
<h4>${model}</h4>
|
|
<p>Requests: ${stats.requests || 0}</p>
|
|
<p>Tokens: ${(stats.input || 0) + (stats.output || 0)}</p>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
}
|
|
const requestsEl = document.getElementById('stat-requests');
|
|
const tokensEl = document.getElementById('stat-tokens');
|
|
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() {
|
|
if (!usageStats || typeof Chart === 'undefined') return;
|
|
|
|
const providerCanvas = document.getElementById('chart-provider');
|
|
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();
|
|
|
|
const providerLabels = Object.keys(usageStats.byProvider);
|
|
const providerData = providerLabels.map((p) => usageStats.byProvider[p].requests);
|
|
|
|
providerChart = new Chart(providerCtx, {
|
|
type: 'doughnut',
|
|
data: {
|
|
labels: providerLabels,
|
|
datasets: [{
|
|
data: providerData,
|
|
backgroundColor: ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9c27b0', '#00bcd4'],
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
position: 'bottom',
|
|
labels: { color: '#e0e0e0' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (agentChart) agentChart.destroy();
|
|
|
|
const agentLabels = Object.keys(usageStats.byAgent);
|
|
const agentData = agentLabels.map((a) => usageStats.byAgent[a].requests);
|
|
|
|
agentChart = new Chart(agentCtx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: agentLabels,
|
|
datasets: [{
|
|
label: 'Requests',
|
|
data: agentData,
|
|
backgroundColor: '#3498db',
|
|
}],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: { legend: { display: false } },
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
ticks: { color: '#e0e0e0' },
|
|
grid: { color: '#444' },
|
|
},
|
|
x: {
|
|
ticks: { color: '#e0e0e0' },
|
|
grid: { color: '#444' },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function renderProviderDetails(usageData) {
|
|
const grid = document.getElementById('provider-grid');
|
|
if (!grid || !usageData || !usageStats) return;
|
|
|
|
grid.innerHTML = '';
|
|
|
|
usageData.providers.forEach((provider) => {
|
|
const providerEl = document.createElement('div');
|
|
providerEl.className = 'provider-card';
|
|
|
|
const providerUsage = usageStats.byProvider[provider.name] || { requests: 0, tokens: 0, cost: 0 };
|
|
|
|
providerEl.innerHTML = `
|
|
<h4>${escapeHtml(provider.name)}</h4>
|
|
<div class="provider-stats">
|
|
<div class="provider-stat">
|
|
<span class="stat-label">Requests</span>
|
|
<span class="stat-value">${providerUsage.requests.toLocaleString()}</span>
|
|
</div>
|
|
<div class="provider-stat">
|
|
<span class="stat-label">Tokens</span>
|
|
<span class="stat-value">${providerUsage.tokens.toLocaleString()}</span>
|
|
</div>
|
|
<div class="provider-stat">
|
|
<span class="stat-label">Cost</span>
|
|
<span class="stat-value">$${providerUsage.cost.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
<div class="model-list">
|
|
<h5>Models</h5>
|
|
${provider.models.map((model) => `
|
|
<div class="model-item">
|
|
<span class="model-name">${escapeHtml(model.name)}</span>
|
|
<span class="model-type">${escapeHtml(model.type)}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
|
|
grid.appendChild(providerEl);
|
|
});
|
|
}
|
|
|
|
function initUsagePage() {
|
|
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');
|
|
|
|
if (!fromInput || !toInput || !applyBtn || !exportJsonBtn || !exportCsvBtn) return;
|
|
|
|
const to = new Date();
|
|
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/real?format=json';
|
|
if (fromValue) url += `&from=${fromValue}`;
|
|
if (toValue) url += `&to=${toValue}`;
|
|
window.open(url, '_blank');
|
|
});
|
|
|
|
exportCsvBtn.addEventListener('click', () => {
|
|
const fromValue = fromInput.value;
|
|
const toValue = toInput.value;
|
|
let url = '/api/usage/export/real?format=csv';
|
|
if (fromValue) url += `&from=${fromValue}`;
|
|
if (toValue) url += `&to=${toValue}`;
|
|
window.open(url, '_blank');
|
|
});
|
|
|
|
loadUsage();
|
|
}
|
|
|
|
// ============ HELPERS ============
|
|
function escapeHtml(text) {
|
|
if (typeof text !== 'string') return '';
|
|
const map = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
};
|
|
return text.replace(/[&<>"']/g, (m) => map[m]);
|
|
}
|
|
|
|
function setupModalBackdropClose() {
|
|
document.querySelectorAll('.modal').forEach((modal) => {
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.classList.remove('active');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ============ INITIALIZATION ============
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
const CURRENT_PAGE = document.body?.dataset?.page || "tasks";
|
|
initTheme();
|
|
setupModalBackdropClose();
|
|
|
|
if (CURRENT_PAGE === 'tasks') initTasksPage();
|
|
if (CURRENT_PAGE === 'wiki') initWikiPage();
|
|
if (CURRENT_PAGE === 'agents') initAgentsPage();
|
|
if (CURRENT_PAGE === 'usage') initUsagePage();
|
|
});
|
|
|
|
// ============ GITEA DASHBOARD ============
|
|
let giteaData = {
|
|
repos: [],
|
|
reviews: [],
|
|
activity: []
|
|
};
|
|
|
|
// Tab switching
|
|
document.querySelectorAll('.gitea-tabs .tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
// Update active tab button
|
|
document.querySelectorAll('.gitea-tabs .tab-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
|
|
// Show corresponding content
|
|
const tabName = btn.dataset.tab;
|
|
document.querySelectorAll('.tab-content').forEach(content => {
|
|
content.classList.remove('active');
|
|
});
|
|
document.getElementById(`${tabName}-tab`).classList.add('active');
|
|
|
|
// Load data for tab
|
|
if (tabName === 'swarm') loadGiteaSwarm();
|
|
else if (tabName === 'reviews') loadGiteaReviews();
|
|
else if (tabName === 'activity') loadGiteaActivity();
|
|
});
|
|
});
|
|
|
|
async function loadGiteaSwarm() {
|
|
try {
|
|
const response = await fetch('/api/gitea/swarm');
|
|
const repos = await response.json();
|
|
giteaData.repos = repos;
|
|
|
|
// Update stats
|
|
const totalRepos = repos.length;
|
|
const totalPRs = repos.reduce((sum, r) => sum + r.open_prs, 0);
|
|
const totalIssues = repos.reduce((sum, r) => sum + r.open_issues, 0);
|
|
const totalBranches = repos.reduce((sum, r) => sum + r.branches, 0);
|
|
|
|
document.getElementById('total-repos').textContent = totalRepos;
|
|
document.getElementById('total-prs').textContent = totalPRs;
|
|
document.getElementById('total-issues').textContent = totalIssues;
|
|
document.getElementById('total-branches').textContent = totalBranches;
|
|
|
|
// Render repo list
|
|
const repoList = document.getElementById('repo-list');
|
|
if (repos.length === 0) {
|
|
repoList.innerHTML = '<p class="empty">No repositories found</p>';
|
|
return;
|
|
}
|
|
|
|
repoList.innerHTML = repos.map(repo => `
|
|
<div class="repo-card">
|
|
<div class="repo-header">
|
|
<h3><a href="${repo.html_url}" target="_blank">${repo.name}</a></h3>
|
|
<span class="repo-stats">
|
|
⭐ ${repo.stars} 🍴 ${repo.forks}
|
|
</span>
|
|
</div>
|
|
<div class="repo-metrics">
|
|
<span class="metric ${repo.open_prs > 0 ? 'has-items' : ''}">
|
|
🔀 ${repo.open_prs} PRs
|
|
</span>
|
|
<span class="metric ${repo.open_issues > 0 ? 'has-items' : ''}">
|
|
🐛 ${repo.open_issues} Issues
|
|
</span>
|
|
<span class="metric">
|
|
🌿 ${repo.branches} Branches
|
|
</span>
|
|
</div>
|
|
<div class="repo-footer">
|
|
<span class="updated">Updated: ${new Date(repo.updated_at).toLocaleDateString()}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading Gitea swarm:', error);
|
|
document.getElementById('repo-list').innerHTML =
|
|
`<p class="error">Failed to load repositories: ${error.message}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadGiteaReviews() {
|
|
try {
|
|
const response = await fetch('/api/gitea/reviews');
|
|
const reviews = await response.json();
|
|
giteaData.reviews = reviews;
|
|
|
|
const reviewsList = document.getElementById('reviews-list');
|
|
if (reviews.length === 0) {
|
|
reviewsList.innerHTML = '<p class="empty">No pending reviews</p>';
|
|
return;
|
|
}
|
|
|
|
reviewsList.innerHTML = reviews.map(pr => `
|
|
<div class="pr-card ${pr.draft ? 'draft' : ''} ${!pr.mergeable ? 'conflict' : ''}">
|
|
<div class="pr-header">
|
|
<span class="pr-repo">${pr.repo}</span>
|
|
<span class="pr-number">#${pr.pr_number}</span>
|
|
</div>
|
|
<h3 class="pr-title">
|
|
<a href="${pr.pr_url}" target="_blank">${pr.pr_title}</a>
|
|
</h3>
|
|
<div class="pr-meta">
|
|
<span class="pr-author">by ${pr.author}</span>
|
|
<span class="pr-date">${new Date(pr.created_at).toLocaleDateString()}</span>
|
|
</div>
|
|
<div class="pr-labels">
|
|
${pr.labels.map(label =>
|
|
`<span class="label" style="background-color: #${label.color}">${label.name}</span>`
|
|
).join('')}
|
|
</div>
|
|
${pr.draft ? '<span class="badge draft-badge">Draft</span>' : ''}
|
|
${!pr.mergeable ? '<span class="badge conflict-badge">Merge Conflict</span>' : ''}
|
|
</div>
|
|
`).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading Gitea reviews:', error);
|
|
document.getElementById('reviews-list').innerHTML =
|
|
`<p class="error">Failed to load reviews: ${error.message}</p>`;
|
|
}
|
|
}
|
|
|
|
async function loadGiteaActivity() {
|
|
try {
|
|
const response = await fetch('/api/gitea/activity');
|
|
const activities = await response.json();
|
|
giteaData.activity = activities;
|
|
|
|
const activityFeed = document.getElementById('activity-feed');
|
|
if (activities.length === 0) {
|
|
activityFeed.innerHTML = '<p class="empty">No recent activity</p>';
|
|
return;
|
|
}
|
|
|
|
activityFeed.innerHTML = activities.map(act => `
|
|
<div class="activity-item">
|
|
<div class="activity-icon">${getActivityIcon(act.op_type)}</div>
|
|
<div class="activity-content">
|
|
<div class="activity-header">
|
|
<a href="${act.repo_url}" class="activity-repo">${act.repo}</a>
|
|
<span class="activity-time">${timeAgo(act.created_at)}</span>
|
|
</div>
|
|
<div class="activity-desc">
|
|
${act.content || act.op_type}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
} catch (error) {
|
|
console.error('Error loading Gitea activity:', error);
|
|
document.getElementById('activity-feed').innerHTML =
|
|
`<p class="error">Failed to load activity: ${error.message}</p>`;
|
|
}
|
|
}
|
|
|
|
function getActivityIcon(type) {
|
|
const icons = {
|
|
'create': '✨',
|
|
'push': '📤',
|
|
'merge': '🔀',
|
|
'close': '✅',
|
|
'reopen': '🔄',
|
|
'comment': '💬',
|
|
'label': '🏷️',
|
|
'milestone': '🎯',
|
|
'assign': '👤',
|
|
'review': '👀',
|
|
'release': '🚀'
|
|
};
|
|
return icons[type] || '📝';
|
|
}
|
|
|
|
function timeAgo(dateString) {
|
|
const now = new Date();
|
|
const date = new Date(dateString);
|
|
const seconds = Math.floor((now - date) / 1000);
|
|
|
|
if (seconds < 60) return 'just now';
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
|
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
|
|
return date.toLocaleDateString();
|
|
}
|
|
|
|
// Auto-load Gitea data on page load
|
|
if (document.querySelector('.gitea-dashboard')) {
|
|
loadGiteaSwarm();
|
|
|
|
// Refresh every 30 seconds
|
|
setInterval(() => {
|
|
const activeTab = document.querySelector('.gitea-tabs .tab-btn.active');
|
|
if (activeTab) {
|
|
const tabName = activeTab.dataset.tab;
|
|
if (tabName === 'swarm') loadGiteaSwarm();
|
|
else if (tabName === 'reviews') loadGiteaReviews();
|
|
else if (tabName === 'activity') loadGiteaActivity();
|
|
}
|
|
}, 30000);
|
|
}
|