diff options
Diffstat (limited to 'admin-panel/src/client')
| -rw-r--r-- | admin-panel/src/client/components/agent-log.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/board.ts | 123 | ||||
| -rw-r--r-- | admin-panel/src/client/components/diff-viewer.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/pipeline-controls.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/project-form.ts | 2 | ||||
| -rw-r--r-- | admin-panel/src/client/components/task-card.ts | 40 | ||||
| -rw-r--r-- | admin-panel/src/client/components/task-modal.ts | 271 | ||||
| -rw-r--r-- | admin-panel/src/client/index.html | 22 | ||||
| -rw-r--r-- | admin-panel/src/client/lib/api.ts | 155 | ||||
| -rw-r--r-- | admin-panel/src/client/lib/markdown.ts | 39 | ||||
| -rw-r--r-- | admin-panel/src/client/lib/ws.ts | 79 | ||||
| -rw-r--r-- | admin-panel/src/client/main.ts | 63 | ||||
| -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 | ||||
| -rw-r--r-- | admin-panel/src/client/styles/main.css | 724 |
16 files changed, 1900 insertions, 0 deletions
diff --git a/admin-panel/src/client/components/agent-log.ts b/admin-panel/src/client/components/agent-log.ts new file mode 100644 index 0000000..5938426 --- /dev/null +++ b/admin-panel/src/client/components/agent-log.ts @@ -0,0 +1,2 @@ +// Agent log is inlined in task-modal.ts — this file exists for Phase 3 expansion +export {}; diff --git a/admin-panel/src/client/components/board.ts b/admin-panel/src/client/components/board.ts new file mode 100644 index 0000000..b611133 --- /dev/null +++ b/admin-panel/src/client/components/board.ts @@ -0,0 +1,123 @@ +import { api, type Task } from '../lib/api.js'; +import { renderTaskCard } from './task-card.js'; +import { showTaskModal } from './task-modal.js'; + +const COLUMNS = [ + { id: 'todo', label: 'Req' }, + { id: 'plan', label: 'Plan' }, + { id: 'plan_review', label: 'Plan Review' }, + { id: 'impl', label: 'Impl' }, + { id: 'impl_review', label: 'Impl Review' }, + { id: 'test', label: 'Test' }, + { id: 'done', label: 'Done' }, +]; + +export function renderBoard(container: HTMLElement, projectName: string, tasks: Task[]): void { + const tasksByStatus = new Map<string, Task[]>(); + for (const col of COLUMNS) { + tasksByStatus.set(col.id, []); + } + for (const task of tasks) { + const list = tasksByStatus.get(task.status); + if (list) list.push(task); + else tasksByStatus.get('todo')!.push(task); // fallback + } + + container.innerHTML = `<div class="board-container">${COLUMNS.map(col => { + const colTasks = tasksByStatus.get(col.id)!; + return ` + <div class="board-column" data-status="${col.id}"> + <div class="column-header"> + <h3>${col.label}</h3> + <span class="column-count">${colTasks.length}</span> + </div> + <div class="column-body" data-status="${col.id}"> + ${colTasks.map(task => renderTaskCard(task)).join('')} + </div> + </div> + `; + }).join('')}</div>`; + + // Setup drag-drop + setupDragDrop(container, projectName); + + // Setup card click + container.querySelectorAll('.task-card').forEach(card => { + card.addEventListener('click', (e) => { + // Don't open modal if dragging + if ((card as HTMLElement).classList.contains('dragging')) return; + const taskId = parseInt((card as HTMLElement).dataset.taskId!); + const task = tasks.find(t => t.id === taskId); + if (task) showTaskModal(projectName, task); + }); + }); +} + +function setupDragDrop(container: HTMLElement, projectName: string): void { + const cards = container.querySelectorAll('.task-card'); + const columnBodies = container.querySelectorAll('.column-body'); + + let draggedCard: HTMLElement | null = null; + + cards.forEach(card => { + const el = card as HTMLElement; + el.draggable = true; + + el.addEventListener('dragstart', (e) => { + draggedCard = el; + el.classList.add('dragging'); + (e as DragEvent).dataTransfer!.effectAllowed = 'move'; + (e as DragEvent).dataTransfer!.setData('text/plain', el.dataset.taskId!); + }); + + el.addEventListener('dragend', () => { + el.classList.remove('dragging'); + draggedCard = null; + columnBodies.forEach(cb => cb.classList.remove('drag-over')); + }); + }); + + columnBodies.forEach(body => { + body.addEventListener('dragover', (e) => { + e.preventDefault(); + (e as DragEvent).dataTransfer!.dropEffect = 'move'; + body.classList.add('drag-over'); + }); + + body.addEventListener('dragleave', () => { + body.classList.remove('drag-over'); + }); + + body.addEventListener('drop', async (e) => { + e.preventDefault(); + body.classList.remove('drag-over'); + + if (!draggedCard) return; + + const taskId = parseInt(draggedCard.dataset.taskId!); + const newStatus = (body as HTMLElement).dataset.status!; + + // Calculate rank based on drop position + const existingCards = Array.from(body.querySelectorAll('.task-card:not(.dragging)')); + let rank: number; + + if (existingCards.length === 0) { + rank = 1; + } else { + // Drop at end by default + const lastCard = existingCards[existingCards.length - 1] as HTMLElement; + const lastRank = parseFloat(lastCard.dataset.rank || '0'); + rank = lastRank + 1; + } + + // Optimistically move card + body.appendChild(draggedCard); + + try { + await api.reorderTask(projectName, taskId, rank, newStatus); + } catch (err) { + console.error('Reorder failed:', err); + } + }); + }); +} diff --git a/admin-panel/src/client/components/diff-viewer.ts b/admin-panel/src/client/components/diff-viewer.ts new file mode 100644 index 0000000..b746b75 --- /dev/null +++ b/admin-panel/src/client/components/diff-viewer.ts @@ -0,0 +1,2 @@ +// Diff viewer is inlined in task-modal.ts — this file exists for Phase 4 expansion +export {}; diff --git a/admin-panel/src/client/components/pipeline-controls.ts b/admin-panel/src/client/components/pipeline-controls.ts new file mode 100644 index 0000000..2b3e9a5 --- /dev/null +++ b/admin-panel/src/client/components/pipeline-controls.ts @@ -0,0 +1,2 @@ +// Pipeline controls are inlined in task-modal.ts — this file exists for Phase 3 expansion +export {}; diff --git a/admin-panel/src/client/components/project-form.ts b/admin-panel/src/client/components/project-form.ts new file mode 100644 index 0000000..d69f194 --- /dev/null +++ b/admin-panel/src/client/components/project-form.ts @@ -0,0 +1,2 @@ +// Project form is inlined in dashboard.ts — this file exists for future extraction if needed +export {}; diff --git a/admin-panel/src/client/components/task-card.ts b/admin-panel/src/client/components/task-card.ts new file mode 100644 index 0000000..a63e507 --- /dev/null +++ b/admin-panel/src/client/components/task-card.ts @@ -0,0 +1,40 @@ +import type { Task } from '../lib/api.js'; + +export function renderTaskCard(task: Task): string { + const tags = parseTags(task.tags); + const priorityClass = `badge-${task.priority}`; + const isActive = task.current_agent !== null; + + return ` + <div class="task-card ${isActive ? 'pipeline-active' : ''}" + data-task-id="${task.id}" + data-rank="${task.rank}" + draggable="true"> + <div class="task-card-header"> + <span class="task-id">#${task.id}</span> + ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''} + </div> + <div class="task-title">${escapeHtml(task.title)}</div> + <div class="task-meta"> + <span class="badge ${priorityClass}">${task.priority}</span> + <span class="badge badge-level">L${task.level}</span> + ${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')} + </div> + </div> + `; +} + +function parseTags(tagsStr: string): string[] { + try { + const parsed = JSON.parse(tagsStr); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/admin-panel/src/client/components/task-modal.ts b/admin-panel/src/client/components/task-modal.ts new file mode 100644 index 0000000..725d921 --- /dev/null +++ b/admin-panel/src/client/components/task-modal.ts @@ -0,0 +1,271 @@ +import { api, type Task } from '../lib/api.js'; +import { renderMarkdown } from '../lib/markdown.js'; + +const STATUS_ORDER = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done']; +const STATUS_LABELS: Record<string, string> = { + todo: 'Req', plan: 'Plan', plan_review: 'Plan Review', + impl: 'Impl', impl_review: 'Impl Review', test: 'Test', done: 'Done' +}; + +const TABS = ['Requirements', 'Plan', 'Implementation', 'Reviews', 'Tests', 'Agent Log']; + +export function showTaskModal(projectName: string, task: Task): void { + document.querySelector('.modal-overlay')?.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.innerHTML = ` + <div class="modal task-modal"> + <div class="modal-header"> + <div class="top-row"> + <span class="task-id" style="font-size:14px">#${task.id}</span> + <button class="btn-icon close-btn" style="font-size:20px">×</button> + </div> + <h2 style="width:100%">${escapeHtml(task.title)}</h2> + <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap"> + <span class="badge badge-${task.priority}">${task.priority}</span> + <span class="badge badge-level">L${task.level}</span> + <span class="badge" style="background:var(--border);color:var(--text-secondary)"> + ${STATUS_LABELS[task.status] || task.status} + </span> + ${task.current_agent ? `<span class="badge badge-agent">${escapeHtml(task.current_agent)}</span>` : ''} + ${parseTags(task.tags).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')} + </div> + </div> + + ${renderLifecycleBar(task)} + + <div class="tabs" id="task-tabs"> + ${TABS.map((tab, i) => ` + <button class="tab ${i === 0 ? 'active' : ''}" data-tab="${i}">${tab}</button> + `).join('')} + </div> + + <div class="tab-content" id="tab-content"> + ${renderTabContent(0, task)} + </div> + + <div class="modal-footer"> + <div class="pipeline-controls"> + <button class="btn btn-primary btn-sm" id="run-pipeline-btn" title="Run full pipeline">Run Pipeline</button> + <button class="btn btn-secondary btn-sm" id="step-pipeline-btn" title="Run next step">Step</button> + <button class="btn btn-danger btn-sm" id="stop-pipeline-btn" title="Stop pipeline">Stop</button> + </div> + <div style="flex:1"></div> + <button class="btn btn-danger btn-sm" id="delete-task-btn">Delete</button> + </div> + </div> + `; + + document.body.appendChild(overlay); + + // Tab switching + overlay.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + overlay.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + const tabIdx = parseInt((tab as HTMLElement).dataset.tab!); + overlay.querySelector('#tab-content')!.innerHTML = renderTabContent(tabIdx, task); + }); + }); + + // Close + overlay.querySelectorAll('.close-btn').forEach(btn => { + btn.addEventListener('click', () => overlay.remove()); + }); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + // Pipeline controls + overlay.querySelector('#run-pipeline-btn')?.addEventListener('click', async () => { + try { + await api.runPipeline(projectName, task.id); + } catch (err) { + alert((err as Error).message); + } + }); + + overlay.querySelector('#step-pipeline-btn')?.addEventListener('click', async () => { + try { + await api.stepPipeline(projectName, task.id); + } catch (err) { + alert((err as Error).message); + } + }); + + overlay.querySelector('#stop-pipeline-btn')?.addEventListener('click', async () => { + try { + await api.stopPipeline(projectName, task.id); + } catch (err) { + alert((err as Error).message); + } + }); + + // Delete + overlay.querySelector('#delete-task-btn')?.addEventListener('click', async () => { + if (!confirm(`Delete task #${task.id}?`)) return; + try { + await api.deleteTask(projectName, task.id); + overlay.remove(); + } catch (err) { + alert((err as Error).message); + } + }); +} + +function renderLifecycleBar(task: Task): string { + const currentIdx = STATUS_ORDER.indexOf(task.status); + return ` + <div class="lifecycle-bar"> + ${STATUS_ORDER.map((s, i) => { + let cls = 'lifecycle-step'; + if (i < currentIdx) cls += ' completed'; + else if (i === currentIdx) cls += ' current'; + return `<div class="${cls}" title="${STATUS_LABELS[s]}"></div>`; + }).join('')} + </div> + `; +} + +function renderTabContent(tabIdx: number, task: Task): string { + switch (tabIdx) { + case 0: // Requirements + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${task.description + ? `<div>${renderMarkdown(task.description)}</div>` + : '<p>No description provided.</p>' + } + ${task.done_when ? ` + <h4 style="margin-top:16px;color:var(--text-primary)">Done When</h4> + <div>${renderMarkdown(task.done_when)}</div> + ` : ''} + </div> + `; + + case 1: // Plan + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${task.plan + ? `<div>${renderMarkdown(task.plan)}</div>` + : '<p>No plan generated yet. Run the pipeline to create one.</p>' + } + ${task.decision_log ? ` + <h4 style="margin-top:16px;color:var(--text-primary)">Decision Log</h4> + <div>${renderMarkdown(task.decision_log)}</div> + ` : ''} + </div> + `; + + case 2: // Implementation + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${task.implementation_notes + ? `<div>${renderMarkdown(task.implementation_notes)}</div>` + : '<p>No implementation notes yet.</p>' + } + ${task.branch_name ? `<p style="margin-top:12px">Branch: <code>${escapeHtml(task.branch_name)}</code></p>` : ''} + ${task.diff ? ` + <h4 style="margin-top:16px;color:var(--text-primary)">Diff</h4> + <div class="diff-viewer">${renderDiff(task.diff)}</div> + ` : ''} + </div> + `; + + case 3: // Reviews + return ` + <div style="color:var(--text-secondary);font-size:13px"> + <h4 style="color:var(--text-primary)">Plan Reviews (${task.plan_review_count})</h4> + ${renderJsonArray(task.plan_review_comments, 'No plan reviews yet.')} + + <h4 style="margin-top:16px;color:var(--text-primary)">Code Reviews (${task.impl_review_count})</h4> + ${renderJsonArray(task.review_comments, 'No code reviews yet.')} + + ${task.gerrit_change_number ? ` + <p style="margin-top:16px">Gerrit Change: <a href="/gerrit/${task.gerrit_change_number}" style="color:var(--accent-glow)">#${task.gerrit_change_number}</a></p> + ` : ''} + </div> + `; + + case 4: // Tests + return ` + <div style="color:var(--text-secondary);font-size:13px"> + ${renderJsonArray(task.test_results, 'No test results yet. Run the pipeline to generate tests.')} + </div> + `; + + case 5: // Agent Log + return ` + <div class="agent-log"> + ${renderAgentLog(task.agent_log)} + </div> + `; + + default: + return ''; + } +} + +function renderDiff(diff: string): string { + return diff.split('\n').map(line => { + let cls = 'diff-line'; + if (line.startsWith('+')) cls += ' diff-add'; + else if (line.startsWith('-')) cls += ' diff-del'; + else if (line.startsWith('@@')) cls += ' diff-hunk'; + return `<div class="${cls}">${escapeHtml(line)}</div>`; + }).join(''); +} + +function renderJsonArray(jsonStr: string, emptyMsg: string): string { + try { + const items = JSON.parse(jsonStr); + if (!Array.isArray(items) || items.length === 0) return `<p>${emptyMsg}</p>`; + return items.map((item: unknown) => { + if (typeof item === 'string') return `<div class="log-entry"><div class="log-message">${escapeHtml(item)}</div></div>`; + if (typeof item === 'object' && item !== null) { + const obj = item as Record<string, unknown>; + return `<div class="log-entry"> + ${obj.agent ? `<div class="log-agent">${escapeHtml(String(obj.agent))}</div>` : ''} + <div class="log-message">${escapeHtml(String(obj.message || obj.comment || JSON.stringify(obj)))}</div> + </div>`; + } + return ''; + }).join(''); + } catch { + return `<p>${emptyMsg}</p>`; + } +} + +function renderAgentLog(logStr: string): string { + try { + const entries = JSON.parse(logStr); + if (!Array.isArray(entries) || entries.length === 0) { + return '<div class="empty-state"><p>No agent activity yet.</p></div>'; + } + return entries.map((entry: Record<string, unknown>) => ` + <div class="log-entry"> + <span class="log-time">${entry.timestamp ? new Date(String(entry.timestamp)).toLocaleTimeString() : ''}</span> + <span class="log-agent">${escapeHtml(String(entry.agent || ''))}</span> + <span class="log-message">${escapeHtml(String(entry.message || ''))}</span> + </div> + `).join(''); + } catch { + return '<div class="empty-state"><p>No agent activity yet.</p></div>'; + } +} + +function parseTags(tagsStr: string): string[] { + try { + const parsed = JSON.parse(tagsStr); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function escapeHtml(text: string): string { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/admin-panel/src/client/index.html b/admin-panel/src/client/index.html new file mode 100644 index 0000000..58af369 --- /dev/null +++ b/admin-panel/src/client/index.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Bastion Admin</title> + <link rel="stylesheet" href="./styles/main.css" /> +</head> +<body> + <div id="app"> + <nav id="navbar"> + <a href="/" class="nav-brand">BASTION</a> + <div class="nav-links"> + <a href="/" data-route="/">Dashboard</a> + <a href="/settings" data-route="/settings">Settings</a> + </div> + </nav> + <main id="main-content"></main> + </div> + <script type="module" src="./main.ts"></script> +</body> +</html> diff --git a/admin-panel/src/client/lib/api.ts b/admin-panel/src/client/lib/api.ts new file mode 100644 index 0000000..d82693f --- /dev/null +++ b/admin-panel/src/client/lib/api.ts @@ -0,0 +1,155 @@ +const BASE = '/api'; + +async function request<T>(path: string, options?: RequestInit): Promise<T> { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + + if (res.status === 204) return undefined as T; + + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || `Request failed: ${res.status}`); + } + return data as T; +} + +export const api = { + // Projects + getProjects: () => request<Project[]>('/projects'), + createProject: (data: CreateProject) => + request<Project>('/projects', { method: 'POST', body: JSON.stringify(data) }), + getProject: (name: string) => request<Project>(`/projects/${name}`), + updateProject: (name: string, data: Partial<Project>) => + request<Project>(`/projects/${name}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteProject: (name: string, deleteRepo = false) => + request<void>(`/projects/${name}?delete_repo=${deleteRepo}`, { method: 'DELETE' }), + + // Tasks + getTasks: (project: string) => request<Task[]>(`/projects/${project}/tasks`), + createTask: (project: string, data: CreateTask) => + request<Task>(`/projects/${project}/tasks`, { method: 'POST', body: JSON.stringify(data) }), + getTask: (project: string, id: number) => request<Task>(`/projects/${project}/tasks/${id}`), + updateTask: (project: string, id: number, data: Partial<Task>) => + request<Task>(`/projects/${project}/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }), + deleteTask: (project: string, id: number) => + request<void>(`/projects/${project}/tasks/${id}`, { method: 'DELETE' }), + reorderTask: (project: string, id: number, rank: number, status?: string) => + request<Task>(`/projects/${project}/tasks/${id}/reorder`, { + method: 'PATCH', + body: JSON.stringify({ rank, status }), + }), + + // Pipeline + runPipeline: (project: string, taskId: number) => + request<void>(`/projects/${project}/pipeline/run/${taskId}`, { method: 'POST' }), + stepPipeline: (project: string, taskId: number) => + request<void>(`/projects/${project}/pipeline/step/${taskId}`, { method: 'POST' }), + stopPipeline: (project: string, taskId: number) => + request<void>(`/projects/${project}/pipeline/stop/${taskId}`, { method: 'POST' }), + getPipelineStatus: (project: string, taskId: number) => + request<PipelineStatus>(`/projects/${project}/pipeline/status/${taskId}`), + + // Dashboard + getDashboardStats: () => request<DashboardStats>('/dashboard/stats'), + getDashboardActivity: () => request<ActivityEntry[]>('/dashboard/activity'), + + // Settings + getSettings: () => request<Record<string, string>>('/settings'), + updateSettings: (data: Record<string, string>) => + request<void>('/settings', { method: 'PATCH', body: JSON.stringify(data) }), +}; + +// Types +export interface Project { + id: number; + name: string; + display_name: string; + description: string | null; + repo_name: string; + repo_path: string; + default_branch: string; + created_at: string; + updated_at: string; +} + +export interface CreateProject { + name: string; + display_name: string; + description?: string; +} + +export interface Task { + id: number; + title: string; + status: string; + priority: string; + level: number; + description: string | null; + plan: string | null; + decision_log: string | null; + done_when: string | null; + implementation_notes: string | null; + tags: string; + plan_review_comments: string; + review_comments: string; + test_results: string; + agent_log: string; + current_agent: string | null; + plan_review_count: number; + impl_review_count: number; + branch_name: string | null; + gerrit_change_id: string | null; + gerrit_change_number: number | null; + diff: string | null; + rank: number; + created_at: string; + started_at: string | null; + planned_at: string | null; + reviewed_at: string | null; + tested_at: string | null; + completed_at: string | null; +} + +export interface CreateTask { + title: string; + description?: string; + priority?: string; + level?: number; + tags?: string[]; +} + +export interface PipelineStatus { + running: boolean; + currentAgent: string | null; + jobs: PipelineJob[]; +} + +export interface PipelineJob { + id: number; + task_id: number; + agent: string; + status: string; + model: string; + started_at: string | null; + completed_at: string | null; + error: string | null; + tokens_used: number; +} + +export interface DashboardStats { + totalProjects: number; + totalTasks: number; + tasksByStatus: Record<string, number>; + completedToday: number; +} + +export interface ActivityEntry { + project: string; + taskId: number; + taskTitle: string; + agent: string; + action: string; + timestamp: string; +} diff --git a/admin-panel/src/client/lib/markdown.ts b/admin-panel/src/client/lib/markdown.ts new file mode 100644 index 0000000..c89fa77 --- /dev/null +++ b/admin-panel/src/client/lib/markdown.ts @@ -0,0 +1,39 @@ +// Minimal markdown renderer — handles the basics without pulling in a library +export function renderMarkdown(text: string): string { + if (!text) return ''; + + let html = escapeHtml(text); + + // Code blocks + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="lang-$1">$2</code></pre>'); + + // Inline code + html = html.replace(/`([^`]+)`/g, '<code>$1</code>'); + + // Headers + html = html.replace(/^### (.+)$/gm, '<h4>$1</h4>'); + html = html.replace(/^## (.+)$/gm, '<h3>$1</h3>'); + html = html.replace(/^# (.+)$/gm, '<h2>$1</h2>'); + + // Bold and italic + html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>'); + html = html.replace(/\*(.+?)\*/g, '<em>$1</em>'); + + // Lists + html = html.replace(/^- (.+)$/gm, '<li>$1</li>'); + html = html.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>'); + + // Line breaks + html = html.replace(/\n\n/g, '</p><p>'); + html = `<p>${html}</p>`; + html = html.replace(/<p><\/p>/g, ''); + + return html; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} diff --git a/admin-panel/src/client/lib/ws.ts b/admin-panel/src/client/lib/ws.ts new file mode 100644 index 0000000..14e0cec --- /dev/null +++ b/admin-panel/src/client/lib/ws.ts @@ -0,0 +1,79 @@ +type EventHandler = (data: unknown) => void; + +class WsClient { + private ws: WebSocket | null = null; + private handlers = new Map<string, Set<EventHandler>>(); + private reconnectTimer: ReturnType<typeof setTimeout> | null = null; + private subscribedProjects = new Set<string>(); + + connect(): void { + if (this.ws?.readyState === WebSocket.OPEN) return; + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + this.ws = new WebSocket(`${protocol}//${location.host}/ws`); + + this.ws.onopen = () => { + // Re-subscribe to projects + for (const project of this.subscribedProjects) { + this.send({ type: 'subscribe', project }); + } + }; + + this.ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + const handlers = this.handlers.get(data.type); + if (handlers) { + for (const handler of handlers) { + handler(data); + } + } + // Also fire wildcard handlers + const wildcardHandlers = this.handlers.get('*'); + if (wildcardHandlers) { + for (const handler of wildcardHandlers) { + handler(data); + } + } + } catch { + // ignore + } + }; + + this.ws.onclose = () => { + this.reconnectTimer = setTimeout(() => this.connect(), 3000); + }; + } + + disconnect(): void { + if (this.reconnectTimer) clearTimeout(this.reconnectTimer); + this.ws?.close(); + this.ws = null; + } + + subscribe(project: string): void { + this.subscribedProjects.add(project); + this.send({ type: 'subscribe', project }); + } + + unsubscribe(project: string): void { + this.subscribedProjects.delete(project); + this.send({ type: 'unsubscribe', project }); + } + + on(event: string, handler: EventHandler): () => void { + if (!this.handlers.has(event)) { + this.handlers.set(event, new Set()); + } + this.handlers.get(event)!.add(handler); + return () => this.handlers.get(event)?.delete(handler); + } + + private send(data: unknown): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(data)); + } + } +} + +export const wsClient = new WsClient(); diff --git a/admin-panel/src/client/main.ts b/admin-panel/src/client/main.ts new file mode 100644 index 0000000..87f25db --- /dev/null +++ b/admin-panel/src/client/main.ts @@ -0,0 +1,63 @@ +import { renderDashboard } from './pages/dashboard.js'; +import { renderProjectPage } from './pages/project.js'; +import { renderSettings } from './pages/settings.js'; +import { wsClient } from './lib/ws.js'; + +const mainContent = document.getElementById('main-content')!; + +// Simple client-side router +type Route = { + pattern: RegExp; + handler: (container: HTMLElement, params: string[]) => Promise<void>; +}; + +const routes: Route[] = [ + { pattern: /^\/$/, handler: (c) => renderDashboard(c) }, + { pattern: /^\/project\/([a-z0-9-]+)$/, handler: (c, p) => renderProjectPage(c, p[1]) }, + { pattern: /^\/settings$/, handler: (c) => renderSettings(c) }, +]; + +function navigate(path: string): void { + for (const route of routes) { + const match = path.match(route.pattern); + if (match) { + updateNavActive(path); + route.handler(mainContent, Array.from(match)); + return; + } + } + // 404 + mainContent.innerHTML = ` + <div class="empty-state"> + <h3>Page not found</h3> + <p><a href="/" data-route="/" style="color:var(--accent-glow)">Back to Dashboard</a></p> + </div> + `; +} + +function updateNavActive(path: string): void { + document.querySelectorAll('.nav-links a').forEach(a => { + const route = (a as HTMLElement).dataset.route; + a.classList.toggle('active', route === path || (route === '/' && path.startsWith('/project/'))); + }); +} + +// Intercept link clicks for SPA navigation +document.addEventListener('click', (e) => { + const link = (e.target as HTMLElement).closest('[data-route]'); + if (link) { + e.preventDefault(); + const path = (link as HTMLElement).dataset.route!; + history.pushState(null, '', path); + navigate(path); + } +}); + +// Handle browser back/forward +window.addEventListener('popstate', () => { + navigate(location.pathname); +}); + +// Init +wsClient.connect(); +navigate(location.pathname); 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> + `; +} diff --git a/admin-panel/src/client/styles/main.css b/admin-panel/src/client/styles/main.css new file mode 100644 index 0000000..d2fe35b --- /dev/null +++ b/admin-panel/src/client/styles/main.css @@ -0,0 +1,724 @@ +/* Cyberpunk Dark Theme */ +:root { + --bg-deep: #0A0D1F; + --bg-card: #1A1A2E; + --accent: #7B2FBE; + --accent-glow: #A855F7; + --alert: #FF2D78; + --text-secondary: #8B9BB4; + --text-primary: #E8ECF4; + --bg-input: #12152a; + --border: #2a2a4a; + --success: #22c55e; + --warning: #eab308; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + background: var(--bg-deep); + color: var(--text-primary); + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + font-size: 14px; + line-height: 1.5; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* Navbar */ +#navbar { + display: flex; + align-items: center; + padding: 0 24px; + height: 52px; + background: var(--bg-card); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.nav-brand { + font-size: 18px; + font-weight: 700; + letter-spacing: 3px; + color: var(--accent-glow); + text-decoration: none; + margin-right: 32px; +} + +.nav-links { + display: flex; + gap: 4px; +} + +.nav-links a { + color: var(--text-secondary); + text-decoration: none; + padding: 6px 14px; + border-radius: 6px; + font-size: 13px; + transition: all 0.15s; +} + +.nav-links a:hover, +.nav-links a.active { + color: var(--text-primary); + background: rgba(123, 47, 190, 0.15); +} + +.nav-links a.active { + color: var(--accent-glow); +} + +/* Main content */ +#main-content { + flex: 1; + overflow: auto; + padding: 24px; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-deep); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { + background: var(--accent-glow); + box-shadow: 0 0 20px rgba(168, 85, 247, 0.3); +} + +.btn-secondary { + background: var(--border); + color: var(--text-primary); +} + +.btn-secondary:hover { + background: var(--text-secondary); +} + +.btn-danger { + background: transparent; + color: var(--alert); + border: 1px solid var(--alert); +} + +.btn-danger:hover { + background: var(--alert); + color: white; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-icon { + padding: 6px; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.btn-icon:hover { + color: var(--text-primary); + background: var(--border); +} + +/* Form elements */ +input, textarea, select { + background: var(--bg-input); + border: 1px solid var(--border); + color: var(--text-primary); + padding: 8px 12px; + border-radius: 6px; + font-size: 13px; + font-family: inherit; + outline: none; + transition: border-color 0.15s; +} + +input:focus, textarea:focus, select:focus { + border-color: var(--accent); +} + +textarea { + resize: vertical; + min-height: 80px; +} + +label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 4px; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; +} + +/* Modal */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + animation: fadeIn 0.15s; +} + +.modal { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 12px; + width: 90%; + max-width: 540px; + max-height: 90vh; + overflow-y: auto; + padding: 24px; + animation: slideUp 0.2s; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 20px; +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border); +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +/* Dashboard */ +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; +} + +.dashboard-header h1 { + font-size: 24px; + font-weight: 600; +} + +.project-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 16px; +} + +.project-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 20px; + cursor: pointer; + transition: all 0.15s; + text-decoration: none; + color: inherit; + display: block; +} + +.project-card:hover { + border-color: var(--accent); + box-shadow: 0 0 20px rgba(123, 47, 190, 0.15); + transform: translateY(-1px); +} + +.project-card h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} + +.project-card .project-name { + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 8px; + font-family: monospace; +} + +.project-card .project-desc { + color: var(--text-secondary); + font-size: 13px; + margin-bottom: 12px; +} + +.project-card .project-stats { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--text-secondary); +} + +.project-card .stat { + display: flex; + align-items: center; + gap: 4px; +} + +.stat-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.stat-dot.todo { background: var(--text-secondary); } +.stat-dot.active { background: var(--accent-glow); } +.stat-dot.done { background: var(--success); } + +/* Kanban Board */ +.board-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.board-header h1 { + font-size: 20px; + font-weight: 600; +} + +.board-container { + display: flex; + gap: 12px; + overflow-x: auto; + height: calc(100vh - 140px); + padding-bottom: 16px; +} + +.board-column { + flex: 0 0 260px; + display: flex; + flex-direction: column; + background: rgba(26, 26, 46, 0.3); + border-radius: 8px; + overflow: hidden; +} + +.column-header { + padding: 12px 14px; + background: var(--bg-card); + border-bottom: 2px solid var(--accent-glow); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.column-header h3 { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; +} + +.column-count { + font-size: 11px; + color: var(--text-secondary); + background: var(--border); + padding: 1px 8px; + border-radius: 10px; +} + +.column-body { + flex: 1; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.column-body.drag-over { + background: rgba(123, 47, 190, 0.08); +} + +/* Task Cards */ +.task-card { + background: var(--bg-card); + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 6px; + padding: 12px; + cursor: pointer; + transition: all 0.15s; + user-select: none; +} + +.task-card:hover { + border-color: var(--accent); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); +} + +.task-card.dragging { + opacity: 0.5; + transform: rotate(2deg); +} + +.task-card.pipeline-active { + animation: pulseGlow 2s infinite; +} + +@keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 5px rgba(168, 85, 247, 0.2); } + 50% { box-shadow: 0 0 20px rgba(168, 85, 247, 0.5); } +} + +.task-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 6px; +} + +.task-id { + font-size: 11px; + color: var(--text-secondary); + font-family: monospace; +} + +.task-title { + font-size: 13px; + font-weight: 500; + line-height: 1.4; + margin-bottom: 8px; +} + +.task-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.badge { + font-size: 10px; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-high { background: var(--alert); color: white; } +.badge-medium { background: var(--accent); color: white; } +.badge-low { background: var(--border); color: var(--text-secondary); } + +.badge-level { + background: var(--border); + color: var(--text-secondary); +} + +.badge-agent { + background: rgba(168, 85, 247, 0.2); + color: var(--accent-glow); +} + +.tag { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + background: var(--border); + color: var(--text-secondary); +} + +/* Task Detail Modal */ +.task-modal { + max-width: 800px; +} + +.task-modal .modal-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.task-modal .modal-header .top-row { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; +} + +.task-modal .tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--border); + margin-bottom: 16px; +} + +.task-modal .tab { + padding: 8px 16px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s; + background: none; + border-top: none; + border-left: none; + border-right: none; +} + +.task-modal .tab:hover { + color: var(--text-primary); +} + +.task-modal .tab.active { + color: var(--accent-glow); + border-bottom-color: var(--accent-glow); +} + +.tab-content { + min-height: 200px; +} + +.lifecycle-bar { + display: flex; + gap: 4px; + margin-bottom: 16px; +} + +.lifecycle-step { + flex: 1; + height: 4px; + border-radius: 2px; + background: var(--border); +} + +.lifecycle-step.completed { + background: var(--accent-glow); +} + +.lifecycle-step.current { + background: var(--accent-glow); + animation: pulseGlow 2s infinite; +} + +/* Agent Log */ +.agent-log { + display: flex; + flex-direction: column; + gap: 12px; +} + +.log-entry { + display: flex; + gap: 12px; + padding: 12px; + background: var(--bg-deep); + border-radius: 6px; + font-size: 13px; +} + +.log-entry .log-time { + color: var(--text-secondary); + font-family: monospace; + font-size: 11px; + white-space: nowrap; +} + +.log-entry .log-agent { + color: var(--accent-glow); + font-weight: 500; + white-space: nowrap; +} + +.log-entry .log-message { + color: var(--text-secondary); +} + +/* Diff Viewer */ +.diff-viewer { + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 6px; + overflow: auto; + max-height: 500px; +} + +.diff-line { + padding: 1px 12px; + white-space: pre; +} + +.diff-add { + background: rgba(34, 197, 94, 0.1); + color: #4ade80; +} + +.diff-del { + background: rgba(255, 45, 120, 0.1); + color: #ff6b9d; +} + +.diff-hunk { + color: var(--accent-glow); + background: rgba(168, 85, 247, 0.05); +} + +/* Pipeline Controls */ +.pipeline-controls { + display: flex; + gap: 8px; + align-items: center; +} + +/* Settings Page */ +.settings-container { + max-width: 600px; +} + +.settings-section { + margin-bottom: 32px; +} + +.settings-section h2 { + font-size: 16px; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 48px 24px; + color: var(--text-secondary); +} + +.empty-state h3 { + font-size: 16px; + margin-bottom: 8px; + color: var(--text-primary); +} + +/* Toast notifications */ +.toast-container { + position: fixed; + bottom: 24px; + right: 24px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 2000; +} + +.toast { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px 16px; + font-size: 13px; + animation: slideUp 0.2s; + max-width: 360px; +} + +.toast.error { + border-color: var(--alert); +} + +.toast.success { + border-color: var(--success); +} + +/* Responsive */ +@media (max-width: 768px) { + .board-container { + gap: 8px; + } + + .board-column { + flex: 0 0 220px; + } + + .project-grid { + grid-template-columns: 1fr; + } +} |
