From 22bd24ec8811e20ce020451978293d2b671ad068 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 01:36:40 +0900 Subject: [PATCH] feat(dashboard): add scheduled task actions (#31) --- apps/dashboard/src/App.tsx | 69 ++++++++++++++++++- apps/dashboard/src/api.ts | 38 +++++++++++ apps/dashboard/src/i18n.ts | 35 ++++++++++ apps/dashboard/src/styles.css | 42 ++++++++++++ src/web-dashboard-server.test.ts | 111 +++++++++++++++++++++++++++++++ src/web-dashboard-server.ts | 84 ++++++++++++++++++++++- 6 files changed, 376 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 3b2aa1c..4910d06 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,10 +1,12 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { + type DashboardTaskAction, type DashboardOverview, type DashboardTask, type StatusSnapshot, fetchDashboardData, + runScheduledTaskAction, } from './api'; import { LOCALES, @@ -32,6 +34,7 @@ type UsageLimitWindow = 'h5' | 'd7'; type DashboardView = 'usage' | 'inbox' | 'health' | 'rooms' | 'scheduled'; type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed'; type TaskResultTone = 'ok' | 'fail' | 'none'; +type TaskActionKey = `${string}:${DashboardTaskAction}`; type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; @@ -278,6 +281,12 @@ function taskDisplayName(task: DashboardTask, t: Messages): string { return task.id; } +function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { + if (task.status === 'active') return ['pause', 'cancel']; + if (task.status === 'paused') return ['resume', 'cancel']; + return []; +} + const INBOX_FILTERS: InboxFilter[] = [ 'all', 'ci-failure', @@ -1089,10 +1098,14 @@ function UsagePanel({ function TaskPanel({ tasks, locale, + onTaskAction, + taskActionKey, t, }: { tasks: DashboardTask[]; locale: Locale; + onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; + taskActionKey: TaskActionKey | null; t: Messages; }) { const taskGroups = useMemo(() => { @@ -1155,6 +1168,7 @@ function TaskPanel({ task.lastResult, t.tasks.noResult, ); + const taskActions = taskActionsFor(task); return (
+ {taskActions.length > 0 ? ( +
+ {taskActions.map((action) => { + const actionKey: TaskActionKey = `${task.id}:${action}`; + const busy = taskActionKey === actionKey; + return ( + + ); + })} +
+ ) : null} +
{t.tasks.next} @@ -1278,6 +1314,9 @@ function App() { const [drawerOpen, setDrawerOpen] = useState(false); const [activeView, setActiveView] = useState(readViewFromHash); const [locale, setLocale] = useState(readInitialLocale); + const [taskActionKey, setTaskActionKey] = useState( + null, + ); const t = messages[locale]; function setDashboardLocale(nextLocale: Locale) { @@ -1306,6 +1345,26 @@ function App() { } } + async function handleTaskAction( + task: DashboardTask, + action: DashboardTaskAction, + ) { + if (action === 'cancel' && !window.confirm(t.tasks.actions.confirmCancel)) { + return; + } + + const actionKey: TaskActionKey = `${task.id}:${action}`; + setTaskActionKey(actionKey); + try { + await runScheduledTaskAction(task.id, action); + await refresh(false); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setTaskActionKey(null); + } + } + useEffect(() => { document.documentElement.lang = localeTags[locale]; }, [locale]); @@ -1430,7 +1489,15 @@ function App() {

{t.panels.scheduled}

{t.panels.promptPreviews}
- + + void handleTaskAction(task, action) + } + taskActionKey={taskActionKey} + tasks={data.tasks} + t={t} + /> ) : null} diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index 546406b..e9022be 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -100,6 +100,8 @@ export interface DashboardTask { isWatcher: boolean; } +export type DashboardTaskAction = 'pause' | 'resume' | 'cancel'; + async function fetchJson(path: string): Promise { const response = await fetch(path, { headers: { accept: 'application/json' }, @@ -110,6 +112,28 @@ async function fetchJson(path: string): Promise { return (await response.json()) as T; } +async function postJson(path: string, body: unknown): Promise { + const response = await fetch(path, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + let message = `${path} failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) message = payload.error; + } catch { + // Keep the status-based message when the server body is not JSON. + } + throw new Error(message); + } + return (await response.json()) as T; +} + export async function fetchDashboardData(): Promise<{ overview: DashboardOverview; snapshots: StatusSnapshot[]; @@ -123,3 +147,17 @@ export async function fetchDashboardData(): Promise<{ return { overview, snapshots, tasks }; } + +export async function runScheduledTaskAction( + taskId: string, + action: DashboardTaskAction, +): Promise<{ + ok: true; + id?: string; + deleted?: boolean; + task?: DashboardTask | null; +}> { + return postJson(`/api/tasks/${encodeURIComponent(taskId)}/actions`, { + action, + }); +} diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts index 91dffc5..f200730 100644 --- a/apps/dashboard/src/i18n.ts +++ b/apps/dashboard/src/i18n.ts @@ -177,6 +177,13 @@ export interface Messages { noResult: string; noTime: string; now: string; + actions: { + pause: string; + resume: string; + cancel: string; + busy: string; + confirmCancel: string; + }; }; status: { processing: string; @@ -386,6 +393,13 @@ export const messages = { noResult: '결과 없음', noTime: '-', now: '지금', + actions: { + pause: 'Pause', + resume: 'Resume', + cancel: 'Cancel', + busy: 'Working', + confirmCancel: 'Cancel this scheduled work?', + }, }, status: { processing: '처리중', @@ -579,6 +593,13 @@ export const messages = { noResult: 'no result', noTime: '-', now: 'now', + actions: { + pause: 'Pause', + resume: 'Resume', + cancel: 'Cancel', + busy: 'Working', + confirmCancel: 'Cancel this scheduled work?', + }, }, status: { processing: 'processing', @@ -772,6 +793,13 @@ export const messages = { noResult: '无结果', noTime: '-', now: '现在', + actions: { + pause: 'Pause', + resume: 'Resume', + cancel: 'Cancel', + busy: 'Working', + confirmCancel: 'Cancel this scheduled work?', + }, }, status: { processing: '处理中', @@ -965,6 +993,13 @@ export const messages = { noResult: '結果なし', noTime: '-', now: '今', + actions: { + pause: 'Pause', + resume: 'Resume', + cancel: 'Cancel', + busy: 'Working', + confirmCancel: 'Cancel this scheduled work?', + }, }, status: { processing: '処理中', diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index e615733..65fa439 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1198,6 +1198,43 @@ progress::-moz-progress-bar { background: rgba(43, 55, 38, 0.08); } +.task-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.task-action { + min-height: 34px; + padding: 0 12px; + border: 1px solid rgba(43, 55, 38, 0.12); + border-radius: 999px; + color: var(--ink); + font: inherit; + font-size: 12px; + font-weight: 900; + background: rgba(255, 250, 240, 0.84); + cursor: pointer; +} + +.task-action:hover, +.task-action:focus-visible { + border-color: rgba(191, 95, 44, 0.42); + outline: none; + box-shadow: 0 0 0 3px rgba(191, 95, 44, 0.12); +} + +.task-action:disabled { + cursor: progress; + opacity: 0.55; +} + +.task-action-cancel { + color: #8f2f22; + border-color: rgba(183, 71, 52, 0.22); + background: rgba(183, 71, 52, 0.08); +} + .task-time-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -1668,6 +1705,11 @@ progress::-moz-progress-bar { padding: 9px; } + .task-action { + min-height: 32px; + padding-inline: 10px; + } + .task-card-main { gap: 7px; } diff --git a/src/web-dashboard-server.test.ts b/src/web-dashboard-server.test.ts index bfbe376..bfeb2c5 100644 --- a/src/web-dashboard-server.test.ts +++ b/src/web-dashboard-server.test.ts @@ -233,6 +233,117 @@ describe('web dashboard server handler', () => { expect(ciFailure?.summary).not.toContain('plain-secret-value'); }); + it('mutates scheduled tasks through explicit action endpoints', async () => { + let task: ScheduledTask | undefined = makeTask({ + id: 'ci-watch-1', + prompt: '[BACKGROUND CI WATCH]\nwatch', + }); + const updates: Array> = []; + const deleted: string[] = []; + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => (task ? [task] : []), + getTaskById: (id) => (task?.id === id ? task : undefined), + updateTask: (id, update) => { + expect(id).toBe('ci-watch-1'); + updates.push(update); + task = task ? { ...task, ...update } : task; + }, + deleteTask: (id) => { + deleted.push(id); + task = undefined; + }, + getPairedTasks: () => [], + }); + + const pause = await handler( + new Request('http://localhost/api/tasks/ci-watch-1/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'pause' }), + }), + ); + expect(pause.status).toBe(200); + await expect(pause.json()).resolves.toMatchObject({ + ok: true, + task: { id: 'ci-watch-1', status: 'paused' }, + }); + expect(updates.at(-1)).toMatchObject({ + status: 'paused', + suspended_until: null, + }); + + const resume = await handler( + new Request('http://localhost/api/tasks/ci-watch-1/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'resume' }), + }), + ); + expect(resume.status).toBe(200); + await expect(resume.json()).resolves.toMatchObject({ + ok: true, + task: { id: 'ci-watch-1', status: 'active' }, + }); + expect(updates.at(-1)).toMatchObject({ + status: 'active', + suspended_until: null, + }); + + const cancel = await handler( + new Request('http://localhost/api/tasks/ci-watch-1/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'cancel' }), + }), + ); + expect(cancel.status).toBe(200); + await expect(cancel.json()).resolves.toEqual({ + ok: true, + id: 'ci-watch-1', + deleted: true, + }); + expect(deleted).toEqual(['ci-watch-1']); + }); + + it('rejects invalid scheduled task actions', async () => { + const handler = createWebDashboardHandler({ + readStatusSnapshots: () => [], + getTasks: () => [], + getTaskById: (id) => + id === 'task-1' ? makeTask({ id: 'task-1' }) : undefined, + updateTask: () => { + throw new Error('updateTask should not be called'); + }, + getPairedTasks: () => [], + }); + + const invalid = await handler( + new Request('http://localhost/api/tasks/task-1/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'restart-everything' }), + }), + ); + expect(invalid.status).toBe(400); + + const missing = await handler( + new Request('http://localhost/api/tasks/missing/actions', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ action: 'pause' }), + }), + ); + expect(missing.status).toBe(404); + + const wrongMethod = await handler( + new Request('http://localhost/api/tasks/task-1/actions', { + method: 'GET', + }), + ); + expect(wrongMethod.status).toBe(405); + }); + it('serves Vite static assets and falls back to index for SPA routes', async () => { const staticDir = fs.mkdtempSync( path.join(os.tmpdir(), 'ejclaw-dashboard-'), diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index b7b9776..ba48805 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -2,7 +2,13 @@ import fs from 'fs'; import path from 'path'; import { WEB_DASHBOARD } from './config.js'; -import { getAllOpenPairedTasks, getAllTasks } from './db.js'; +import { + deleteTask, + getAllOpenPairedTasks, + getAllTasks, + getTaskById, + updateTask, +} from './db.js'; import { logger } from './logger.js'; import { readStatusSnapshots, @@ -21,6 +27,12 @@ export interface WebDashboardHandlerOptions { statusMaxAgeMs?: number; readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[]; getTasks?: () => ScheduledTask[]; + getTaskById?: (id: string) => ScheduledTask | undefined; + updateTask?: ( + id: string, + updates: Partial>, + ) => void; + deleteTask?: (id: string) => void; getPairedTasks?: () => PairedTask[]; now?: () => string; } @@ -101,16 +113,84 @@ function serveStaticFile(staticDir: string, pathname: string): Response { }); } +type TaskAction = 'pause' | 'resume' | 'cancel'; + +function parseTaskActionPath(pathname: string): string | null { + const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function isTaskAction(value: unknown): value is TaskAction { + return value === 'pause' || value === 'resume' || value === 'cancel'; +} + +async function readTaskAction(request: Request): Promise { + try { + const body = (await request.json()) as { action?: unknown }; + return isTaskAction(body.action) ? body.action : null; + } catch { + return null; + } +} + export function createWebDashboardHandler( opts: WebDashboardHandlerOptions = {}, ): (request: Request) => Response | Promise { const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots; const loadTasks = opts.getTasks ?? getAllTasks; + const loadTaskById = opts.getTaskById ?? getTaskById; + const mutateTask = opts.updateTask ?? updateTask; + const removeTask = opts.deleteTask ?? deleteTask; const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; - return (request: Request): Response => { + return async (request: Request): Promise => { const url = new URL(request.url); + const actionTaskId = parseTaskActionPath(url.pathname); + + if (actionTaskId) { + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + const action = await readTaskAction(request); + if (!action) { + return jsonResponse({ error: 'Invalid task action' }, { status: 400 }); + } + + const task = loadTaskById(actionTaskId); + if (!task) { + return jsonResponse({ error: 'Task not found' }, { status: 404 }); + } + + if (task.status === 'completed' && action !== 'cancel') { + return jsonResponse( + { error: 'Completed tasks cannot be changed' }, + { status: 409 }, + ); + } + + if (action === 'cancel') { + removeTask(actionTaskId); + return jsonResponse({ ok: true, id: actionTaskId, deleted: true }); + } + + mutateTask(actionTaskId, { + status: action === 'pause' ? 'paused' : 'active', + suspended_until: null, + }); + + const updatedTask = loadTaskById(actionTaskId); + return jsonResponse({ + ok: true, + task: updatedTask ? sanitizeScheduledTask(updatedTask) : null, + }); + } if (request.method !== 'GET' && request.method !== 'HEAD') { return jsonResponse({ error: 'Method not allowed' }, { status: 405 });