883 lines
29 KiB
JavaScript
883 lines
29 KiB
JavaScript
import { marked } from 'marked';
|
||
import DOMPurify from 'dompurify';
|
||
import './style.css';
|
||
|
||
const API_URL = '/api';
|
||
// These should ideally be environment-specific
|
||
const AUTH_CONFIG = {
|
||
issuer: 'https://idm.flegr.me/application/o/bot/',
|
||
clientId: 'CicDk8mpSBY1SW4ofCamO3B583ttmvKTSPDQwvpb',
|
||
redirectUri: window.location.origin + '/callback',
|
||
authorizeEndpoint: 'https://idm.flegr.me/application/o/authorize/',
|
||
};
|
||
|
||
const state = {
|
||
tasks: [],
|
||
selectedTaskId: null,
|
||
selectedRunId: null,
|
||
currentView: 'dashboard', // 'dashboard' or 'task'
|
||
isEditing: false,
|
||
isAuthenticated: false,
|
||
chatMessages: [],
|
||
activeDashboardTab: 'chat' // 'chat' or 'activity'
|
||
};
|
||
// DOM elements
|
||
const loginOverlay = document.getElementById('login-overlay');
|
||
const callbackOverlay = document.getElementById('callback-overlay');
|
||
const loginBtn = document.getElementById('login-btn');
|
||
const appEl = document.getElementById('app');
|
||
const logoutBtn = document.getElementById('logout-btn');
|
||
const taskListEl = document.getElementById('task-list');
|
||
const newTaskBtn = document.getElementById('new-task-btn');
|
||
const modalContainer = document.getElementById('modal-container');
|
||
const newTaskForm = document.getElementById('new-task-form');
|
||
const cancelTaskBtn = document.getElementById('cancel-task');
|
||
const emptyStateEl = document.getElementById('empty-state');
|
||
const taskViewEl = document.getElementById('task-view');
|
||
const logsOutputEl = document.getElementById('logs-output');
|
||
const viewGoalEl = document.getElementById('view-goal');
|
||
const viewStatusEl = document.getElementById('view-status');
|
||
const viewDateEl = document.getElementById('view-date');
|
||
const answerContainerEl = document.getElementById('answer-container');
|
||
const answerOutputEl = document.getElementById('answer-output');
|
||
const rerunBtn = document.getElementById('rerun-btn');
|
||
const runListEl = document.getElementById('run-list');
|
||
const toggleLogsBtn = document.getElementById('toggle-logs-btn');
|
||
const logsContainerEl = document.getElementById('logs-container');
|
||
const dashboardViewEl = document.getElementById('dashboard-view');
|
||
const recentRunsListEl = document.getElementById('recent-runs-list');
|
||
const logoLink = document.getElementById('logo-link');
|
||
const editTaskBtn = document.getElementById('edit-task-btn');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
const submitTaskBtn = document.getElementById('submit-task-btn');
|
||
const goalTextarea = document.getElementById('goal-textarea');
|
||
const cronInput = document.getElementById('cron-input');
|
||
const schedulePresets = document.getElementById('schedule-presets');
|
||
const toggleCustomCronBtn = document.getElementById('toggle-custom-cron');
|
||
const customCronContainer = document.getElementById('custom-cron-container');
|
||
const presetBtns = document.querySelectorAll('.btn-preset');
|
||
const chatMessagesEl = document.getElementById('chat-messages');
|
||
const chatForm = document.getElementById('chat-form');
|
||
const chatInput = document.getElementById('chat-input');
|
||
const clearChatBtn = document.getElementById('clear-chat-btn');
|
||
const chatSendBtn = document.getElementById('chat-send-btn');
|
||
const sidebarEl = document.querySelector('.sidebar');
|
||
const menuToggle = document.getElementById('menu-toggle');
|
||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||
|
||
function updateState(newState) {
|
||
Object.assign(state, newState);
|
||
renderApp();
|
||
}
|
||
|
||
function showToast(message, type = 'info') {
|
||
const container = document.getElementById('toast-container');
|
||
const toast = document.createElement('div');
|
||
toast.className = `toast ${type}`;
|
||
|
||
const icons = {
|
||
success: '✓',
|
||
error: '✕',
|
||
info: 'ℹ'
|
||
};
|
||
|
||
toast.innerHTML = `
|
||
<span class="toast-icon">${icons[type] || 'ℹ'}</span>
|
||
<span class="toast-message">${message}</span>
|
||
`;
|
||
|
||
container.appendChild(toast);
|
||
|
||
// Auto remove
|
||
setTimeout(() => {
|
||
toast.style.animation = 'fadeOut 0.3s forwards';
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 4000);
|
||
}
|
||
|
||
function renderApp() {
|
||
renderTaskList();
|
||
|
||
if (state.currentView === 'dashboard') {
|
||
fetchRecentRuns();
|
||
renderChat();
|
||
} else if (state.selectedTaskId) {
|
||
const task = state.tasks.find(t => t.id === state.selectedTaskId);
|
||
if (task) {
|
||
renderRunHistory(task);
|
||
showTaskView(task);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Wrapper for fetch to include Authorization header
|
||
async function fetchWithAuth(url, options = {}) {
|
||
let response = await fetch(url, { ...options, credentials: 'include' });
|
||
|
||
if (response.status === 401) {
|
||
// Try to refresh token
|
||
try {
|
||
const success = await attemptTokenRefresh();
|
||
if (success) {
|
||
// Retry original request
|
||
response = await fetch(url, { ...options, credentials: 'include' });
|
||
}
|
||
} catch (error) {
|
||
console.error('Token refresh failed:', error);
|
||
}
|
||
}
|
||
|
||
if (response.status === 401) {
|
||
logout();
|
||
throw new Error('Session expired');
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
async function attemptTokenRefresh() {
|
||
try {
|
||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ refresh_token: '' })
|
||
});
|
||
|
||
if (response.ok) {
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
console.error('Error during token refresh:', error);
|
||
}
|
||
return false;
|
||
}
|
||
// I'll replace the fetchTasks function and add updateState
|
||
async function fetchTasks() {
|
||
try {
|
||
const response = await fetchWithAuth(`${API_URL}/tasks`);
|
||
const newTasks = await response.json();
|
||
|
||
// Check if we should follow the latest run
|
||
let newSelectedRunId = state.selectedRunId;
|
||
if (state.selectedTaskId) {
|
||
const currentTask = newTasks.find(t => t.id === state.selectedTaskId);
|
||
if (currentTask && currentTask.runs && currentTask.runs.length > 0) {
|
||
// If we don't have a selected run or the runs changed, we might want to update
|
||
if (!state.selectedRunId || (state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.length !== currentTask.runs.length)) {
|
||
// Only auto-switch if we are "following" the latest
|
||
const wasFollowingLatest = state.tasks.find(t => t.id === state.selectedTaskId)?.runs?.[0]?.id === state.selectedRunId;
|
||
if (wasFollowingLatest || !state.selectedRunId) {
|
||
newSelectedRunId = currentTask.runs[0].id;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
updateState({
|
||
tasks: newTasks,
|
||
selectedRunId: newSelectedRunId
|
||
});
|
||
} catch (error) {
|
||
console.error('Error fetching tasks:', error);
|
||
}
|
||
}
|
||
|
||
async function fetchRecentRuns() {
|
||
try {
|
||
const response = await fetchWithAuth(`${API_URL}/runs/recent`);
|
||
const recentRuns = await response.json();
|
||
renderDashboard(recentRuns);
|
||
} catch (error) {
|
||
console.error('Error fetching recent runs:', error);
|
||
}
|
||
}
|
||
|
||
function renderTaskList() {
|
||
const sortedTasks = [...state.tasks].sort((a, b) => {
|
||
return new Date(b.created_at) - new Date(a.created_at);
|
||
});
|
||
|
||
taskListEl.innerHTML = sortedTasks
|
||
.map((task) => {
|
||
const latestRun = task.runs && task.runs.length > 0
|
||
? task.runs[0]
|
||
: null;
|
||
const status = latestRun ? latestRun.status : 'pending';
|
||
const date = latestRun ? new Date(latestRun.created_at) : new Date(task.created_at);
|
||
|
||
return `
|
||
<li class="task-item ${state.selectedTaskId === task.id ? 'active' : ''}" data-id="${task.id}">
|
||
<div class="task-item-title">${task.goal}</div>
|
||
<div class="task-item-meta">
|
||
<span class="status-dot ${status}"></span>
|
||
<span>${date.toLocaleDateString()} ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||
</div>
|
||
</li>
|
||
`;
|
||
})
|
||
.join('');
|
||
|
||
// Add event listeners
|
||
document.querySelectorAll('.task-item').forEach((item) => {
|
||
item.addEventListener('click', () => {
|
||
selectTask(item.dataset.id);
|
||
});
|
||
});
|
||
}
|
||
|
||
function selectTask(id, runId = null) {
|
||
state.selectedTaskId = id;
|
||
state.currentView = 'task';
|
||
const task = state.tasks.find((t) => t.id === id);
|
||
if (!task) return;
|
||
|
||
if (runId) {
|
||
state.selectedRunId = runId;
|
||
} else if (task.runs && task.runs.length > 0) {
|
||
// Default to latest run if not specified (index 0 is newest)
|
||
state.selectedRunId = task.runs[0].id;
|
||
} else {
|
||
state.selectedRunId = null;
|
||
}
|
||
|
||
renderTaskList();
|
||
renderRunHistory(task);
|
||
showTaskView(task);
|
||
|
||
// Fetch and update subscription status
|
||
fetch(`${API_URL}/tasks/${id}/subscription`)
|
||
.then(res => res.json())
|
||
.then(data => {
|
||
const btn = document.getElementById('notify-task-btn');
|
||
if (data.isSubscribed) {
|
||
btn.classList.add('notified');
|
||
} else {
|
||
btn.classList.remove('notified');
|
||
}
|
||
})
|
||
.catch(err => console.error('Failed to fetch subscription status', err));
|
||
|
||
// Close sidebar on mobile after selection
|
||
if (window.innerWidth <= 768) {
|
||
closeMobileMenu();
|
||
}
|
||
}
|
||
|
||
function renderRunHistory(task) {
|
||
runListEl.innerHTML = task.runs
|
||
.map(
|
||
(run, index) => `
|
||
<li class="run-item ${state.selectedRunId === run.id ? 'active' : ''}" data-id="${run.id}">
|
||
<span>Run #${task.runs.length - index}</span>
|
||
<span class="run-item-date">${new Date(run.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
|
||
</li>
|
||
`
|
||
)
|
||
.join('');
|
||
|
||
document.querySelectorAll('.run-item').forEach((item) => {
|
||
item.addEventListener('click', () => {
|
||
selectTask(task.id, item.dataset.id);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showDashboard() {
|
||
state.currentView = 'dashboard';
|
||
state.selectedTaskId = null;
|
||
state.selectedRunId = null;
|
||
|
||
emptyStateEl.classList.add('hidden');
|
||
taskViewEl.classList.add('hidden');
|
||
dashboardViewEl.classList.remove('hidden');
|
||
|
||
renderTaskList();
|
||
fetchRecentRuns();
|
||
renderDashboardTabs();
|
||
}
|
||
|
||
function renderDashboardTabs() {
|
||
const tabs = document.querySelectorAll('.tab-btn');
|
||
const panels = document.querySelectorAll('.tab-panel');
|
||
|
||
tabs.forEach(tab => {
|
||
if (tab.dataset.tab === state.activeDashboardTab) {
|
||
tab.classList.add('active');
|
||
} else {
|
||
tab.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
panels.forEach(panel => {
|
||
if (panel.id === `${state.activeDashboardTab}-tab-panel`) {
|
||
panel.classList.add('active');
|
||
} else {
|
||
panel.classList.remove('active');
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderDashboard(recentRuns) {
|
||
recentRunsListEl.innerHTML = recentRuns
|
||
.map(
|
||
(run) => `
|
||
<tr data-task-id="${run.task_id}" data-run-id="${run.id}">
|
||
<td>${escapeHtml(run.goal)}</td>
|
||
<td><span class="status-badge ${run.status}">${run.status}</span></td>
|
||
<td>${new Date(run.created_at).toLocaleString()}</td>
|
||
</tr>
|
||
`
|
||
)
|
||
.join('');
|
||
|
||
document.querySelectorAll('#recent-runs-list tr').forEach((row) => {
|
||
row.addEventListener('click', () => {
|
||
selectTask(row.dataset.taskId, row.dataset.runId);
|
||
});
|
||
});
|
||
}
|
||
|
||
function showTaskView(task) {
|
||
state.currentView = 'task';
|
||
emptyStateEl.classList.add('hidden');
|
||
dashboardViewEl.classList.add('hidden');
|
||
taskViewEl.classList.remove('hidden');
|
||
|
||
const run = task.runs.find(r => r.id === state.selectedRunId) || task.runs[0];
|
||
|
||
viewGoalEl.textContent = task.goal;
|
||
|
||
// Show schedule info if exists
|
||
const scheduleInfo = task.cron ? `<div class="schedule-badge">🕒 Scheduled: ${task.cron}</div>` : '';
|
||
const headerMain = document.querySelector('.header-main');
|
||
const existingBadge = headerMain.querySelector('.schedule-badge');
|
||
if (existingBadge) existingBadge.remove();
|
||
if (scheduleInfo) {
|
||
headerMain.insertAdjacentHTML('beforeend', scheduleInfo);
|
||
}
|
||
|
||
if (!run) {
|
||
viewStatusEl.textContent = 'No runs';
|
||
viewStatusEl.className = 'status-badge pending';
|
||
viewDateEl.textContent = '-';
|
||
logsOutputEl.innerHTML = '<div class="waiting-placeholder">No logs available. Click "Run Task" to start the agent.</div>';
|
||
answerContainerEl.classList.remove('hidden');
|
||
answerOutputEl.innerHTML = '<div class="waiting-placeholder">Waiting for the first execution...</div>';
|
||
return;
|
||
}
|
||
|
||
viewStatusEl.textContent = run.status;
|
||
viewStatusEl.className = `status-badge ${run.status}`;
|
||
viewDateEl.textContent = new Date(run.created_at).toLocaleString(undefined, {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
answerContainerEl.classList.remove('hidden');
|
||
if (run.answer) {
|
||
const rawHtml = marked.parse(run.answer);
|
||
answerOutputEl.innerHTML = DOMPurify.sanitize(rawHtml);
|
||
} else {
|
||
answerOutputEl.innerHTML = '<div class="waiting-placeholder">Agent is working on the final answer...</div>';
|
||
}
|
||
|
||
// Check if we should auto-scroll
|
||
const isAtBottom = logsOutputEl.scrollHeight - logsOutputEl.scrollTop <= logsOutputEl.clientHeight + 10;
|
||
const isFirstLoad = logsOutputEl.innerHTML === '';
|
||
|
||
// Simple log format
|
||
logsOutputEl.innerHTML = run.logs
|
||
.split('\n')
|
||
.map((line) => `<div class="log-entry">${escapeHtml(line)}</div>`)
|
||
.join('');
|
||
|
||
if (isAtBottom || isFirstLoad) {
|
||
logsOutputEl.scrollTop = logsOutputEl.scrollHeight;
|
||
}
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function renderChat() {
|
||
if (!chatMessagesEl) return;
|
||
|
||
if (state.chatMessages.length === 0) {
|
||
chatMessagesEl.innerHTML = `
|
||
<div class="chat-empty-state">
|
||
<p>Start a conversation with the assistant.</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
chatMessagesEl.innerHTML = state.chatMessages
|
||
.map(msg => `
|
||
<div class="chat-message ${msg.role}">
|
||
${DOMPurify.sanitize(marked.parse(msg.content || ''))}
|
||
</div>
|
||
`)
|
||
.join('');
|
||
|
||
chatMessagesEl.scrollTop = chatMessagesEl.scrollHeight;
|
||
}
|
||
|
||
async function sendChatMessage(text) {
|
||
const userMessage = { role: 'user', content: text };
|
||
state.chatMessages.push(userMessage);
|
||
renderChat();
|
||
|
||
chatInput.value = '';
|
||
chatInput.disabled = true;
|
||
chatSendBtn.disabled = true;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`${API_URL}/chat`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ messages: state.chatMessages })
|
||
});
|
||
|
||
if (!response.ok) throw new Error('Chat API failed');
|
||
|
||
const result = await response.json();
|
||
state.chatMessages.push(result.message);
|
||
renderChat();
|
||
} catch (error) {
|
||
console.error('Chat error:', error);
|
||
showToast('Failed to get chat response.', 'error');
|
||
state.chatMessages.push({ role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' });
|
||
renderChat();
|
||
} finally {
|
||
chatInput.disabled = false;
|
||
chatSendBtn.disabled = false;
|
||
chatInput.focus();
|
||
}
|
||
}
|
||
|
||
// Event Listeners
|
||
rerunBtn.addEventListener('click', async () => {
|
||
if (!state.selectedTaskId) return;
|
||
|
||
try {
|
||
const response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}/runs`, {
|
||
method: 'POST',
|
||
});
|
||
const updatedTask = await response.json();
|
||
const index = state.tasks.findIndex(t => t.id === updatedTask.id);
|
||
if (index !== -1) {
|
||
state.tasks[index] = updatedTask;
|
||
}
|
||
selectTask(updatedTask.id);
|
||
showToast('Task rerun successfully!', 'success');
|
||
} catch (error) {
|
||
console.error('Failed to rerun task:', error);
|
||
showToast('Failed to rerun task.', 'error');
|
||
}
|
||
});
|
||
|
||
newTaskBtn.addEventListener('click', () => {
|
||
state.isEditing = false;
|
||
modalTitle.textContent = 'New Agent Task';
|
||
submitTaskBtn.textContent = 'Save Task';
|
||
goalTextarea.value = '';
|
||
cronInput.value = '';
|
||
updateScheduleUI('');
|
||
modalContainer.classList.remove('hidden');
|
||
});
|
||
|
||
toggleLogsBtn.addEventListener('click', () => {
|
||
logsContainerEl.classList.toggle('hidden');
|
||
const isHidden = logsContainerEl.classList.contains('hidden');
|
||
toggleLogsBtn.classList.toggle('active', !isHidden);
|
||
toggleLogsBtn.innerHTML = isHidden ? '<span>⌨</span> Inspect Logs' : '<span>✕</span> Hide Logs';
|
||
});
|
||
|
||
editTaskBtn.addEventListener('click', () => {
|
||
const task = state.tasks.find((t) => t.id === state.selectedTaskId);
|
||
if (!task) return;
|
||
|
||
state.isEditing = true;
|
||
modalTitle.textContent = 'Edit Agent Task';
|
||
submitTaskBtn.textContent = 'Save Task';
|
||
goalTextarea.value = task.goal;
|
||
cronInput.value = task.cron || '';
|
||
updateScheduleUI(task.cron || '');
|
||
modalContainer.classList.remove('hidden');
|
||
});
|
||
|
||
cancelTaskBtn.addEventListener('click', () => {
|
||
modalContainer.classList.add('hidden');
|
||
state.isEditing = false;
|
||
});
|
||
|
||
logoLink.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
showDashboard();
|
||
});
|
||
|
||
function updateScheduleUI(cron) {
|
||
let matched = false;
|
||
presetBtns.forEach(btn => {
|
||
if (btn.dataset.cron === (cron || '')) {
|
||
btn.classList.add('active');
|
||
matched = true;
|
||
} else {
|
||
btn.classList.remove('active');
|
||
}
|
||
});
|
||
|
||
if (matched) {
|
||
customCronContainer.classList.add('hidden');
|
||
} else if (cron) {
|
||
customCronContainer.classList.remove('hidden');
|
||
} else {
|
||
customCronContainer.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
presetBtns.forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const cron = btn.dataset.cron;
|
||
cronInput.value = cron;
|
||
updateScheduleUI(cron);
|
||
});
|
||
});
|
||
|
||
toggleCustomCronBtn.addEventListener('click', () => {
|
||
customCronContainer.classList.toggle('hidden');
|
||
});
|
||
|
||
chatForm.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const text = chatInput.value.trim();
|
||
if (text) {
|
||
sendChatMessage(text);
|
||
}
|
||
});
|
||
|
||
clearChatBtn.addEventListener('click', () => {
|
||
state.chatMessages = [];
|
||
renderChat();
|
||
});
|
||
|
||
cronInput.addEventListener('input', () => {
|
||
// If user types manually, update presets active state
|
||
updateScheduleUI(cronInput.value);
|
||
});
|
||
|
||
newTaskForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const formData = new FormData(newTaskForm);
|
||
const goal = formData.get('goal');
|
||
const cron = formData.get('cron') || null;
|
||
|
||
modalContainer.classList.add('hidden');
|
||
newTaskForm.reset();
|
||
|
||
try {
|
||
let response;
|
||
if (state.isEditing) {
|
||
response = await fetchWithAuth(`${API_URL}/tasks/${state.selectedTaskId}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ goal, cron }),
|
||
});
|
||
} else {
|
||
response = await fetchWithAuth(`${API_URL}/tasks`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ goal, cron }),
|
||
});
|
||
}
|
||
const updatedTask = await response.json();
|
||
|
||
if (state.isEditing) {
|
||
const index = state.tasks.findIndex(t => t.id === updatedTask.id);
|
||
if (index !== -1) {
|
||
state.tasks[index] = updatedTask;
|
||
}
|
||
} else {
|
||
state.tasks.unshift(updatedTask);
|
||
}
|
||
|
||
state.isEditing = false;
|
||
selectTask(updatedTask.id);
|
||
renderTaskList();
|
||
showToast(state.isEditing ? 'Task updated successfully' : 'Task created successfully', 'success');
|
||
} catch (error) {
|
||
console.error('Save task failed:', error);
|
||
showToast('Failed to execute task. Check console.', 'error');
|
||
}
|
||
});
|
||
|
||
async function checkSession() {
|
||
try {
|
||
const response = await fetch(`${API_URL}/auth/session`, { credentials: 'include' });
|
||
if (response.ok) {
|
||
state.isAuthenticated = true;
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
console.error('Session check failed:', error);
|
||
}
|
||
state.isAuthenticated = false;
|
||
return false;
|
||
}
|
||
|
||
let socket = null;
|
||
let reconnectDelay = 1000;
|
||
|
||
function connectWebSocket() {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||
|
||
console.log('Connecting to WebSocket:', wsUrl);
|
||
socket = new WebSocket(wsUrl);
|
||
|
||
socket.onopen = () => {
|
||
console.log('WebSocket connected');
|
||
reconnectDelay = 1000;
|
||
// Initial fetch to sync state
|
||
fetchTasks();
|
||
};
|
||
|
||
socket.onmessage = (event) => {
|
||
try {
|
||
const { type, data } = JSON.parse(event.data);
|
||
console.log('WebSocket event:', type, data);
|
||
|
||
switch (type) {
|
||
case 'TaskCreated':
|
||
state.tasks.unshift(data);
|
||
renderApp();
|
||
showToast('New task created', 'success');
|
||
break;
|
||
case 'TaskUpdated':
|
||
case 'RunFinished':
|
||
const index = state.tasks.findIndex(t => t.id === data.id);
|
||
if (index !== -1) {
|
||
const wasSelected = state.selectedTaskId === data.id;
|
||
state.tasks[index] = data;
|
||
if (wasSelected) {
|
||
// Update selected run if we were following latest
|
||
const wasFollowingLatest = state.selectedRunId === state.tasks[index].runs?.[1]?.id || !state.selectedRunId;
|
||
if (wasFollowingLatest && data.runs && data.runs.length > 0) {
|
||
state.selectedRunId = data.runs[0].id;
|
||
}
|
||
}
|
||
} else {
|
||
state.tasks.unshift(data);
|
||
}
|
||
renderApp();
|
||
if (type === 'RunFinished') {
|
||
showToast(`Task run completed: ${data.goal}`, 'info');
|
||
}
|
||
break;
|
||
case 'RunStarted':
|
||
const taskIndex = state.tasks.findIndex(t => t.id === data.task_id);
|
||
if (taskIndex !== -1) {
|
||
// We don't have the full task update here, but we can update status
|
||
// For simplicity, we just trigger a fetch or wait for RunFinished
|
||
// But let's at least show it's running in the UI if selected
|
||
if (state.tasks[taskIndex].runs) {
|
||
// Prepend a dummy run or just fetch
|
||
fetchTasks();
|
||
}
|
||
}
|
||
showToast(`Task started: ${data.goal}`, 'info');
|
||
break;
|
||
}
|
||
} catch (e) {
|
||
console.error('Error handling WebSocket message:', e);
|
||
}
|
||
};
|
||
|
||
socket.onclose = () => {
|
||
console.log('WebSocket disconnected. Reconnecting...');
|
||
setTimeout(() => {
|
||
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
|
||
connectWebSocket();
|
||
}, reconnectDelay);
|
||
};
|
||
|
||
socket.onerror = (error) => {
|
||
console.error('WebSocket error:', error);
|
||
socket.close();
|
||
};
|
||
}
|
||
|
||
async function showLogin() {
|
||
loginOverlay.classList.remove('hidden');
|
||
appEl.classList.add('hidden');
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await fetch(`${API_URL}/auth/logout`, { method: 'POST', credentials: 'include' });
|
||
} catch (error) {
|
||
console.error('Logout failed:', error);
|
||
}
|
||
state.isAuthenticated = false;
|
||
showLogin();
|
||
}
|
||
|
||
async function handleCallback() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const code = params.get('code');
|
||
if (!code) return;
|
||
|
||
window.history.replaceState({}, document.title, "/");
|
||
callbackOverlay.classList.remove('hidden');
|
||
loginOverlay.classList.add('hidden');
|
||
|
||
try {
|
||
const response = await fetch(`${API_URL}/auth/callback?code=${code}&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}`, {
|
||
credentials: 'include'
|
||
});
|
||
const data = await response.json();
|
||
|
||
if (response.ok) {
|
||
state.isAuthenticated = true;
|
||
callbackOverlay.classList.add('hidden');
|
||
appEl.classList.remove('hidden');
|
||
initializeApp();
|
||
} else {
|
||
throw new Error('No access token in response');
|
||
}
|
||
} catch (error) {
|
||
console.error('Callback failed:', error);
|
||
showToast('Authentication failed.', 'error');
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
loginBtn.addEventListener('click', () => {
|
||
const authUrl = `${AUTH_CONFIG.authorizeEndpoint}?client_id=${AUTH_CONFIG.clientId}&response_type=code&redirect_uri=${encodeURIComponent(AUTH_CONFIG.redirectUri)}&scope=openid profile email offline_access`;
|
||
window.location.href = authUrl;
|
||
});
|
||
|
||
logoutBtn.addEventListener('click', () => {
|
||
logout();
|
||
});
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
state.activeDashboardTab = btn.dataset.tab;
|
||
renderDashboardTabs();
|
||
});
|
||
});
|
||
|
||
function toggleMobileMenu() {
|
||
sidebarEl.classList.toggle('open');
|
||
menuToggle.classList.toggle('active');
|
||
sidebarOverlay.classList.toggle('hidden');
|
||
document.body.style.overflow = sidebarEl.classList.contains('open') ? 'hidden' : '';
|
||
}
|
||
|
||
function closeMobileMenu() {
|
||
sidebarEl.classList.remove('open');
|
||
menuToggle.classList.remove('active');
|
||
sidebarOverlay.classList.add('hidden');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
menuToggle.addEventListener('click', toggleMobileMenu);
|
||
sidebarOverlay.addEventListener('click', closeMobileMenu);
|
||
|
||
async function initializeApp() {
|
||
const hasSession = await checkSession();
|
||
|
||
if (hasSession) {
|
||
appEl.classList.remove('hidden');
|
||
loginOverlay.classList.add('hidden');
|
||
await fetchTasks();
|
||
connectWebSocket();
|
||
} else {
|
||
showLogin();
|
||
}
|
||
}
|
||
|
||
// Check for callback on load
|
||
if (window.location.pathname === '/callback' || window.location.search.includes('code=')) {
|
||
handleCallback();
|
||
} else {
|
||
initializeApp();
|
||
}
|
||
|
||
// Register Service Worker for PWA
|
||
if ('serviceWorker' in navigator) {
|
||
window.addEventListener('load', () => {
|
||
navigator.serviceWorker.register('/sw.js')
|
||
.then(reg => {
|
||
console.log('SW registered', reg);
|
||
setupPush(reg);
|
||
})
|
||
.catch(err => console.error('SW registration failed', err));
|
||
});
|
||
}
|
||
|
||
async function setupPush(registration) {
|
||
try {
|
||
const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`);
|
||
const { publicKey } = await vapidResponse.json();
|
||
|
||
const subscription = await registration.pushManager.subscribe({
|
||
userVisibleOnly: true,
|
||
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
||
});
|
||
|
||
await fetch(`${API_URL}/notifications/register`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
endpoint: subscription.endpoint,
|
||
p256dh: b64(subscription.getKey('p256dh')),
|
||
auth: b64(subscription.getKey('auth'))
|
||
})
|
||
});
|
||
console.log('Push registered');
|
||
} catch (err) {
|
||
console.warn('Push registration failed:', err);
|
||
}
|
||
}
|
||
|
||
function b64(buffer) {
|
||
return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
|
||
}
|
||
|
||
function urlBase64ToUint8Array(base64String) {
|
||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||
const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/');
|
||
const rawData = window.atob(base64);
|
||
const outputArray = new Uint8Array(rawData.length);
|
||
for (let i = 0; i < rawData.length; ++i) {
|
||
outputArray[i] = rawData.charCodeAt(i);
|
||
}
|
||
return outputArray;
|
||
}
|
||
|
||
async function toggleTaskSubscription(taskId) {
|
||
const btn = document.getElementById('notify-task-btn');
|
||
const isNotified = btn.classList.contains('notified');
|
||
const method = isNotified ? 'DELETE' : 'POST';
|
||
|
||
try {
|
||
await fetch(`${API_URL}/tasks/${taskId}/subscribe`, { method });
|
||
btn.classList.toggle('notified');
|
||
showToast(isNotified ? 'Notifications disabled' : 'Notifications enabled');
|
||
} catch (err) {
|
||
showToast('Failed to update notifications');
|
||
}
|
||
}
|
||
|
||
document.getElementById('notify-task-btn').addEventListener('click', () => {
|
||
if (state.selectedTaskId) toggleTaskSubscription(state.selectedTaskId);
|
||
});
|