feat: implement full-stack TaskPilot scheduler

This commit is contained in:
2026-10-18 22:05:45 +08:00
parent d88c21753d
commit 8ce02af13a
72 changed files with 17812 additions and 0 deletions
+1251
View File
File diff suppressed because it is too large Load Diff
+156
View File
@@ -0,0 +1,156 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import type { Task, TaskListResponse } from './api/client'
import App from './App'
const task: Task = {
id: 'echo_task',
name: 'Echo task',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
enabled: true,
timeout_secs: 300,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: {
type: 'script',
working_dir: 'D:\\Projects\\echo',
interpreter: 'python.exe',
script: 'main.py',
args: [],
env: {},
},
created_at: '2026-10-18T10:00:00Z',
updated_at: '2026-10-18T10:00:00Z',
}
const taskList: TaskListResponse = {
items: [task],
page: 1,
page_size: 20,
total: 1,
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function createFetchMock() {
return vi.fn(
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url
if (url.endsWith('/api/v1/health')) {
return jsonResponse({
status: 'ok',
service: 'task-pilot-backend',
version: '0.1.0',
})
}
if (url.endsWith('/api/v1/session')) {
return jsonResponse({ token: 'test-token' })
}
if (url.endsWith('/api/v1/cron/preview')) {
return jsonResponse({
timezone: 'Asia/Shanghai',
runs: [
'2026-10-19 02:00:00',
'2026-10-20 02:00:00',
],
})
}
if (url.includes('/api/v1/tasks') && init?.method === 'POST') {
return jsonResponse(
{
...task,
...JSON.parse(String(init.body)),
},
201,
)
}
if (url.includes('/api/v1/tasks')) {
return jsonResponse(taskList)
}
return jsonResponse({ code: 'NOT_FOUND', message: 'not found', details: {} }, 404)
},
)
}
function renderApp() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<App />
</MemoryRouter>
</QueryClientProvider>,
)
}
describe('TaskDashboard', () => {
it('renders tasks returned by the Rust API', async () => {
vi.stubGlobal('fetch', createFetchMock())
renderApp()
expect(await screen.findByText('Echo task')).toBeInTheDocument()
expect(screen.getByText('echo_task')).toBeInTheDocument()
expect(screen.getByText('0 0 2 * * *')).toBeInTheDocument()
expect(await screen.findByText('在线')).toBeInTheDocument()
})
it('creates a script task with the visible form values', async () => {
const fetchMock = createFetchMock()
vi.stubGlobal('fetch', fetchMock)
const user = userEvent.setup()
renderApp()
await user.click(screen.getByRole('button', { name: '+ 新建任务' }))
await user.clear(screen.getByLabelText('任务标识'))
await user.type(screen.getByLabelText('任务标识'), 'new_task')
await user.type(screen.getByLabelText('任务名称'), 'New task')
await user.type(screen.getByLabelText('工作目录'), 'D:\\Projects\\new')
await user.click(
screen.getByRole('button', { name: '保存并热刷新' }),
)
await waitFor(() => {
const createCall = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/tasks') && init?.method === 'POST',
)
expect(createCall).toBeDefined()
})
const createCall = fetchMock.mock.calls.find(
([input, init]) =>
String(input).endsWith('/api/v1/tasks') && init?.method === 'POST',
)
const payload = JSON.parse(String(createCall?.[1]?.body))
expect(payload.id).toBe('new_task')
expect(payload.name).toBe('New task')
expect(payload.action.type).toBe('script')
expect(payload.action.working_dir).toBe('D:\\Projects\\new')
})
})
+31
View File
@@ -0,0 +1,31 @@
import { Link, Route, Routes } from 'react-router-dom'
import { TaskDashboard } from './pages/TaskDashboard'
import './App.css'
function NotFoundPage() {
return (
<main className="app-shell">
<header className="dashboard-header">
<div>
<p className="eyebrow">404 / ROUTE NOT FOUND</p>
<h1>页面不存在</h1>
<p className="subtitle">该地址尚未配置到 TaskPilot 路由。</p>
</div>
</header>
<Link className="button button-primary" to="/">
返回任务列表
</Link>
</main>
)
}
function App() {
return (
<Routes>
<Route path="/" element={<TaskDashboard />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
)
}
export default App
+234
View File
@@ -0,0 +1,234 @@
import type { components } from './generated'
export type HealthResponse = components['schemas']['HealthResponse']
export type Task = components['schemas']['TaskResponse']
export type TaskAction = components['schemas']['TaskAction']
export type TaskActionType = components['schemas']['TaskActionType']
export type CreateTaskRequest = components['schemas']['CreateTaskRequest']
export type UpdateTaskRequest = components['schemas']['UpdateTaskRequest']
export type TaskListResponse = components['schemas']['TaskListResponse']
export type TaskRun = components['schemas']['TaskRunResponse']
export type TaskRunListResponse = components['schemas']['TaskRunListResponse']
export type RunStatus = components['schemas']['RunStatus']
export type RunTrigger = components['schemas']['RunTrigger']
export type LogChunkResponse = components['schemas']['LogChunkResponse']
export type ErrorResponse = components['schemas']['ErrorResponse']
export type SessionResponse = components['schemas']['SessionResponse']
export type CronPreviewRequest = components['schemas']['CronPreviewRequest']
export type CronPreviewResponse = components['schemas']['CronPreviewResponse']
export interface TaskFilters {
q?: string
enabled?: boolean
action_type?: TaskActionType
page?: number
page_size?: number
}
export interface RunFilters {
page?: number
page_size?: number
status?: RunStatus
trigger?: RunTrigger
}
export type ExecutionEvent =
| {
event: 'run'
data: {
run: TaskRun
}
}
| {
event: 'log'
data: {
run_id: number
offset: number
next_offset: number
content: string
}
}
export class ApiRequestError extends Error {
readonly status: number
readonly code?: string
readonly field?: string | null
readonly details?: Record<string, string>
constructor(status: number, body?: ErrorResponse) {
super(body?.message ?? `请求失败(HTTP ${status})`)
this.name = 'ApiRequestError'
this.status = status
this.code = body?.code
this.field = body?.field
this.details = body?.details
}
}
async function requestJson<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
headers.set('Accept', 'application/json')
const method = (init.method ?? 'GET').toUpperCase()
if (init.body) {
headers.set('Content-Type', 'application/json')
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
headers.set('X-TaskPilot-Token', await fetchSessionToken())
}
const response = await fetch(path, {
...init,
headers,
})
if (!response.ok) {
let body: ErrorResponse | undefined
try {
body = (await response.json()) as ErrorResponse
} catch {
body = undefined
}
throw new ApiRequestError(response.status, body)
}
if (response.status === 204) {
return undefined as T
}
return (await response.json()) as T
}
async function fetchSessionToken(): Promise<string> {
const response = await fetch('/api/v1/session', {
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new ApiRequestError(response.status)
}
const session = (await response.json()) as SessionResponse
return session.token
}
function withQuery(path: string, values: object): string {
const query = new URLSearchParams()
for (const [key, value] of Object.entries(values)) {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
}
const serialized = query.toString()
return serialized ? `${path}?${serialized}` : path
}
export function fetchHealth(signal?: AbortSignal): Promise<HealthResponse> {
return requestJson<HealthResponse>('/api/v1/health', { signal })
}
export function listTasks(
filters: TaskFilters = {},
signal?: AbortSignal,
): Promise<TaskListResponse> {
return requestJson<TaskListResponse>(
withQuery('/api/v1/tasks', filters),
{ signal },
)
}
export function getTask(id: string, signal?: AbortSignal): Promise<Task> {
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
signal,
})
}
export function createTask(payload: CreateTaskRequest): Promise<Task> {
return requestJson<Task>('/api/v1/tasks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function updateTask(
id: string,
payload: UpdateTaskRequest,
): Promise<Task> {
return requestJson<Task>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function deleteTask(id: string): Promise<void> {
return requestJson<void>(`/api/v1/tasks/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
export function setTaskEnabled(
id: string,
enabled: boolean,
): Promise<Task> {
const action = enabled ? 'enable' : 'disable'
return requestJson<Task>(
`/api/v1/tasks/${encodeURIComponent(id)}/${action}`,
{ method: 'POST' },
)
}
export function runTask(id: string): Promise<TaskRun> {
return requestJson<TaskRun>(
`/api/v1/tasks/${encodeURIComponent(id)}/run`,
{ method: 'POST' },
)
}
export function listTaskRuns(
taskId: string,
filters: RunFilters = {},
signal?: AbortSignal,
): Promise<TaskRunListResponse> {
return requestJson<TaskRunListResponse>(
withQuery(`/api/v1/tasks/${encodeURIComponent(taskId)}/runs`, filters),
{ signal },
)
}
export function getRun(
runId: number,
signal?: AbortSignal,
): Promise<TaskRun> {
return requestJson<TaskRun>(`/api/v1/runs/${runId}`, { signal })
}
export function getRunLogs(
runId: number,
offset = 0,
signal?: AbortSignal,
): Promise<LogChunkResponse> {
return requestJson<LogChunkResponse>(
withQuery(`/api/v1/runs/${runId}/logs`, { offset }),
{ signal },
)
}
export function cancelRun(runId: number): Promise<TaskRun> {
return requestJson<TaskRun>(`/api/v1/runs/${runId}/cancel`, {
method: 'POST',
})
}
export function runLogDownloadUrl(runId: number): string {
return `/api/v1/runs/${runId}/logs/download`
}
export function previewCron(
request: CronPreviewRequest,
signal?: AbortSignal,
): Promise<CronPreviewResponse> {
return requestJson<CronPreviewResponse>('/api/v1/cron/preview', {
method: 'POST',
body: JSON.stringify(request),
signal,
})
}
+940
View File
@@ -0,0 +1,940 @@
/**
* This file was auto-generated by openapi-typescript.
* Do not make direct changes to the file.
*/
export interface paths {
"/api/v1/cron/preview": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["preview_cron"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["execution_events"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/health": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["health"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_run"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["cancel_run"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_run_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/runs/{run_id}/logs/download": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["download_run_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/session": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["session"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_tasks"];
put?: never;
post: operations["create_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["get_task"];
put: operations["update_task"];
post?: never;
delete: operations["delete_task"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/disable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["disable_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/enable": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["enable_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/run": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["run_task"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tasks/{id}/runs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["list_task_runs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** @enum {string} */
ConcurrencyPolicy: "skip";
CreateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
enabled?: boolean;
id: string;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
CronPreviewRequest: {
count: number;
expression: string;
timezone: string;
};
CronPreviewResponse: {
runs: string[];
timezone: string;
};
ErrorResponse: {
code: string;
details: {
[key: string]: string;
};
field?: string | null;
message: string;
};
HealthResponse: {
/** @description Stable service identifier. */
service: string;
/** @description Current service status. */
status: string;
/** @description Backend package version. */
version: string;
};
/** @enum {string} */
HttpMethod: "GET" | "POST";
LogChunkResponse: {
complete: boolean;
content: string;
/** Format: int64 */
next_offset: number;
};
RetryPolicy: {
/** Format: int64 */
delay_secs?: number;
/** Format: int32 */
max_attempts?: number;
};
/** @enum {string} */
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
/** @enum {string} */
RunTrigger: "schedule" | "manual" | "retry";
SessionResponse: {
/** @description Random per-process token required for mutating browser requests. */
token: string;
};
TaskAction: {
args?: string[];
env?: {
[key: string]: string;
};
interpreter: string;
script: string;
/** @enum {string} */
type: "script";
working_dir: string;
} | {
body?: string | null;
expected_status?: number[];
headers?: {
[key: string]: string;
};
method: components["schemas"]["HttpMethod"];
/** @enum {string} */
type: "http";
url: string;
} | {
args?: string[];
env?: {
[key: string]: string;
};
executable: string;
/** @enum {string} */
type: "process";
working_dir?: string | null;
};
/** @enum {string} */
TaskActionType: "script" | "http" | "process";
TaskListResponse: {
items: components["schemas"]["TaskResponse"][];
/** Format: int32 */
page: number;
/** Format: int32 */
page_size: number;
/** Format: int64 */
total: number;
};
TaskResponse: {
action: components["schemas"]["TaskAction"];
concurrency_policy: components["schemas"]["ConcurrencyPolicy"];
/** Format: date-time */
created_at: string;
cron: string;
enabled: boolean;
id: string;
name: string;
retry_policy: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs: number;
timezone: string;
/** Format: date-time */
updated_at: string;
};
TaskRunListResponse: {
items: components["schemas"]["TaskRunResponse"][];
/** Format: int32 */
page: number;
/** Format: int32 */
page_size: number;
/** Format: int64 */
total: number;
};
TaskRunResponse: {
/** Format: date-time */
created_at: string;
/** Format: int64 */
duration_ms?: number | null;
error_message?: string | null;
/** Format: int32 */
exit_code?: number | null;
/** Format: date-time */
finished_at?: string | null;
/** Format: int64 */
id: number;
/** Format: date-time */
started_at?: string | null;
status: components["schemas"]["RunStatus"];
task_id: string;
trigger: components["schemas"]["RunTrigger"];
};
UpdateTaskRequest: {
action: components["schemas"]["TaskAction"];
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
cron: string;
enabled?: boolean;
name: string;
retry_policy?: components["schemas"]["RetryPolicy"];
/** Format: int64 */
timeout_secs?: number;
timezone?: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export interface operations {
preview_cron: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CronPreviewRequest"];
};
};
responses: {
/** @description Upcoming cron occurrences */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CronPreviewResponse"];
};
};
/** @description Invalid cron preview request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
execution_events: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Run status and log events */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/event-stream": string;
};
};
};
};
health: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description TaskPilot API is healthy */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HealthResponse"];
};
};
};
};
get_run: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task run detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
cancel_run: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cancellation requested */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Run is not cancellable */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_run_logs: {
parameters: {
query?: {
offset?: number;
};
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Incremental task run log */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LogChunkResponse"];
};
};
/** @description Run not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
download_run_logs: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Run identifier */
run_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Plain text task log */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/plain": string;
};
};
/** @description Run or log not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
session: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Local API session token */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SessionResponse"];
};
};
};
};
list_tasks: {
parameters: {
query?: {
q?: string;
enabled?: boolean;
action_type?: components["schemas"]["TaskActionType"];
page?: number;
page_size?: number;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Paginated task list */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskListResponse"];
};
};
};
};
create_task: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateTaskRequest"];
};
};
responses: {
/** @description Task created */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Task ID already exists */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
get_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task detail */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
update_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UpdateTaskRequest"];
};
};
responses: {
/** @description Task updated */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Validation failed */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
delete_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
disable_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task disabled */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
enable_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task enabled */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
run_task: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Manual run accepted */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunResponse"];
};
};
/** @description Task not found */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Action executor not implemented */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
};
};
list_task_runs: {
parameters: {
query?: {
page?: number;
page_size?: number;
status?: components["schemas"]["RunStatus"];
trigger?: components["schemas"]["RunTrigger"];
};
header?: never;
path: {
/** @description Stable task identifier */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Task run history */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TaskRunListResponse"];
};
};
};
};
}
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useRef } from 'react'
import { useFocusTrap } from '../lib/useFocusTrap'
interface ConfirmDialogProps {
open: boolean
title: string
description: string
busy?: boolean
onConfirm: () => void
onCancel: () => void
}
export function ConfirmDialog({
open,
title,
description,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef)
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !busy) {
onCancel()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [busy, onCancel, open])
if (!open) {
return null
}
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !busy) {
onCancel()
}
}}
>
<section
ref={dialogRef}
className="confirm-card"
tabIndex={-1}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-description"
>
<p className="eyebrow">CONFIRM ACTION</p>
<h2 id="confirm-title">{title}</h2>
<p className="muted-copy">{description}</p>
<div className="modal-actions">
<button
type="button"
className="button button-secondary"
onClick={onCancel}
disabled={busy}
>
取消
</button>
<button
type="button"
className="button button-danger"
onClick={onConfirm}
disabled={busy}
>
{busy ? '删除中...' : '确认删除'}
</button>
</div>
</section>
</div>
)
}
+501
View File
@@ -0,0 +1,501 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import {
cancelRun,
getRun,
getRunLogs,
listTaskRuns,
runLogDownloadUrl,
type ExecutionEvent,
type RunStatus,
type RunTrigger,
type Task,
type TaskRun,
} from '../api/client'
import {
formatDateTime,
formatDuration,
isTerminalRunStatus,
} from '../lib/format'
import { useFocusTrap } from '../lib/useFocusTrap'
import { RunStatusBadge } from './StatusBadge'
interface RunLogDrawerProps {
open: boolean
task: Task | null
initialRunId?: number | null
onClose: () => void
}
export function RunLogDrawer({
open,
task,
initialRunId,
onClose,
}: RunLogDrawerProps) {
const queryClient = useQueryClient()
const [selectedRunId, setSelectedRunId] = useState<number | null>(
initialRunId ?? null,
)
const [historyStatus, setHistoryStatus] = useState<RunStatus | ''>('')
const [historyTrigger, setHistoryTrigger] = useState<RunTrigger | ''>('')
const [sseConnected, setSseConnected] = useState(false)
const [logEvents] = useState(() => new EventTarget())
const drawerRef = useRef<HTMLElement>(null)
useFocusTrap(open, drawerRef)
const runsQuery = useQuery({
queryKey: [
'task-runs',
task?.id,
historyStatus,
historyTrigger,
],
queryFn: ({ signal }) =>
listTaskRuns(
task!.id,
{
page: 1,
page_size: 50,
status: historyStatus || undefined,
trigger: historyTrigger || undefined,
},
signal,
),
enabled: open && Boolean(task),
refetchInterval: (query) =>
query.state.data?.items.some((run) => !isTerminalRunStatus(run.status))
? 1_000
: 5_000,
})
const runs = runsQuery.data?.items ?? []
const effectiveRunId = selectedRunId ?? runs[0]?.id ?? null
const selectedRunQuery = useQuery({
queryKey: ['run', effectiveRunId],
queryFn: ({ signal }) => getRun(effectiveRunId!, signal),
enabled: effectiveRunId !== null,
refetchInterval: (query) =>
query.state.data && !isTerminalRunStatus(query.state.data.status)
? 800
: false,
})
const selectedRun =
selectedRunQuery.data ?? runs.find((run) => run.id === effectiveRunId) ?? null
const cancelMutation = useMutation({
mutationFn: (runId: number) => cancelRun(runId),
onSuccess: (run) => {
queryClient.setQueryData(['run', run.id], run)
void queryClient.invalidateQueries({
queryKey: ['task-runs', run.task_id],
})
},
})
useEffect(() => {
if (!open || !task) {
return
}
const source = new EventSource('/api/v1/events')
source.onopen = () => setSseConnected(true)
source.onerror = () => setSseConnected(false)
const onRun = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as ExecutionEvent
if (payload.event !== 'run' || payload.data.run.task_id !== task.id) {
return
}
queryClient.setQueryData(['run', payload.data.run.id], payload.data.run)
void queryClient.invalidateQueries({
queryKey: ['task-runs', task.id],
})
} catch {
setSseConnected(false)
}
}
const onLog = (event: MessageEvent<string>) => {
try {
const payload = JSON.parse(event.data) as ExecutionEvent
if (payload.event === 'log') {
logEvents.dispatchEvent(
new CustomEvent('log', { detail: payload }),
)
}
} catch {
setSseConnected(false)
}
}
const onLagged = () => {
void queryClient.invalidateQueries({
queryKey: ['task-runs', task.id],
})
}
source.addEventListener('run', onRun as EventListener)
source.addEventListener('log', onLog as EventListener)
source.addEventListener('lagged', onLagged)
return () => {
source.removeEventListener('run', onRun as EventListener)
source.removeEventListener('log', onLog as EventListener)
source.removeEventListener('lagged', onLagged)
source.close()
setSseConnected(false)
}
}, [logEvents, open, queryClient, task])
useEffect(() => {
if (!open) {
return
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [onClose, open])
if (!open || !task) {
return null
}
return (
<div
className="modal-overlay drawer-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target) {
onClose()
}
}}
>
<aside
ref={drawerRef}
className="run-drawer"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="run-drawer-title"
>
<header className="modal-header">
<div>
<p className="eyebrow">RUN HISTORY</p>
<h2 id="run-drawer-title">{task.name}</h2>
<p className="drawer-subtitle mono">{task.id}</p>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
aria-label="关闭运行日志"
>
×
</button>
</header>
<div className="run-drawer-body">
<section className="run-history-panel">
<div className="section-heading">
<div>
<p className="section-label">RUNS</p>
<h3>最近运行</h3>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => void runsQuery.refetch()}
disabled={runsQuery.isFetching}
>
刷新
</button>
</div>
<div className="history-filters">
<select
className="field-input compact-select"
aria-label="运行状态筛选"
value={historyStatus}
onChange={(event) => {
setHistoryStatus(event.target.value as RunStatus | '')
setSelectedRunId(null)
}}
>
<option value="">全部状态</option>
<option value="queued">排队中</option>
<option value="running">运行中</option>
<option value="succeeded">成功</option>
<option value="failed">失败</option>
<option value="timed_out">已超时</option>
<option value="cancelled">已取消</option>
<option value="skipped">已跳过</option>
</select>
<select
className="field-input compact-select"
aria-label="触发方式筛选"
value={historyTrigger}
onChange={(event) => {
setHistoryTrigger(event.target.value as RunTrigger | '')
setSelectedRunId(null)
}}
>
<option value="">全部触发</option>
<option value="schedule">定时</option>
<option value="manual">手动</option>
<option value="retry">重试</option>
</select>
</div>
{runsQuery.isPending ? (
<p className="muted-copy">正在加载运行记录...</p>
) : runsQuery.isError ? (
<div className="inline-alert inline-alert-error">
{runsQuery.error.message}
</div>
) : runs.length === 0 ? (
<p className="empty-inline">该任务还没有运行记录。</p>
) : (
<div className="run-list">
{runs.map((run) => (
<button
type="button"
className={`run-list-item ${
run.id === effectiveRunId ? 'is-selected' : ''
}`}
key={run.id}
onClick={() => setSelectedRunId(run.id)}
>
<span className="run-list-main">
<span className="mono">#{run.id}</span>
<RunStatusBadge status={run.status} />
</span>
<span className="run-list-meta">
{formatDateTime(run.created_at)} · {run.trigger}
</span>
</button>
))}
</div>
)}
</section>
<section className="run-detail-panel">
<div className="section-heading">
<div>
<p className="section-label">DETAIL</p>
<h3>
{effectiveRunId ? `运行 #${effectiveRunId}` : '运行详情'}
</h3>
</div>
{selectedRun ? (
<div className="run-detail-actions">
<RunStatusBadge status={selectedRun.status} />
{!isTerminalRunStatus(selectedRun.status) ? (
<button
type="button"
className="button button-danger button-small"
disabled={
cancelMutation.isPending &&
cancelMutation.variables === selectedRun.id
}
onClick={() => cancelMutation.mutate(selectedRun.id)}
>
{cancelMutation.isPending ? '取消中...' : '取消运行'}
</button>
) : null}
<a
className="button button-secondary button-small"
href={runLogDownloadUrl(selectedRun.id)}
>
下载日志
</a>
</div>
) : null}
</div>
{selectedRunQuery.isError ? (
<div className="inline-alert inline-alert-error">
{selectedRunQuery.error.message}
</div>
) : null}
{selectedRun ? (
<RunMetadata run={selectedRun} />
) : (
<p className="empty-inline">选择一条运行记录查看日志。</p>
)}
{effectiveRunId ? (
<RunLogOutput
key={effectiveRunId}
runId={effectiveRunId}
logEvents={logEvents}
sseConnected={sseConnected}
runComplete={
selectedRun ? isTerminalRunStatus(selectedRun.status) : false
}
/>
) : null}
</section>
</div>
</aside>
</div>
)
}
function RunMetadata({ run }: { run: TaskRun }) {
return (
<dl className="run-metadata">
<div>
<dt>触发方式</dt>
<dd>{run.trigger}</dd>
</div>
<div>
<dt>开始时间</dt>
<dd>{formatDateTime(run.started_at)}</dd>
</div>
<div>
<dt>耗时</dt>
<dd>{formatDuration(run.duration_ms)}</dd>
</div>
<div>
<dt>退出码</dt>
<dd>{run.exit_code ?? '--'}</dd>
</div>
{run.error_message ? (
<div className="run-error-metadata">
<dt>结果说明</dt>
<dd>{run.error_message}</dd>
</div>
) : null}
</dl>
)
}
interface RunLogOutputProps {
runId: number
logEvents: EventTarget
sseConnected: boolean
runComplete: boolean
}
function RunLogOutput({
runId,
logEvents,
sseConnected,
runComplete,
}: RunLogOutputProps) {
const [content, setContent] = useState('')
const [pollComplete, setPollComplete] = useState(runComplete)
const [error, setError] = useState<string | null>(null)
const [reloadKey, setReloadKey] = useState(0)
const [reconcileKey, setReconcileKey] = useState(0)
const offsetRef = useRef(0)
const outputRef = useRef<HTMLPreElement>(null)
const complete = runComplete || pollComplete
useEffect(() => {
let cancelled = false
const controller = new AbortController()
const poll = async () => {
while (!cancelled) {
try {
const startOffset = offsetRef.current
const chunk = await getRunLogs(runId, startOffset, controller.signal)
if (cancelled) {
return
}
if (offsetRef.current !== startOffset) {
continue
}
if (chunk.content) {
setContent((current) => current + chunk.content)
}
offsetRef.current = chunk.next_offset
if (chunk.complete) {
setPollComplete(true)
return
}
await new Promise((resolve) => window.setTimeout(resolve, 600))
} catch (requestError) {
if (!cancelled) {
setError(
requestError instanceof Error
? requestError.message
: '读取日志失败',
)
}
return
}
}
}
void poll()
return () => {
cancelled = true
controller.abort()
}
}, [reconcileKey, reloadKey, runId])
useEffect(() => {
const onLog = (event: Event) => {
const payload = (event as CustomEvent<ExecutionEvent>).detail
if (
payload.event !== 'log' ||
payload.data.run_id !== runId
) {
return
}
if (payload.data.offset === offsetRef.current) {
if (payload.data.content) {
setContent((current) => current + payload.data.content)
}
offsetRef.current = payload.data.next_offset
} else if (payload.data.offset > offsetRef.current) {
setReconcileKey((value) => value + 1)
}
}
logEvents.addEventListener('log', onLog)
return () => logEvents.removeEventListener('log', onLog)
}, [logEvents, runId])
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, [content])
return (
<div className="log-viewer-wrap">
<div className="log-viewer-header">
<span>运行日志</span>
<span className={complete ? 'log-complete' : 'log-streaming'}>
{error
? '读取失败'
: complete
? '已完成'
: sseConnected
? 'SSE 实时连接'
: '轮询读取中'}
</span>
</div>
{error ? (
<div className="inline-alert inline-alert-error">
{error}
<button
type="button"
className="text-button"
onClick={() => {
setContent('')
setPollComplete(false)
setError(null)
offsetRef.current = 0
setReloadKey((value) => value + 1)
}}
>
重试
</button>
</div>
) : null}
<pre className="log-viewer" ref={outputRef} aria-live="polite">
{content || '等待日志输出...'}
</pre>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import type { RunStatus } from '../api/client'
import { runStatusLabels } from '../lib/format'
interface RunStatusBadgeProps {
status: RunStatus
}
export function RunStatusBadge({ status }: RunStatusBadgeProps) {
return (
<span className={`status-badge status-${status}`}>
<span className="status-dot" aria-hidden="true" />
{runStatusLabels[status]}
</span>
)
}
interface EnabledBadgeProps {
enabled: boolean
}
export function EnabledBadge({ enabled }: EnabledBadgeProps) {
return (
<span
className={`status-badge ${enabled ? 'status-enabled' : 'status-disabled'}`}
>
<span className="status-dot" aria-hidden="true" />
{enabled ? '活跃中' : '已暂停'}
</span>
)
}
+623
View File
@@ -0,0 +1,623 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
type FieldPath,
type UseFormRegister,
useFieldArray,
useForm,
} from 'react-hook-form'
import { ApiRequestError, previewCron, type Task } from '../api/client'
import {
createEmptyTaskFormValues,
mapServerFieldToFormField,
taskFormSchema,
taskToFormValues,
type TaskFormValues,
} from '../lib/taskForm'
import { useFocusTrap } from '../lib/useFocusTrap'
import { useDebouncedValue } from '../lib/useDebouncedValue'
interface TaskEditorModalProps {
open: boolean
task: Task | null
saving: boolean
onClose: () => void
onSubmit: (values: TaskFormValues) => Promise<void>
}
export function TaskEditorModal({
open,
task,
saving,
onClose,
onSubmit,
}: TaskEditorModalProps) {
const [submitError, setSubmitError] = useState<string | null>(null)
const [cronPreviewCount, setCronPreviewCount] = useState(5)
const dialogRef = useRef<HTMLElement>(null)
useFocusTrap(open, dialogRef, '[data-dialog-autofocus]')
const {
register,
control,
handleSubmit,
reset,
watch,
setError,
formState: { errors },
} = useForm<TaskFormValues>({
resolver: zodResolver(taskFormSchema),
defaultValues: createEmptyTaskFormValues(),
mode: 'onBlur',
})
const actionType = watch('actionType')
const cronValue = watch('cron')
const timezoneValue = watch('timezone')
const debouncedCron = useDebouncedValue(cronValue, 300)
const debouncedTimezone = useDebouncedValue(timezoneValue, 300)
const cronPreview = useQuery({
queryKey: [
'cron-preview',
debouncedCron,
debouncedTimezone,
cronPreviewCount,
],
queryFn: ({ signal }) =>
previewCron(
{
expression: debouncedCron,
timezone: debouncedTimezone,
count: cronPreviewCount,
},
signal,
),
enabled:
open &&
Boolean(debouncedCron.trim()) &&
Boolean(debouncedTimezone.trim()),
retry: false,
})
const scriptArgs = useFieldArray({ control, name: 'script.args' })
const scriptEnv = useFieldArray({ control, name: 'script.env' })
const httpHeaders = useFieldArray({ control, name: 'http.headers' })
const processArgs = useFieldArray({ control, name: 'process.args' })
const processEnv = useFieldArray({ control, name: 'process.env' })
useEffect(() => {
if (!open) {
return
}
reset(task ? taskToFormValues(task) : createEmptyTaskFormValues())
setCronPreviewCount(5)
setSubmitError(null)
}, [open, reset, task])
useEffect(() => {
if (!open) {
return
}
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !saving) {
onClose()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', onKeyDown)
}
}, [onClose, open, saving])
if (!open) {
return null
}
const submit = handleSubmit(async (values) => {
setSubmitError(null)
try {
await onSubmit(values)
} catch (error) {
if (error instanceof ApiRequestError && error.field) {
const formField = mapServerFieldToFormField(error.field, values.actionType)
if (formField) {
setError(formField as FieldPath<TaskFormValues>, {
type: 'server',
message: error.message,
})
return
}
}
setSubmitError(
error instanceof Error ? error.message : '保存任务时发生未知错误',
)
}
})
return (
<div
className="modal-overlay"
onMouseDown={(event) => {
if (event.currentTarget === event.target && !saving) {
onClose()
}
}}
>
<section
ref={dialogRef}
className="modal-card task-editor"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="task-editor-title"
>
<header className="modal-header">
<div>
<h2 id="task-editor-title">
{task ? `编辑任务 [${task.id}]` : '新建定时任务'}
</h2>
</div>
<button
type="button"
className="icon-button"
onClick={onClose}
disabled={saving}
aria-label="关闭任务编辑器"
>
×
</button>
</header>
<form className="modal-body" onSubmit={submit} noValidate>
{submitError ? (
<div className="inline-alert inline-alert-error" role="alert">
{submitError}
</div>
) : null}
<div className="form-grid">
<Field label="任务标识" error={errors.id?.message}>
<input
{...register('id')}
className="field-input mono"
placeholder="例如:sync_data"
disabled={Boolean(task)}
data-dialog-autofocus
aria-invalid={Boolean(errors.id)}
/>
</Field>
<Field label="任务名称" error={errors.name?.message}>
<input
{...register('name')}
className="field-input"
placeholder="业务可读名称"
aria-invalid={Boolean(errors.name)}
/>
</Field>
</div>
<div className="form-grid">
<div className="field">
<div className="cron-header-row">
<span className="field-label">Cron 表达式</span>
<div className="cron-n-selector">
<span>预览未来</span>
<select
value={cronPreviewCount}
onChange={(event) =>
setCronPreviewCount(Number(event.target.value))
}
aria-label="Cron 预览次数"
>
<option value="3">3 次</option>
<option value="5">5 次</option>
<option value="8">8 次</option>
<option value="10">10 次</option>
</select>
</div>
</div>
<input
{...register('cron')}
className="field-input mono"
placeholder="0 0 2 * * *"
aria-invalid={Boolean(errors.cron)}
/>
<div className="cron-preview-box" aria-live="polite">
{cronPreview.isFetching ? (
<span className="cron-preview-message">正在计算...</span>
) : cronPreview.isError ? (
<span className="cron-preview-error">
{cronPreview.error.message}
</span>
) : cronPreview.data?.runs.length ? (
cronPreview.data.runs.map((run, index) => (
<div className="cron-run-item" key={run}>
<span className="cron-run-index">#{index + 1}</span>
<span className="cron-run-time">{run}</span>
</div>
))
) : (
<span className="cron-preview-message">
等待输入有效 Cron 表达式...
</span>
)}
</div>
{errors.cron?.message ? (
<span className="field-error">{errors.cron.message}</span>
) : (
<span className="field-hint">
支持 5/6 位字段及 @daily、@hourly
</span>
)}
</div>
<Field label="超时时间(秒)" error={errors.timeout_secs?.message}>
<input
{...register('timeout_secs', { valueAsNumber: true })}
type="number"
min="1"
max="86400"
className="field-input mono"
aria-invalid={Boolean(errors.timeout_secs)}
/>
<span className="field-hint">
超时后将被 Windows Job Object 强制终止
</span>
</Field>
</div>
<div className="form-grid">
<Field label="IANA 时区" error={errors.timezone?.message}>
<input
{...register('timezone')}
className="field-input"
list="timezone-options"
placeholder="Asia/Shanghai"
aria-invalid={Boolean(errors.timezone)}
/>
<datalist id="timezone-options">
<option value="Asia/Shanghai" />
<option value="Asia/Hong_Kong" />
<option value="UTC" />
<option value="Europe/London" />
<option value="America/New_York" />
</datalist>
</Field>
<Field label="任务类型">
<select {...register('actionType')} className="field-input">
<option value="script">执行脚本</option>
<option value="http">HTTP 请求</option>
<option value="process">二进制进程</option>
</select>
</Field>
</div>
<label className="checkbox-field">
<input type="checkbox" {...register('enabled')} />
<span>
<strong>立即激活此任务调度</strong>
<small>保存后立即注册到 Cron 调度器</small>
</span>
</label>
{actionType === 'script' ? (
<fieldset className="action-panel">
<legend>脚本配置</legend>
<div className="form-grid">
<Field
label="工作目录"
error={errors.script?.working_dir?.message}
>
<input
{...register('script.working_dir')}
className="field-input mono"
placeholder="D:\Projects\sync"
/>
</Field>
<Field
label="解释器"
error={errors.script?.interpreter?.message}
>
<input
{...register('script.interpreter')}
className="field-input mono"
placeholder="python.exe"
/>
</Field>
</div>
<Field
label="脚本文件或相对路径"
error={errors.script?.script?.message}
>
<input
{...register('script.script')}
className="field-input mono"
placeholder="main.py 或 scripts\sync.py"
/>
</Field>
<div className="collection-header">
<div>
<strong>运行参数</strong>
<small>每行一个参数,不经过 Shell 拼接</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => scriptArgs.append({ value: '' })}
>
+ 添加参数
</button>
</div>
<div className="collection-list">
{scriptArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`script.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--mode"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => scriptArgs.remove(index)}
>
删除
</button>
</div>
))}
{scriptArgs.fields.length === 0 ? (
<p className="empty-inline">暂无运行参数</p>
) : null}
{errors.script?.args?.message ? (
<span className="field-error">
{errors.script.args.message}
</span>
) : null}
</div>
<KeyValueEditor
title="环境变量"
fields={scriptEnv.fields}
registerKey={register}
name="script.env"
error={errors.script?.env?.message}
onAdd={() => scriptEnv.append({ key: '', value: '' })}
onRemove={scriptEnv.remove}
/>
</fieldset>
) : null}
{actionType === 'http' ? (
<fieldset className="action-panel">
<legend>HTTP 配置</legend>
<div className="form-grid">
<Field label="请求 URL" error={errors.http?.url?.message}>
<input
{...register('http.url')}
className="field-input mono"
placeholder="https://api.example.com/health"
/>
</Field>
<Field label="请求方式">
<select {...register('http.method')} className="field-input">
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</Field>
</div>
<div className="form-grid">
<Field
label="期望状态码"
hint="使用逗号分隔,例如:200, 204"
error={errors.http?.expected_status?.message}
>
<input
{...register('http.expected_status')}
className="field-input mono"
placeholder="200"
/>
</Field>
<Field label="JSON Body" error={errors.http?.body?.message}>
<textarea
{...register('http.body')}
className="field-input field-textarea mono"
rows={4}
placeholder='{"trigger":"manual"}'
/>
</Field>
</div>
<KeyValueEditor
title="请求 Header"
fields={httpHeaders.fields}
registerKey={register}
name="http.headers"
error={errors.http?.headers?.message}
onAdd={() => httpHeaders.append({ key: '', value: '' })}
onRemove={httpHeaders.remove}
/>
</fieldset>
) : null}
{actionType === 'process' ? (
<fieldset className="action-panel">
<legend>进程配置</legend>
<div className="form-grid">
<Field
label="可执行程序"
error={errors.process?.executable?.message}
>
<input
{...register('process.executable')}
className="field-input mono"
placeholder="C:\Tools\exporter.exe"
/>
</Field>
<Field label="工作目录">
<input
{...register('process.working_dir')}
className="field-input mono"
placeholder="可选"
/>
</Field>
</div>
<div className="collection-header">
<div>
<strong>启动参数</strong>
<small>每行一个参数</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={() => processArgs.append({ value: '' })}
>
+ 添加参数
</button>
</div>
<div className="collection-list">
{processArgs.fields.map((field, index) => (
<div className="argument-row" key={field.id}>
<input
{...register(`process.args.${index}.value`)}
className="field-input mono"
placeholder="例如:--port"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => processArgs.remove(index)}
>
删除
</button>
</div>
))}
{processArgs.fields.length === 0 ? (
<p className="empty-inline">暂无启动参数</p>
) : null}
{errors.process?.args?.message ? (
<span className="field-error">
{errors.process.args.message}
</span>
) : null}
</div>
<KeyValueEditor
title="环境变量"
fields={processEnv.fields}
registerKey={register}
name="process.env"
error={errors.process?.env?.message}
onAdd={() => processEnv.append({ key: '', value: '' })}
onRemove={processEnv.remove}
/>
</fieldset>
) : null}
<footer className="form-actions">
<button
type="button"
className="button button-secondary"
onClick={onClose}
disabled={saving}
>
取消
</button>
<button
type="submit"
className="button button-primary"
disabled={saving}
>
{saving ? '保存中...' : task ? '保存修改' : '保存并热刷新'}
</button>
</footer>
</form>
</section>
</div>
)
}
interface FieldProps {
label: string
error?: string
hint?: string
children: ReactNode
}
function Field({ label, error, hint, children }: FieldProps) {
return (
<label className="field">
<span className="field-label">{label}</span>
{children}
{error ? <span className="field-error">{error}</span> : null}
{!error && hint ? <span className="field-hint">{hint}</span> : null}
</label>
)
}
interface KeyValueEditorProps {
title: string
name: 'script.env' | 'http.headers' | 'process.env'
fields: Array<{ id: string; key: string; value: string }>
registerKey: UseFormRegister<TaskFormValues>
onAdd: () => void
onRemove: (index: number) => void
error?: string
}
function KeyValueEditor({
title,
name,
fields,
registerKey,
onAdd,
onRemove,
error,
}: KeyValueEditorProps) {
return (
<div className="key-value-editor">
<div className="collection-header">
<div>
<strong>{title}</strong>
<small>名称和值会原样提交给后端</small>
</div>
<button
type="button"
className="button button-secondary button-small"
onClick={onAdd}
>
+ 添加
</button>
</div>
<div className="collection-list">
{fields.map((field, index) => (
<div className="key-value-row" key={field.id}>
<input
{...registerKey(
`${name}.${index}.key` as FieldPath<TaskFormValues>,
)}
className="field-input mono"
placeholder="名称"
/>
<input
{...registerKey(
`${name}.${index}.value` as FieldPath<TaskFormValues>,
)}
type="password"
autoComplete="off"
className="field-input mono"
placeholder="值"
/>
<button
type="button"
className="text-button danger-text"
onClick={() => onRemove(index)}
>
删除
</button>
</div>
))}
{fields.length === 0 ? <p className="empty-inline">暂无配置</p> : null}
{error ? <span className="field-error">{error}</span> : null}
</div>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
:root {
--bg-color: #f7f7f8;
--card-bg: #ffffff;
--text-main: #111827;
--text-muted: #8c939d;
--text-light: #b5b9c0;
--primary: #111827;
--primary-hover: #262e3d;
--border-color: #f0f1f3;
--badge-bg: #f3f4f6;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 18px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.02);
--shadow-card: 0 8px 30px rgba(0, 0, 0, 0.03);
--shadow-modal: 0 20px 40px rgba(15, 23, 42, 0.08);
--background: var(--bg-color);
--surface: var(--card-bg);
--surface-muted: #fafafa;
--text: var(--text-main);
--text-secondary: var(--text-muted);
--text-tertiary: #a4aab3;
--border: #eceef1;
--success: #10b981;
--success-bg: #eefbf5;
--danger: #f43f5e;
--danger-bg: #fff1f2;
--shadow: var(--shadow-card);
--font-sans:
'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', Consolas, 'Courier New', monospace;
color: var(--text);
background: var(--background);
font-family: var(--font-sans);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html {
min-width: 320px;
background: var(--background);
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
line-height: 1.5;
font-size: 14px;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
::selection {
background: #111827;
color: #fff;
}
:focus-visible {
outline: 3px solid rgba(17, 24, 39, 0.25);
outline-offset: 3px;
}
#root {
min-height: 100vh;
}
+59
View File
@@ -0,0 +1,59 @@
import type { RunStatus, TaskActionType } from '../api/client'
const dateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
export function formatDateTime(value: string | null | undefined): string {
if (!value) {
return '--'
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
}
export function formatDuration(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
}
if (value < 1_000) {
return `${value} ms`
}
if (value < 60_000) {
return `${(value / 1_000).toFixed(2)} s`
}
return `${(value / 60_000).toFixed(1)} min`
}
export function actionTypeLabel(type: TaskActionType): string {
return {
script: '脚本',
http: 'HTTP',
process: '进程',
}[type]
}
export const runStatusLabels: Record<RunStatus, string> = {
queued: '排队中',
running: '运行中',
succeeded: '成功',
failed: '失败',
timed_out: '已超时',
cancelled: '已取消',
skipped: '已跳过',
}
export function isTerminalRunStatus(status: RunStatus): boolean {
return [
'succeeded',
'failed',
'timed_out',
'cancelled',
'skipped',
].includes(status)
}
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
createEmptyTaskFormValues,
mapServerFieldToFormField,
valuesToCreateRequest,
} from './taskForm'
describe('task form payload', () => {
it('creates an HTTP action instead of forcing script type', () => {
const values = createEmptyTaskFormValues()
values.id = 'health_check'
values.name = 'Health check'
values.actionType = 'http'
values.http.url = 'https://example.com/health'
values.http.method = 'POST'
values.http.expected_status = '200, 204'
values.http.headers = [{ key: 'X-Test', value: 'yes' }]
values.http.body = '{"ping":true}'
const payload = valuesToCreateRequest(values)
expect(payload.action.type).toBe('http')
expect(payload.action).toMatchObject({
type: 'http',
url: 'https://example.com/health',
method: 'POST',
expected_status: [200, 204],
headers: { 'X-Test': 'yes' },
})
})
it('creates a process action with structured arguments', () => {
const values = createEmptyTaskFormValues()
values.id = 'worker'
values.name = 'Worker'
values.actionType = 'process'
values.process.executable = 'C:\\Tools\\worker.exe'
values.process.args = [{ value: '--port' }, { value: '8080' }]
values.process.env = [{ key: 'WORKER_MODE', value: 'local' }]
const payload = valuesToCreateRequest(values)
expect(payload.action).toEqual({
type: 'process',
executable: 'C:\\Tools\\worker.exe',
working_dir: null,
args: ['--port', '8080'],
env: { WORKER_MODE: 'local' },
})
})
it('maps backend validation fields to the current form section', () => {
expect(mapServerFieldToFormField('action.working_dir', 'script')).toBe(
'script.working_dir',
)
expect(mapServerFieldToFormField('action.url', 'http')).toBe('http.url')
expect(mapServerFieldToFormField('cron', 'script')).toBe('cron')
})
})
+333
View File
@@ -0,0 +1,333 @@
import { z } from 'zod'
import type {
CreateTaskRequest,
Task,
TaskAction,
TaskActionType,
UpdateTaskRequest,
} from '../api/client'
export interface KeyValueFormValue {
key: string
value: string
}
export interface ArgumentFormValue {
value: string
}
export interface TaskFormValues {
id: string
name: string
cron: string
timezone: string
timeout_secs: number
enabled: boolean
actionType: TaskActionType
script: {
working_dir: string
interpreter: string
script: string
args: ArgumentFormValue[]
env: KeyValueFormValue[]
}
http: {
url: string
method: 'GET' | 'POST'
body: string
expected_status: string
headers: KeyValueFormValue[]
}
process: {
executable: string
working_dir: string
args: ArgumentFormValue[]
env: KeyValueFormValue[]
}
}
const keyValueSchema = z.object({
key: z.string(),
value: z.string(),
})
function hasValidCronShape(expression: string): boolean {
const value = expression.trim()
if (['@daily', '@hourly'].includes(value)) {
return true
}
return [5, 6].includes(value.split(/\s+/).length)
}
export const taskFormSchema = z
.object({
id: z
.string()
.trim()
.min(1, '请输入任务标识')
.max(64, '任务标识不能超过 64 个字符')
.regex(
/^[A-Za-z0-9_-]+$/,
'只能使用英文字母、数字、下划线和连字符',
),
name: z
.string()
.trim()
.min(1, '请输入任务名称')
.max(120, '任务名称不能超过 120 个字符'),
cron: z
.string()
.trim()
.min(1, '请输入 Cron 表达式')
.refine(hasValidCronShape, 'Cron 必须是 5 位或 6 位字段'),
timezone: z.string().trim().min(1, '请输入 IANA 时区'),
timeout_secs: z
.number({ error: '请输入有效的超时时间' })
.int('超时时间必须是整数')
.min(1, '超时时间至少为 1 秒')
.max(86_400, '超时时间不能超过 86400 秒'),
enabled: z.boolean(),
actionType: z.enum(['script', 'http', 'process']),
script: z.object({
working_dir: z.string(),
interpreter: z.string(),
script: z.string(),
args: z.array(z.object({ value: z.string() })),
env: z.array(keyValueSchema),
}),
http: z.object({
url: z.string(),
method: z.enum(['GET', 'POST']),
body: z.string(),
expected_status: z.string(),
headers: z.array(keyValueSchema),
}),
process: z.object({
executable: z.string(),
working_dir: z.string(),
args: z.array(z.object({ value: z.string() })),
env: z.array(keyValueSchema),
}),
})
.superRefine((values, context) => {
if (values.actionType === 'script') {
if (!values.script.working_dir.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'working_dir'],
message: '请输入工作目录',
})
}
if (!values.script.interpreter.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'interpreter'],
message: '请输入解释器或可执行程序',
})
}
if (!values.script.script.trim()) {
context.addIssue({
code: 'custom',
path: ['script', 'script'],
message: '请输入脚本文件名或路径',
})
}
}
if (values.actionType === 'http') {
try {
const url = new URL(values.http.url)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('unsupported protocol')
}
} catch {
context.addIssue({
code: 'custom',
path: ['http', 'url'],
message: '请输入有效的 HTTP 或 HTTPS URL',
})
}
}
if (
values.actionType === 'process' &&
!values.process.executable.trim()
) {
context.addIssue({
code: 'custom',
path: ['process', 'executable'],
message: '请输入可执行程序路径',
})
}
})
export function createEmptyTaskFormValues(): TaskFormValues {
return {
id: '',
name: '',
cron: '0 0 2 * * *',
timezone: 'Asia/Shanghai',
timeout_secs: 300,
enabled: true,
actionType: 'script',
script: {
working_dir: '',
interpreter: 'python.exe',
script: 'main.py',
args: [],
env: [],
},
http: {
url: '',
method: 'GET',
body: '',
expected_status: '200',
headers: [],
},
process: {
executable: '',
working_dir: '',
args: [],
env: [],
},
}
}
export function taskToFormValues(task: Task): TaskFormValues {
const values = createEmptyTaskFormValues()
values.id = task.id
values.name = task.name
values.cron = task.cron
values.timezone = task.timezone
values.timeout_secs = task.timeout_secs
values.enabled = task.enabled
values.actionType = task.action.type
if (task.action.type === 'script') {
values.script = {
working_dir: task.action.working_dir,
interpreter: task.action.interpreter,
script: task.action.script,
args: (task.action.args ?? []).map((value) => ({ value })),
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key,
value,
})),
}
} else if (task.action.type === 'http') {
values.http = {
url: task.action.url,
method: task.action.method,
body: task.action.body ?? '',
expected_status: (task.action.expected_status ?? []).join(', '),
headers: Object.entries(task.action.headers ?? {}).map(
([key, value]) => ({ key, value }),
),
}
} else {
values.process = {
executable: task.action.executable,
working_dir: task.action.working_dir ?? '',
args: (task.action.args ?? []).map((value) => ({ value })),
env: Object.entries(task.action.env ?? {}).map(([key, value]) => ({
key,
value,
})),
}
}
return values
}
export function valuesToAction(values: TaskFormValues): TaskAction {
if (values.actionType === 'script') {
return {
type: 'script',
working_dir: values.script.working_dir.trim(),
interpreter: values.script.interpreter.trim(),
script: values.script.script.trim(),
args: cleanList(values.script.args.map((argument) => argument.value)),
env: keyValuesToObject(values.script.env),
}
}
if (values.actionType === 'http') {
const expectedStatus = values.http.expected_status
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isInteger(value) && value >= 100 && value <= 599)
return {
type: 'http',
url: values.http.url.trim(),
method: values.http.method,
body: values.http.body.trim() || null,
expected_status: expectedStatus,
headers: keyValuesToObject(values.http.headers),
}
}
return {
type: 'process',
executable: values.process.executable.trim(),
working_dir: values.process.working_dir.trim() || null,
args: cleanList(values.process.args.map((argument) => argument.value)),
env: keyValuesToObject(values.process.env),
}
}
export function valuesToCreateRequest(
values: TaskFormValues,
): CreateTaskRequest {
return {
id: values.id.trim(),
...valuesToUpdateRequest(values),
}
}
export function valuesToUpdateRequest(
values: TaskFormValues,
): UpdateTaskRequest {
return {
name: values.name.trim(),
cron: values.cron.trim(),
timezone: values.timezone.trim(),
timeout_secs: values.timeout_secs,
enabled: values.enabled,
concurrency_policy: 'skip',
retry_policy: {
max_attempts: 0,
delay_secs: 0,
},
action: valuesToAction(values),
}
}
export function mapServerFieldToFormField(
field: string | null | undefined,
actionType: TaskActionType,
): string | null {
if (!field) {
return null
}
if (!field.startsWith('action.')) {
return field
}
const actionField = field.slice('action.'.length)
return `${actionType}.${actionField}`
}
function cleanList(values: string[]): string[] {
return values.map((value) => value.trim()).filter(Boolean)
}
function keyValuesToObject(
values: KeyValueFormValue[],
): Record<string, string> {
return Object.fromEntries(
values
.map(({ key, value }) => [key.trim(), value] as const)
.filter(([key]) => key),
)
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect, useState } from 'react'
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = window.setTimeout(() => setDebounced(value), delayMs)
return () => window.clearTimeout(timer)
}, [delayMs, value])
return debounced
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, type RefObject } from 'react'
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export function useFocusTrap(
open: boolean,
containerRef: RefObject<HTMLElement | null>,
initialFocusSelector?: string,
) {
useEffect(() => {
if (!open || !containerRef.current) {
return
}
const container = containerRef.current
const previousFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null
const focusFirst = () => {
const initial = initialFocusSelector
? container.querySelector<HTMLElement>(initialFocusSelector)
: null
const first =
initial ?? container.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
;(first ?? container).focus()
}
const frame = window.requestAnimationFrame(focusFirst)
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return
}
const focusable = Array.from(
container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((element) => element.offsetParent !== null)
if (focusable.length === 0) {
event.preventDefault()
container.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
previousFocus?.focus()
}
}, [containerRef, initialFocusSelector, open])
}
+26
View File
@@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import './index.css'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 15_000,
},
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)
+577
View File
@@ -0,0 +1,577 @@
import {
keepPreviousData,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
createTask,
deleteTask,
fetchHealth,
listTasks,
runTask,
setTaskEnabled,
updateTask,
type Task,
type TaskActionType,
type TaskFilters,
} from '../api/client'
import { ConfirmDialog } from '../components/ConfirmDialog'
import { RunLogDrawer } from '../components/RunLogDrawer'
import {
EnabledBadge,
} from '../components/StatusBadge'
import { TaskEditorModal } from '../components/TaskEditorModal'
import { actionTypeLabel } from '../lib/format'
import {
type TaskFormValues,
valuesToCreateRequest,
valuesToUpdateRequest,
} from '../lib/taskForm'
const PAGE_SIZE = 20
export function TaskDashboard() {
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const q = searchParams.get('q') ?? ''
const enabledValue = searchParams.get('enabled')
const actionTypeValue = searchParams.get('action_type')
const page = Math.max(Number(searchParams.get('page') ?? 1) || 1, 1)
const [editorOpen, setEditorOpen] = useState(false)
const [editingTask, setEditingTask] = useState<Task | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Task | null>(null)
const [logTask, setLogTask] = useState<Task | null>(null)
const [initialRunId, setInitialRunId] = useState<number | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const enabled: boolean | undefined =
enabledValue === 'true'
? true
: enabledValue === 'false'
? false
: undefined
const actionType = isActionType(actionTypeValue)
? actionTypeValue
: undefined
const filters: TaskFilters = {
q: q || undefined,
enabled,
action_type: actionType,
page,
page_size: PAGE_SIZE,
}
const healthQuery = useQuery({
queryKey: ['health'],
queryFn: ({ signal }) => fetchHealth(signal),
refetchInterval: 30_000,
})
const tasksQuery = useQuery({
queryKey: ['tasks', 'list', filters],
queryFn: ({ signal }) => listTasks(filters, signal),
placeholderData: keepPreviousData,
})
const totalTasksQuery = useQuery({
queryKey: ['tasks', 'summary', 'total'],
queryFn: ({ signal }) => listTasks({ page: 1, page_size: 1 }, signal),
})
const enabledTasksQuery = useQuery({
queryKey: ['tasks', 'summary', 'enabled'],
queryFn: ({ signal }) =>
listTasks({ enabled: true, page: 1, page_size: 1 }, signal),
})
const scriptTasksQuery = useQuery({
queryKey: ['tasks', 'summary', 'script'],
queryFn: ({ signal }) =>
listTasks(
{ action_type: 'script', page: 1, page_size: 1 },
signal,
),
})
const saveMutation = useMutation({
mutationFn: ({
task,
values,
}: {
task: Task | null
values: TaskFormValues
}) =>
task
? updateTask(task.id, valuesToUpdateRequest(values))
: createTask(valuesToCreateRequest(values)),
onSuccess: (task, variables) => {
setEditorOpen(false)
setEditingTask(null)
showNotice(
variables.task ? `任务 ${task.id} 已更新` : `任务 ${task.id} 已创建`,
)
void invalidateTaskData(queryClient)
},
})
const toggleMutation = useMutation({
mutationFn: (task: Task) => setTaskEnabled(task.id, !task.enabled),
onSuccess: (task) => {
showNotice(task.enabled ? `已启用 ${task.id}` : `已停用 ${task.id}`)
void invalidateTaskData(queryClient)
},
onError: (error) => setActionError(error.message),
})
const deleteMutation = useMutation({
mutationFn: (task: Task) => deleteTask(task.id),
onSuccess: (_data, task) => {
setDeleteTarget(null)
showNotice(`任务 ${task.id} 已删除`)
void invalidateTaskData(queryClient)
},
onError: (error) => setActionError(error.message),
})
const runMutation = useMutation({
mutationFn: (task: Task) => runTask(task.id),
onSuccess: (run, task) => {
setLogTask(task)
setInitialRunId(run.id)
setActionError(null)
showNotice(
run.status === 'skipped'
? `任务 ${task.id} 已有运行实例,本次触发已跳过`
: `任务 ${task.id} 已提交运行`,
)
void queryClient.invalidateQueries({
queryKey: ['task-runs', task.id],
})
},
onError: (error) => setActionError(error.message),
})
useEffect(() => {
if (!notice) {
return
}
const timer = window.setTimeout(() => setNotice(null), 4_000)
return () => window.clearTimeout(timer)
}, [notice])
const tasks = tasksQuery.data?.items ?? []
const total = tasksQuery.data?.total ?? 0
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const updateFilters = (changes: Record<string, string | undefined>) => {
const next = new URLSearchParams(searchParams)
for (const [key, value] of Object.entries(changes)) {
if (value) {
next.set(key, value)
} else {
next.delete(key)
}
}
setSearchParams(next)
}
const openCreate = () => {
setEditingTask(null)
setEditorOpen(true)
}
const openEdit = (task: Task) => {
setEditingTask(task)
setEditorOpen(true)
}
const refreshAll = () => {
void queryClient.invalidateQueries({ queryKey: ['tasks'] })
void queryClient.invalidateQueries({ queryKey: ['health'] })
}
const showNotice = (message: string) => {
setNotice(message)
}
return (
<main className="app-shell">
<header className="dashboard-header">
<div className="header-title">
<h1>TaskPilot</h1>
<p className="subtitle">
Rust 本地轻量化任务引擎 · Windows 原生调度中心
</p>
</div>
<div className="header-actions">
<button
type="button"
className="button button-primary"
onClick={openCreate}
>
+ 新建任务
</button>
</div>
</header>
{notice ? (
<div className="toast-message" role="status">
{notice}
</div>
) : null}
{actionError ? (
<div className="inline-alert inline-alert-error page-alert" role="alert">
<span>{actionError}</span>
<button
type="button"
className="text-button"
onClick={() => setActionError(null)}
>
关闭
</button>
</div>
) : null}
<section className="metrics-grid" aria-label="任务统计">
<MetricCard
label="已托管任务"
value={totalTasksQuery.data?.total}
loading={totalTasksQuery.isPending}
/>
<MetricCard
label="正常运行中"
value={enabledTasksQuery.data?.total}
loading={enabledTasksQuery.isPending}
/>
<MetricCard
label="可用脚本"
value={scriptTasksQuery.data?.total}
loading={scriptTasksQuery.isPending}
/>
<article className="metric-card">
<span>Rust 引擎</span>
<strong className={healthQuery.isError ? 'metric-danger' : ''}>
{healthQuery.isPending
? '...'
: healthQuery.isError
? '离线'
: '在线'}
</strong>
</article>
</section>
<section className="card task-card">
<div className="toolbar">
<form
className="search-form"
onSubmit={(event) => {
event.preventDefault()
const value = String(
new FormData(event.currentTarget).get('q') ?? '',
).trim()
updateFilters({ q: value || undefined, page: '1' })
}}
>
<label className="visually-hidden" htmlFor="task-search">
搜索任务
</label>
<input
id="task-search"
key={q}
name="q"
className="field-input search-input"
defaultValue={q}
placeholder="搜索任务名称或 ID"
/>
<button type="submit" className="button button-secondary">
搜索
</button>
</form>
<div className="filter-row">
<label>
<span className="visually-hidden">启用状态</span>
<select
className="field-input compact-select"
value={enabledValue ?? ''}
onChange={(event) =>
updateFilters({
enabled: event.target.value || undefined,
page: '1',
})
}
>
<option value="">全部状态</option>
<option value="true">已启用</option>
<option value="false">已停用</option>
</select>
</label>
<label>
<span className="visually-hidden">任务类型</span>
<select
className="field-input compact-select"
value={actionTypeValue ?? ''}
onChange={(event) =>
updateFilters({
action_type: event.target.value || undefined,
page: '1',
})
}
>
<option value="">全部类型</option>
<option value="script">脚本</option>
<option value="http">HTTP</option>
<option value="process">进程</option>
</select>
</label>
{(q || enabledValue || actionTypeValue) && (
<button
type="button"
className="text-button"
onClick={() => {
setSearchParams({})
}}
>
清除筛选
</button>
)}
<button
type="button"
className="text-button"
onClick={refreshAll}
disabled={tasksQuery.isFetching}
>
{tasksQuery.isFetching ? '同步中...' : '刷新'}
</button>
</div>
</div>
{tasksQuery.isError ? (
<div className="state-panel state-error">
<h2>任务列表加载失败</h2>
<p>{tasksQuery.error.message}</p>
<button
type="button"
className="button button-secondary"
onClick={() => void tasksQuery.refetch()}
>
重试
</button>
</div>
) : tasksQuery.isPending ? (
<div className="state-panel">
<span className="loading-mark" aria-hidden="true" />
<p>正在读取任务...</p>
</div>
) : tasks.length === 0 ? (
<div className="state-panel">
<p className="section-label">EMPTY</p>
<h2>{q || enabledValue || actionTypeValue ? '没有匹配任务' : '还没有任务'}</h2>
<p>
{q || enabledValue || actionTypeValue
? '请调整搜索条件后重试。'
: '创建第一个定时任务,开始使用真实调度能力。'}
</p>
{!q && !enabledValue && !actionTypeValue ? (
<button
type="button"
className="button button-primary"
onClick={openCreate}
>
新建任务
</button>
) : null}
</div>
) : (
<>
<div className="table-container">
<table className="task-table">
<thead>
<tr>
<th>名称 / 任务标识</th>
<th>调度周期</th>
<th>类型</th>
<th>状态</th>
<th>超时配置</th>
<th className="align-right">操作</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => {
const isRunning =
runMutation.isPending &&
runMutation.variables?.id === task.id
return (
<tr key={task.id}>
<td>
<div className="task-name">{task.name}</div>
<div className="task-id mono">{task.id}</div>
</td>
<td>
<code>{task.cron}</code>
<div className="cell-note">{task.timezone}</div>
</td>
<td>
<span className="type-badge">
{actionTypeLabel(task.action.type)}
</span>
<div className="cell-note">
超时 {task.timeout_secs}s
</div>
</td>
<td>
<EnabledBadge enabled={task.enabled} />
</td>
<td className="cell-note">超时 {task.timeout_secs}s</td>
<td>
<div className="row-actions">
<button
type="button"
className="button button-secondary button-small"
disabled={isRunning}
title="立即运行"
onClick={() => runMutation.mutate(task)}
>
{isRunning ? '提交中' : '运行'}
</button>
<button
type="button"
className="button button-secondary button-small"
onClick={() => {
setLogTask(task)
setInitialRunId(null)
}}
>
日志
</button>
<button
type="button"
className="button button-secondary button-small"
onClick={() => openEdit(task)}
>
编辑
</button>
<button
type="button"
className="button button-secondary button-small"
disabled={
toggleMutation.isPending &&
toggleMutation.variables?.id === task.id
}
onClick={() => toggleMutation.mutate(task)}
>
{task.enabled ? '停用' : '启用'}
</button>
<button
type="button"
className="button button-ghost button-small danger-text"
onClick={() => setDeleteTarget(task)}
>
删除
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
<footer className="pagination">
<span>
共 {total} 个任务,第 {page} / {totalPages} 页
</span>
<div>
<button
type="button"
className="button button-secondary button-small"
disabled={page <= 1}
onClick={() =>
updateFilters({ page: String(Math.max(1, page - 1)) })
}
>
上一页
</button>
<button
type="button"
className="button button-secondary button-small"
disabled={page >= totalPages}
onClick={() =>
updateFilters({ page: String(Math.min(totalPages, page + 1)) })
}
>
下一页
</button>
</div>
</footer>
</>
)}
</section>
<TaskEditorModal
open={editorOpen}
task={editingTask}
saving={saveMutation.isPending}
onClose={() => {
if (!saveMutation.isPending) {
setEditorOpen(false)
}
}}
onSubmit={async (values) => {
await saveMutation.mutateAsync({ task: editingTask, values })
}}
/>
<RunLogDrawer
key={`${logTask?.id ?? 'closed'}-${initialRunId ?? 'latest'}`}
open={Boolean(logTask)}
task={logTask}
initialRunId={initialRunId}
onClose={() => {
setLogTask(null)
setInitialRunId(null)
}}
/>
<ConfirmDialog
open={Boolean(deleteTarget)}
title={`删除任务 ${deleteTarget?.id ?? ''}`}
description="任务配置和运行历史将被删除,当前运行也会被取消。此操作不能撤销。"
busy={deleteMutation.isPending}
onCancel={() => {
if (!deleteMutation.isPending) {
setDeleteTarget(null)
}
}}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget)
}
}}
/>
</main>
)
}
interface MetricCardProps {
label: string
value: number | undefined
loading: boolean
}
function MetricCard({ label, value, loading }: MetricCardProps) {
return (
<article className="metric-card">
<span>{label}</span>
<strong>{loading ? '...' : (value ?? '--')}</strong>
</article>
)
}
function isActionType(value: string | null): value is TaskActionType {
return value === 'script' || value === 'http' || value === 'process'
}
async function invalidateTaskData(queryClient: ReturnType<typeof useQueryClient>) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
queryClient.invalidateQueries({ queryKey: ['task-runs'] }),
])
}
+8
View File
@@ -0,0 +1,8 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})