summaryrefslogtreecommitdiff
path: root/admin-panel/src/client/pages
diff options
context:
space:
mode:
Diffstat (limited to 'admin-panel/src/client/pages')
-rw-r--r--admin-panel/src/client/pages/dashboard.ts150
-rw-r--r--admin-panel/src/client/pages/project.ts167
-rw-r--r--admin-panel/src/client/pages/settings.ts59
3 files changed, 376 insertions, 0 deletions
diff --git a/admin-panel/src/client/pages/dashboard.ts b/admin-panel/src/client/pages/dashboard.ts
new file mode 100644
index 0000000..ded6d3b
--- /dev/null
+++ b/admin-panel/src/client/pages/dashboard.ts
@@ -0,0 +1,150 @@
+import { api, type Project } from '../lib/api.js';
+
+export async function renderDashboard(container: HTMLElement): Promise<void> {
+ container.innerHTML = `
+ <div class="dashboard-header">
+ <h1>Projects</h1>
+ <button class="btn btn-primary" id="new-project-btn">+ New Project</button>
+ </div>
+ <div class="project-grid" id="project-grid">
+ <div class="empty-state">
+ <h3>Loading...</h3>
+ </div>
+ </div>
+ `;
+
+ const grid = container.querySelector('#project-grid')!;
+ const newBtn = container.querySelector('#new-project-btn')!;
+
+ // Load projects
+ try {
+ const projects = await api.getProjects();
+ renderProjects(grid, projects);
+ } catch (err) {
+ grid.innerHTML = `<div class="empty-state"><h3>Failed to load projects</h3><p>${(err as Error).message}</p></div>`;
+ }
+
+ newBtn.addEventListener('click', () => showCreateDialog(grid));
+}
+
+function renderProjects(grid: Element, projects: Project[]): void {
+ if (projects.length === 0) {
+ grid.innerHTML = `
+ <div class="empty-state">
+ <h3>No projects yet</h3>
+ <p>Create your first project to get started.</p>
+ </div>
+ `;
+ return;
+ }
+
+ grid.innerHTML = projects.map(p => `
+ <a href="/project/${p.name}" class="project-card" data-route="/project/${p.name}">
+ <h3>${escapeHtml(p.display_name)}</h3>
+ <div class="project-name">${escapeHtml(p.name)}</div>
+ ${p.description ? `<div class="project-desc">${escapeHtml(p.description)}</div>` : ''}
+ <div class="project-stats">
+ <span class="stat"><span class="stat-dot todo"></span> ${escapeHtml(p.repo_name)}</span>
+ <span class="stat">${timeAgo(p.created_at)}</span>
+ </div>
+ </a>
+ `).join('');
+}
+
+function showCreateDialog(grid: Element): void {
+ // Remove existing modal if present
+ document.querySelector('.modal-overlay')?.remove();
+
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+ <div class="modal">
+ <div class="modal-header">
+ <h2>New Project</h2>
+ <button class="btn-icon close-btn">&times;</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">&larr; 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">&times;</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>
+ `;
+}