diff options
Diffstat (limited to 'admin-panel/src/client/pages')
| -rw-r--r-- | admin-panel/src/client/pages/dashboard.ts | 150 | ||||
| -rw-r--r-- | admin-panel/src/client/pages/project.ts | 167 | ||||
| -rw-r--r-- | admin-panel/src/client/pages/settings.ts | 59 |
3 files changed, 376 insertions, 0 deletions
diff --git a/admin-panel/src/client/pages/dashboard.ts b/admin-panel/src/client/pages/dashboard.ts new file mode 100644 index 0000000..ded6d3b --- /dev/null +++ b/admin-panel/src/client/pages/dashboard.ts @@ -0,0 +1,150 @@ +import { api, type Project } from '../lib/api.js'; + +export async function renderDashboard(container: HTMLElement): Promise<void> { + container.innerHTML = ` + <div class="dashboard-header"> + <h1>Projects</h1> + <button class="btn btn-primary" id="new-project-btn">+ New Project</button> + </div> + <div class="project-grid" id="project-grid"> + <div class="empty-state"> + <h3>Loading...</h3> + </div> + </div> + `; + + const grid = container.querySelector('#project-grid')!; + const newBtn = container.querySelector('#new-project-btn')!; + + // Load projects + try { + const projects = await api.getProjects(); + renderProjects(grid, projects); + } catch (err) { + grid.innerHTML = `<div class="empty-state"><h3>Failed to load projects</h3><p>${(err as Error).message}</p></div>`; + } + + newBtn.addEventListener('click', () => showCreateDialog(grid)); +} + +function renderProjects(grid: Element, projects: Project[]): void { + if (projects.length === 0) { + grid.innerHTML = ` + <div class="empty-state"> + <h3>No projects yet</h3> + <p>Create your first project to get started.</p> + </div> + `; + return; + } + + grid.innerHTML = projects.map(p => ` + <a href="/project/${p.name}" class="project-card" data-route="/project/${p.name}"> + <h3>${escapeHtml(p.display_name)}</h3> + <div class="project-name">${escapeHtml(p.name)}</div> + ${p.description ? `<div class="project-desc">${escapeHtml(p.description)}</div>` : ''} + <div class="project-stats"> + <span class="stat"><span class="stat-dot todo"></span> ${escapeHtml(p.repo_name)}</span> + <span class="stat">${timeAgo(p.created_at)}</span> + </div> + </a> + `).join(''); +} + +function showCreateDialog(grid: Element): void { + // Remove existing modal if present + document.querySelector('.modal-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + <div class="modal"> + <div class="modal-header"> + <h2>New Project</h2> + <button class="btn-icon close-btn">×</button> + </div> + <form id="create-project-form"> + <div class="form-group"> + <label for="proj-name">Project Name</label> + <input type="text" id="proj-name" placeholder="my-project" required pattern="[a-z0-9-]+" /> + </div> + <div class="form-group"> + <label for="proj-display">Display Name</label> + <input type="text" id="proj-display" placeholder="My Project" required /> + </div> + <div class="form-group"> + <label for="proj-desc">Description</label> + <textarea id="proj-desc" placeholder="What is this project about?"></textarea> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary close-btn">Cancel</button> + <button type="submit" class="btn btn-primary">Create Project</button> + </div> + </form> + </div> + `; + + document.body.appendChild(overlay); + + // Auto-generate name from display name + const displayInput = overlay.querySelector('#proj-display') as HTMLInputElement; + const nameInput = overlay.querySelector('#proj-name') as HTMLInputElement; + displayInput.addEventListener('input', () => { + nameInput.value = displayInput.value + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') + .replace(/\s+/g, '-'); + }); + + // Close handlers + overlay.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', () => overlay.remove()); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + // Submit + const form = overlay.querySelector('#create-project-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; + submitBtn.disabled = true; + submitBtn.textContent = 'Creating...'; + + try { + await api.createProject({ + name: nameInput.value, + display_name: displayInput.value, + description: (overlay.querySelector('#proj-desc') as HTMLTextAreaElement).value || undefined, + }); + overlay.remove(); + // Reload projects + const projects = await api.getProjects(); + renderProjects(grid, projects); + } catch (err) { + submitBtn.disabled = false; + submitBtn.textContent = 'Create Project'; + alert((err as Error).message); + } + }); + + displayInput.focus(); +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function timeAgo(dateStr: string): string { + const date = new Date(dateStr + 'Z'); + const now = new Date(); + const diff = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} diff --git a/admin-panel/src/client/pages/project.ts b/admin-panel/src/client/pages/project.ts new file mode 100644 index 0000000..5e01d64 --- /dev/null +++ b/admin-panel/src/client/pages/project.ts @@ -0,0 +1,167 @@ +import { api, type Task, type Project } from '../lib/api.js'; +import { wsClient } from '../lib/ws.js'; +import { renderBoard } from '../components/board.js'; + +let currentProject: string | null = null; +let cleanupWs: (() => void) | null = null; + +export async function renderProjectPage(container: HTMLElement, projectName: string): Promise<void> { + // Cleanup previous subscriptions + if (currentProject && currentProject !== projectName) { + wsClient.unsubscribe(currentProject); + } + if (cleanupWs) cleanupWs(); + + currentProject = projectName; + + container.innerHTML = ` + <div class="board-header"> + <div style="display:flex;align-items:center;gap:12px"> + <a href="/" data-route="/" class="btn btn-secondary btn-sm">← Back</a> + <h1 id="project-title">Loading...</h1> + </div> + <button class="btn btn-primary" id="new-task-btn">+ New Task</button> + </div> + <div id="board-root"></div> + `; + + const boardRoot = container.querySelector('#board-root') as HTMLElement; + const titleEl = container.querySelector('#project-title')!; + const newTaskBtn = container.querySelector('#new-task-btn')!; + + try { + const [project, tasks] = await Promise.all([ + api.getProject(projectName), + api.getTasks(projectName), + ]); + + titleEl.textContent = project.display_name; + renderBoard(boardRoot, projectName, tasks); + + // WebSocket subscription + wsClient.subscribe(projectName); + const unsub1 = wsClient.on('task:created', (data: unknown) => { + const event = data as { project: string; task: Task }; + if (event.project === projectName) { + refreshBoard(boardRoot, projectName); + } + }); + const unsub2 = wsClient.on('task:updated', (data: unknown) => { + const event = data as { project: string; task: Task }; + if (event.project === projectName) { + refreshBoard(boardRoot, projectName); + } + }); + const unsub3 = wsClient.on('task:deleted', (data: unknown) => { + const event = data as { project: string }; + if (event.project === projectName) { + refreshBoard(boardRoot, projectName); + } + }); + + cleanupWs = () => { unsub1(); unsub2(); unsub3(); }; + + } catch (err) { + boardRoot.innerHTML = `<div class="empty-state"><h3>Failed to load project</h3><p>${(err as Error).message}</p></div>`; + } + + newTaskBtn.addEventListener('click', () => showCreateTaskDialog(projectName, boardRoot)); +} + +async function refreshBoard(boardRoot: HTMLElement, projectName: string): Promise<void> { + try { + const tasks = await api.getTasks(projectName); + renderBoard(boardRoot, projectName, tasks); + } catch { + // silently fail on refresh + } +} + +function showCreateTaskDialog(projectName: string, boardRoot: HTMLElement): void { + document.querySelector('.modal-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + <div class="modal"> + <div class="modal-header"> + <h2>New Task</h2> + <button class="btn-icon close-btn">×</button> + </div> + <form id="create-task-form"> + <div class="form-group"> + <label for="task-title">Title</label> + <input type="text" id="task-title" placeholder="What needs to be done?" required /> + </div> + <div class="form-group"> + <label for="task-desc">Description</label> + <textarea id="task-desc" rows="4" placeholder="Detailed requirements..."></textarea> + </div> + <div style="display:flex;gap:12px"> + <div class="form-group" style="flex:1"> + <label for="task-priority">Priority</label> + <select id="task-priority"> + <option value="low">Low</option> + <option value="medium" selected>Medium</option> + <option value="high">High</option> + </select> + </div> + <div class="form-group" style="flex:1"> + <label for="task-level">Level</label> + <select id="task-level"> + <option value="1">L1 — Quick</option> + <option value="2">L2 — Standard</option> + <option value="3" selected>L3 — Full</option> + </select> + </div> + </div> + <div class="form-group"> + <label for="task-tags">Tags (comma-separated)</label> + <input type="text" id="task-tags" placeholder="frontend, bug-fix" /> + </div> + <div class="modal-footer"> + <button type="button" class="btn btn-secondary close-btn">Cancel</button> + <button type="submit" class="btn btn-primary">Create Task</button> + </div> + </form> + </div> + `; + + document.body.appendChild(overlay); + + overlay.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', () => overlay.remove()); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + const form = overlay.querySelector('#create-task-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = form.querySelector('button[type="submit"]') as HTMLButtonElement; + submitBtn.disabled = true; + submitBtn.textContent = 'Creating...'; + + const tagsStr = (overlay.querySelector('#task-tags') as HTMLInputElement).value; + const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []; + + try { + await api.createTask(projectName, { + title: (overlay.querySelector('#task-title') as HTMLInputElement).value, + description: (overlay.querySelector('#task-desc') as HTMLTextAreaElement).value || undefined, + priority: (overlay.querySelector('#task-priority') as HTMLSelectElement).value, + level: parseInt((overlay.querySelector('#task-level') as HTMLSelectElement).value), + tags, + }); + overlay.remove(); + await refreshBoard(boardRoot, projectName); + } catch (err) { + submitBtn.disabled = false; + submitBtn.textContent = 'Create Task'; + alert((err as Error).message); + } + }); + + (overlay.querySelector('#task-title') as HTMLInputElement).focus(); +} diff --git a/admin-panel/src/client/pages/settings.ts b/admin-panel/src/client/pages/settings.ts new file mode 100644 index 0000000..e78c69f --- /dev/null +++ b/admin-panel/src/client/pages/settings.ts @@ -0,0 +1,59 @@ +export async function renderSettings(container: HTMLElement): Promise<void> { + container.innerHTML = ` + <div class="settings-container"> + <h1 style="margin-bottom:24px">Settings</h1> + + <div class="settings-section"> + <h2>API Configuration</h2> + <div class="form-group"> + <label for="api-key">Anthropic API Key</label> + <input type="password" id="api-key" placeholder="sk-ant-..." style="width:100%" /> + <small style="color:var(--text-secondary);margin-top:4px;display:block"> + Configured via ANTHROPIC_API_KEY environment variable. + The API key is set in the container's environment, not stored in the database. + </small> + </div> + </div> + + <div class="settings-section"> + <h2>Agent Models</h2> + <p style="color:var(--text-secondary);margin-bottom:16px;font-size:13px"> + Configure which Claude model each agent uses. + </p> + <div class="form-group"> + <label>Planner (Plan generation)</label> + <select disabled><option>Claude Opus</option></select> + </div> + <div class="form-group"> + <label>Critic (Plan review)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <div class="form-group"> + <label>Builder (Implementation)</label> + <select disabled><option>Claude Opus</option></select> + </div> + <div class="form-group"> + <label>Shield (Test writing)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <div class="form-group"> + <label>Inspector (Code review)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <div class="form-group"> + <label>Ranger (Test runner)</label> + <select disabled><option>Claude Sonnet</option></select> + </div> + <small style="color:var(--text-secondary)">Model configuration will be available when the AI pipeline is enabled.</small> + </div> + + <div class="settings-section"> + <h2>Git / SSH</h2> + <p style="color:var(--text-secondary);font-size:13px"> + SSH key for git-server and Gerrit access is mounted at <code>/app/.ssh/</code> in the container. + Ensure the public key is added to <code>/var/git_ssh_keys/</code> on the host. + </p> + </div> + </div> + `; +} |
