feat: implement full-stack TaskPilot scheduler
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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'] }),
|
||||
])
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
Reference in New Issue
Block a user