diff options
| author | Arseney300 <Arseney300@gmail.com> | 2026-03-05 09:47:09 +0700 |
|---|---|---|
| committer | Arseney300 <Arseney300@gmail.com> | 2026-03-05 09:47:09 +0700 |
| commit | 0885d20ac18c252f6735cd288448d5d93e95b80f (patch) | |
| tree | edfb6814e97d4000605d9f6a918c30c6aca9e10c /admin-panel/src/server/routes | |
| parent | 22749b796d7a5e4efa9494156a3fdcfe39028da7 (diff) | |
WIP: admin_panel: add featurefeature/admin_panel
Diffstat (limited to 'admin-panel/src/server/routes')
| -rw-r--r-- | admin-panel/src/server/routes/dashboard.ts | 138 | ||||
| -rw-r--r-- | admin-panel/src/server/routes/gerrit.ts | 68 | ||||
| -rw-r--r-- | admin-panel/src/server/routes/pipeline.ts | 82 | ||||
| -rw-r--r-- | admin-panel/src/server/routes/projects.ts | 138 | ||||
| -rw-r--r-- | admin-panel/src/server/routes/settings.ts | 35 | ||||
| -rw-r--r-- | admin-panel/src/server/routes/tasks.ts | 248 |
6 files changed, 709 insertions, 0 deletions
diff --git a/admin-panel/src/server/routes/dashboard.ts b/admin-panel/src/server/routes/dashboard.ts new file mode 100644 index 0000000..3142e8e --- /dev/null +++ b/admin-panel/src/server/routes/dashboard.ts @@ -0,0 +1,138 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; + +const router = Router(); + +interface Project { + id: number; + name: string; + display_name: string; +} + +// GET /api/dashboard/stats +router.get('/stats', (_req: Request, res: Response) => { + const adminDb = getAdminDb(); + const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[]; + + let totalTasks = 0; + let completedToday = 0; + const tasksByStatus: Record<string, number> = {}; + + const today = new Date().toISOString().split('T')[0]; + + for (const project of projects) { + try { + const db = getProjectDb(project.name); + const tasks = db.prepare('SELECT status, completed_at FROM tasks').all() as Array<{ + status: string; + completed_at: string | null; + }>; + + totalTasks += tasks.length; + + for (const task of tasks) { + tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1; + if (task.completed_at && task.completed_at.startsWith(today)) { + completedToday++; + } + } + } catch { + // Project DB might not exist yet + } + } + + res.json({ + totalProjects: projects.length, + totalTasks, + tasksByStatus, + completedToday, + }); +}); + +// GET /api/dashboard/activity +router.get('/activity', (_req: Request, res: Response) => { + const adminDb = getAdminDb(); + const projects = adminDb.prepare('SELECT * FROM projects').all() as Project[]; + + const activity: Array<{ + project: string; + taskId: number; + taskTitle: string; + agent: string; + action: string; + timestamp: string; + }> = []; + + for (const project of projects) { + try { + const db = getProjectDb(project.name); + const tasks = db.prepare('SELECT id, title, agent_log FROM tasks WHERE agent_log != \'[]\'').all() as Array<{ + id: number; + title: string; + agent_log: string; + }>; + + for (const task of tasks) { + try { + const logs = JSON.parse(task.agent_log) as Array<{ + agent: string; + message: string; + timestamp: string; + }>; + for (const log of logs) { + activity.push({ + project: project.name, + taskId: task.id, + taskTitle: task.title, + agent: log.agent, + action: log.message, + timestamp: log.timestamp, + }); + } + } catch { + // Skip malformed logs + } + } + } catch { + // Project DB might not exist + } + } + + // Sort by timestamp descending, take 20 most recent + activity.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + res.json(activity.slice(0, 20)); +}); + +// GET /api/projects/:project/stats +router.get('/projects/:project', (req: Request, res: Response) => { + const { project } = req.params; + + const adminDb = getAdminDb(); + if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + try { + const db = getProjectDb(project as string); + const tasks = db.prepare('SELECT status FROM tasks').all() as Array<{ status: string }>; + const tasksByStatus: Record<string, number> = {}; + for (const task of tasks) { + tasksByStatus[task.status] = (tasksByStatus[task.status] || 0) + 1; + } + + const totalJobs = db.prepare('SELECT COUNT(*) as count FROM pipeline_jobs').get() as { count: number }; + const totalTokens = db.prepare('SELECT SUM(tokens_used) as total FROM pipeline_jobs').get() as { total: number | null }; + + res.json({ + totalTasks: tasks.length, + tasksByStatus, + totalPipelineJobs: totalJobs.count, + totalTokensUsed: totalTokens.total || 0, + }); + } catch { + res.json({ totalTasks: 0, tasksByStatus: {}, totalPipelineJobs: 0, totalTokensUsed: 0 }); + } +}); + +export default router; diff --git a/admin-panel/src/server/routes/gerrit.ts b/admin-panel/src/server/routes/gerrit.ts new file mode 100644 index 0000000..b48ea53 --- /dev/null +++ b/admin-panel/src/server/routes/gerrit.ts @@ -0,0 +1,68 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; +import { getChange, getChangeByNumber } from '../services/gerrit.service.js'; + +const router = Router({ mergeParams: true }); + +type Params = { project: string; taskId: string }; + +// GET /api/projects/:project/gerrit/:taskId +router.get('/:taskId', async (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + + const adminDb = getAdminDb(); + if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT gerrit_change_id, gerrit_change_number FROM tasks WHERE id = ?').get(parseInt(taskId)) as { + gerrit_change_id: string | null; + gerrit_change_number: number | null; + } | undefined; + + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + if (!task.gerrit_change_id && !task.gerrit_change_number) { + res.json({ change: null }); + return; + } + + try { + const change = task.gerrit_change_id + ? await getChange(task.gerrit_change_id) + : task.gerrit_change_number + ? await getChangeByNumber(task.gerrit_change_number) + : null; + res.json({ change }); + } catch (err) { + res.status(502).json({ error: `Gerrit API error: ${(err as Error).message}` }); + } +}); + +// GET /api/projects/:project/diff/:taskId +router.get('/diff/:taskId', (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + + const adminDb = getAdminDb(); + if (!adminDb.prepare('SELECT id FROM projects WHERE name = ?').get(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT diff FROM tasks WHERE id = ?').get(parseInt(taskId)) as { diff: string | null } | undefined; + + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + res.json({ diff: task.diff || '' }); +}); + +export default router; diff --git a/admin-panel/src/server/routes/pipeline.ts b/admin-panel/src/server/routes/pipeline.ts new file mode 100644 index 0000000..39df35c --- /dev/null +++ b/admin-panel/src/server/routes/pipeline.ts @@ -0,0 +1,82 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb } from '../db.js'; +import { + runFullPipeline, + runStep, + stopPipeline, + getPipelineStatus, + isPipelineRunning, +} from '../services/pipeline.service.js'; + +const router = Router({ mergeParams: true }); + +type Params = { project: string; taskId: string }; + +function projectExists(name: string): boolean { + const db = getAdminDb(); + return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(name); +} + +// POST /api/projects/:project/pipeline/run/:taskId +router.post('/run/:taskId', async (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const id = parseInt(taskId); + if (isPipelineRunning(project, id)) { + res.status(409).json({ error: 'Pipeline already running for this task' }); + return; + } + + // Run async — don't block the response + runFullPipeline(project, id).catch(err => { + console.error(`Pipeline error for ${project}/${taskId}:`, err); + }); + + res.json({ status: 'started' }); +}); + +// POST /api/projects/:project/pipeline/step/:taskId +router.post('/step/:taskId', async (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + try { + await runStep(project, parseInt(taskId)); + res.json({ status: 'completed' }); + } catch (err) { + res.status(400).json({ error: (err as Error).message }); + } +}); + +// POST /api/projects/:project/pipeline/stop/:taskId +router.post('/stop/:taskId', (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + stopPipeline(project, parseInt(taskId)); + res.json({ status: 'stopping' }); +}); + +// GET /api/projects/:project/pipeline/status/:taskId +router.get('/status/:taskId', (req: Request<Params>, res: Response) => { + const { project, taskId } = req.params; + if (!projectExists(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const status = getPipelineStatus(project, parseInt(taskId)); + res.json(status); +}); + +export default router; diff --git a/admin-panel/src/server/routes/projects.ts b/admin-panel/src/server/routes/projects.ts new file mode 100644 index 0000000..b2e63a8 --- /dev/null +++ b/admin-panel/src/server/routes/projects.ts @@ -0,0 +1,138 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; +import { createBareRepo, setRepoDescription, repoExists, deleteRepo } from '../services/git.service.js'; + +const router = Router(); + +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; +} + +// GET /api/projects +router.get('/', (_req: Request, res: Response) => { + const db = getAdminDb(); + const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all(); + res.json(projects); +}); + +// POST /api/projects +router.post('/', (req: Request, res: Response) => { + const { name, display_name, description } = req.body; + + if (!name || !display_name) { + res.status(400).json({ error: 'name and display_name are required' }); + return; + } + + // Sanitize name: lowercase, alphanumeric + hyphens + const safeName = name.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + if (!safeName) { + res.status(400).json({ error: 'Invalid project name' }); + return; + } + + const repoName = `${safeName}.git`; + const db = getAdminDb(); + + // Check for duplicate + const existing = db.prepare('SELECT id FROM projects WHERE name = ?').get(safeName); + if (existing) { + res.status(409).json({ error: 'Project already exists' }); + return; + } + + // Create bare git repo + const repo = createBareRepo(repoName); + if (description) { + setRepoDescription(repoName, description); + } + + // Insert into admin DB + const result = db.prepare( + 'INSERT INTO projects (name, display_name, description, repo_name, repo_path) VALUES (?, ?, ?, ?, ?)' + ).run(safeName, display_name, description || null, repoName, repo.path); + + // Initialize project DB (creates tables) + getProjectDb(safeName); + + const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(result.lastInsertRowid); + res.status(201).json(project); +}); + +// GET /api/projects/:name +router.get('/:name', (req: Request, res: Response) => { + const db = getAdminDb(); + const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined; + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + res.json(project); +}); + +// PATCH /api/projects/:name +router.patch('/:name', (req: Request, res: Response) => { + const db = getAdminDb(); + const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined; + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const { display_name, description } = req.body; + const updates: string[] = []; + const params: unknown[] = []; + + if (display_name !== undefined) { + updates.push('display_name = ?'); + params.push(display_name); + } + if (description !== undefined) { + updates.push('description = ?'); + params.push(description); + if (description) { + setRepoDescription(project.repo_name, description); + } + } + + if (updates.length === 0) { + res.json(project); + return; + } + + updates.push("updated_at = datetime('now')"); + params.push(req.params.name); + + db.prepare(`UPDATE projects SET ${updates.join(', ')} WHERE name = ?`).run(...params); + const updated = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name); + res.json(updated); +}); + +// DELETE /api/projects/:name +router.delete('/:name', (req: Request, res: Response) => { + const db = getAdminDb(); + const project = db.prepare('SELECT * FROM projects WHERE name = ?').get(req.params.name) as Project | undefined; + if (!project) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + db.prepare('DELETE FROM projects WHERE name = ?').run(req.params.name); + + // Optionally delete repo (controlled by query param) + if (req.query.delete_repo === 'true') { + deleteRepo(project.repo_name); + } + + res.status(204).send(); +}); + +export default router; diff --git a/admin-panel/src/server/routes/settings.ts b/admin-panel/src/server/routes/settings.ts new file mode 100644 index 0000000..4eed6f1 --- /dev/null +++ b/admin-panel/src/server/routes/settings.ts @@ -0,0 +1,35 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb } from '../db.js'; + +const router = Router(); + +// GET /api/settings +router.get('/', (_req: Request, res: Response) => { + const db = getAdminDb(); + const rows = db.prepare('SELECT key, value FROM settings').all() as Array<{ + key: string; + value: string; + }>; + const settings: Record<string, string> = {}; + for (const row of rows) { + settings[row.key] = row.value; + } + res.json(settings); +}); + +// PATCH /api/settings +router.patch('/', (req: Request, res: Response) => { + const db = getAdminDb(); + const stmt = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + + const updates = req.body as Record<string, string>; + for (const [key, value] of Object.entries(updates)) { + if (typeof key === 'string' && typeof value === 'string') { + stmt.run(key, value); + } + } + + res.status(204).send(); +}); + +export default router; diff --git a/admin-panel/src/server/routes/tasks.ts b/admin-panel/src/server/routes/tasks.ts new file mode 100644 index 0000000..1ab1e8d --- /dev/null +++ b/admin-panel/src/server/routes/tasks.ts @@ -0,0 +1,248 @@ +import { Router, Request, Response } from 'express'; +import { getAdminDb, getProjectDb } from '../db.js'; +import { broadcast } from '../ws.js'; + +const router = Router({ mergeParams: true }); + +type Params = { project: string; id?: string }; + +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; +} + +const VALID_STATUSES = ['todo', 'plan', 'plan_review', 'impl', 'impl_review', 'test', 'done']; +const VALID_PRIORITIES = ['high', 'medium', 'low']; + +function getProject(projectName: string): boolean { + const db = getAdminDb(); + return !!db.prepare('SELECT id FROM projects WHERE name = ?').get(projectName); +} + +// GET /api/projects/:project/tasks +router.get('/', (req: Request<Params>, res: Response) => { + const { project } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const tasks = db.prepare('SELECT * FROM tasks ORDER BY rank ASC, id ASC').all(); + res.json(tasks); +}); + +// POST /api/projects/:project/tasks +router.post('/', (req: Request<Params>, res: Response) => { + const { project } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const { title, description, priority, level, tags, status } = req.body; + if (!title) { + res.status(400).json({ error: 'title is required' }); + return; + } + + const db = getProjectDb(project); + + // Get max rank for ordering + const maxRank = (db.prepare('SELECT MAX(rank) as max FROM tasks').get() as { max: number | null })?.max || 0; + + const result = db.prepare( + `INSERT INTO tasks (title, description, priority, level, tags, status, rank) + VALUES (?, ?, ?, ?, ?, ?, ?)` + ).run( + title, + description || null, + VALID_PRIORITIES.includes(priority) ? priority : 'medium', + [1, 2, 3].includes(level) ? level : 3, + JSON.stringify(tags || []), + VALID_STATUSES.includes(status) ? status : 'todo', + maxRank + 1 + ); + + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(result.lastInsertRowid); + broadcast(project, { type: 'task:created', project, task }); + res.status(201).json(task); +}); + +// GET /api/projects/:project/tasks/:id +router.get('/:id', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + res.json(task); +}); + +// PATCH /api/projects/:project/tasks/:id +router.patch('/:id', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as Task | undefined; + if (!existing) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + const allowedFields = [ + 'title', 'status', 'priority', 'level', 'description', 'plan', + 'decision_log', 'done_when', 'implementation_notes', 'tags', + 'plan_review_comments', 'review_comments', 'test_results', + 'agent_log', 'current_agent', 'plan_review_count', 'impl_review_count', + 'branch_name', 'gerrit_change_id', 'gerrit_change_number', 'diff', 'rank' + ]; + + const updates: string[] = []; + const params: unknown[] = []; + + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + let value = req.body[field]; + + // Validate specific fields + if (field === 'status' && !VALID_STATUSES.includes(value)) continue; + if (field === 'priority' && !VALID_PRIORITIES.includes(value)) continue; + if (field === 'level' && ![1, 2, 3].includes(value)) continue; + + // Stringify JSON fields + if (['tags', 'plan_review_comments', 'review_comments', 'test_results', 'agent_log'].includes(field)) { + value = typeof value === 'string' ? value : JSON.stringify(value); + } + + updates.push(`${field} = ?`); + params.push(value); + } + } + + // Set timestamp fields based on status changes + if (req.body.status) { + const now = new Date().toISOString(); + switch (req.body.status) { + case 'plan': + if (!existing.started_at) { + updates.push('started_at = ?'); + params.push(now); + } + break; + case 'impl': + updates.push('planned_at = ?'); + params.push(now); + break; + case 'test': + updates.push('reviewed_at = ?'); + params.push(now); + break; + case 'done': + updates.push('completed_at = ?'); + params.push(now); + break; + } + } + + if (updates.length === 0) { + res.json(existing); + return; + } + + params.push(id); + db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + broadcast(project, { type: 'task:updated', project, task }); + res.json(task); +}); + +// DELETE /api/projects/:project/tasks/:id +router.delete('/:id', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const db = getProjectDb(project); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + if (!task) { + res.status(404).json({ error: 'Task not found' }); + return; + } + + db.prepare('DELETE FROM tasks WHERE id = ?').run(id); + broadcast(project, { type: 'task:deleted', project, taskId: Number(id) }); + res.status(204).send(); +}); + +// PATCH /api/projects/:project/tasks/:id/reorder +router.patch('/:id/reorder', (req: Request<Params>, res: Response) => { + const { project, id } = req.params; + if (!getProject(project)) { + res.status(404).json({ error: 'Project not found' }); + return; + } + + const { rank, status } = req.body; + if (rank === undefined) { + res.status(400).json({ error: 'rank is required' }); + return; + } + + const db = getProjectDb(project); + const updates: string[] = ['rank = ?']; + const params: unknown[] = [rank]; + + if (status && VALID_STATUSES.includes(status)) { + updates.push('status = ?'); + params.push(status); + } + + params.push(id); + db.prepare(`UPDATE tasks SET ${updates.join(', ')} WHERE id = ?`).run(...params); + const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(id); + broadcast(project, { type: 'task:updated', project, task }); + res.json(task); +}); + +export default router; |
