From 92e4e22e4d925b248b20ed62514b749765f7e98a Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Mon, 27 Apr 2026 03:05:04 +0900 Subject: [PATCH] feat(dashboard): create and edit scheduled tasks --- apps/dashboard/src/App.tsx | 264 ++++++++++++++++++++++++++++- apps/dashboard/src/api.ts | 47 ++++++ apps/dashboard/src/i18n.ts | 105 ++++++++++++ apps/dashboard/src/styles.css | 110 +++++++++++++ src/index.ts | 1 + src/web-dashboard-server.test.ts | 192 +++++++++++++++++++++ src/web-dashboard-server.ts | 275 ++++++++++++++++++++++++++++++- 7 files changed, 987 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index 422b959..716e196 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -1,15 +1,21 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { + type CreateScheduledTaskInput, type DashboardInboxAction, + type DashboardTaskContextMode, + type DashboardTaskScheduleType, type DashboardTaskAction, type DashboardOverview, type DashboardTask, + type UpdateScheduledTaskInput, type StatusSnapshot, + createScheduledTask, fetchDashboardData, runInboxAction, runScheduledTaskAction, sendRoomMessage, + updateScheduledTask, } from './api'; import { LOCALES, @@ -37,11 +43,20 @@ 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 TaskActionKey = + | 'create' + | `${string}:edit` + | `${string}:${DashboardTaskAction}`; type InboxActionKey = `${string}:${DashboardInboxAction}`; type InboxFilter = 'all' | InboxItem['kind']; type HealthLevel = 'ok' | 'stale' | 'down'; +interface RoomOption { + jid: string; + name: string; + folder: string; +} + const REFRESH_INTERVAL_MS = 15_000; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2'; const DEFAULT_VIEW: DashboardView = 'inbox'; @@ -295,6 +310,85 @@ function taskActionsFor(task: DashboardTask): DashboardTaskAction[] { return []; } +function buildRoomOptions(snapshots: StatusSnapshot[]): RoomOption[] { + const rooms = new Map(); + for (const snapshot of snapshots) { + for (const entry of snapshot.entries) { + if (!rooms.has(entry.jid)) { + rooms.set(entry.jid, { + jid: entry.jid, + name: entry.name || entry.folder || entry.jid, + folder: entry.folder, + }); + } + } + } + return [...rooms.values()].sort((a, b) => + `${a.name} ${a.folder}`.localeCompare(`${b.name} ${b.folder}`), + ); +} + +function isTaskScheduleType( + value: FormDataEntryValue | null, +): value is DashboardTaskScheduleType { + return value === 'cron' || value === 'interval' || value === 'once'; +} + +function isTaskContextMode( + value: FormDataEntryValue | null, +): value is DashboardTaskContextMode { + return value === 'group' || value === 'isolated'; +} + +function readRequiredText(form: FormData, name: string): string | null { + const value = form.get(name); + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function readTaskForm( + form: FormData, + includeRoom: true, +): CreateScheduledTaskInput | null; +function readTaskForm( + form: FormData, + includeRoom: false, +): UpdateScheduledTaskInput | null; +function readTaskForm( + form: FormData, + includeRoom: boolean, +): CreateScheduledTaskInput | UpdateScheduledTaskInput | null { + const prompt = readRequiredText(form, 'prompt'); + const scheduleValue = readRequiredText(form, 'scheduleValue'); + const scheduleTypeValue = form.get('scheduleType'); + if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) { + return null; + } + const scheduleType = scheduleTypeValue; + + if (!includeRoom) { + return prompt + ? { prompt, scheduleType, scheduleValue } + : { scheduleType, scheduleValue }; + } + + if (!prompt) { + return null; + } + + const roomJid = readRequiredText(form, 'roomJid'); + const contextMode = form.get('contextMode'); + if (!roomJid || !isTaskContextMode(contextMode)) return null; + return { + contextMode, + prompt, + roomJid, + scheduleType, + scheduleValue, + }; +} + function inboxActionsFor(item: InboxItem): DashboardInboxAction[] { if (item.source !== 'paired-task') return []; if ( @@ -1262,14 +1356,20 @@ function UsagePanel({ function TaskPanel({ tasks, + rooms, locale, onTaskAction, + onTaskCreate, + onTaskUpdate, taskActionKey, t, }: { tasks: DashboardTask[]; + rooms: RoomOption[]; locale: Locale; onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void; + onTaskCreate: (input: CreateScheduledTaskInput) => void; + onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void; taskActionKey: TaskActionKey | null; t: Messages; }) { @@ -1301,12 +1401,73 @@ function TaskPanel({ ]; }, [tasks]); - if (tasks.length === 0) { - return {t.tasks.empty}; - } - return (
+
{ + event.preventDefault(); + const form = new FormData(event.currentTarget); + const input = readTaskForm(form, true); + if (!input) return; + onTaskCreate(input); + event.currentTarget.reset(); + }} + > +
+ {t.tasks.createTitle} + {t.tasks.createSubtitle} +
+ +