summaryrefslogtreecommitdiff
path: root/admin-panel/src/client/components
diff options
context:
space:
mode:
authorArseney300 <Arseney300@gmail.com>2026-03-05 09:47:09 +0700
committerArseney300 <Arseney300@gmail.com>2026-03-05 09:47:09 +0700
commit0885d20ac18c252f6735cd288448d5d93e95b80f (patch)
treeedfb6814e97d4000605d9f6a918c30c6aca9e10c /admin-panel/src/client/components
parent22749b796d7a5e4efa9494156a3fdcfe39028da7 (diff)
WIP: admin_panel: add featurefeature/admin_panel
Diffstat (limited to 'admin-panel/src/client/components')
-rw-r--r--admin-panel/src/client/components/agent-log.ts2
-rw-r--r--admin-panel/src/client/components/board.ts123
-rw-r--r--admin-panel/src/client/components/diff-viewer.ts2
-rw-r--r--admin-panel/src/client/components/pipeline-controls.ts2
-rw-r--r--admin-panel/src/client/components/project-form.ts2
-rw-r--r--admin-panel/src/client/components/task-card.ts40
-rw-r--r--admin-panel/src/client/components/task-modal.ts271
7 files changed, 442 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">&times;</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;
+}