feat: 加固运行期可靠性并补齐调度与观测接口
- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态 - 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected - 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule - SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB - 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
This commit is contained in:
@@ -68,6 +68,21 @@ function createFetchMock(options: FetchMockOptions = {}) {
|
||||
if (url.endsWith('/api/v1/session')) {
|
||||
return jsonResponse({ token: 'test-token' })
|
||||
}
|
||||
if (url.endsWith('/api/v1/stats')) {
|
||||
return jsonResponse({
|
||||
total: 1,
|
||||
enabled: 1,
|
||||
running: 0,
|
||||
script: 1,
|
||||
http: 0,
|
||||
process: 0,
|
||||
})
|
||||
}
|
||||
if (url.endsWith('/api/v1/schedule')) {
|
||||
return jsonResponse([
|
||||
{ task_id: 'echo_task', next_run_at: '2026-10-19T02:00:00Z' },
|
||||
])
|
||||
}
|
||||
if (url.endsWith('/api/v1/cron/preview')) {
|
||||
return jsonResponse({
|
||||
timezone: 'Asia/Shanghai',
|
||||
@@ -152,6 +167,13 @@ describe('TaskDashboard', () => {
|
||||
expect(await screen.findByText('在线')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the next run time reported by the schedule endpoint', async () => {
|
||||
vi.stubGlobal('fetch', createFetchMock())
|
||||
renderApp()
|
||||
|
||||
expect(await screen.findByText(/下次/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a script task with the visible form values', async () => {
|
||||
const fetchMock = createFetchMock()
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
+56
-18
@@ -24,6 +24,8 @@ export type InterpreterKind = components['schemas']['InterpreterKind']
|
||||
export type DirectoryEntry = components['schemas']['DirectoryEntry']
|
||||
export type DirectoryListingResponse =
|
||||
components['schemas']['DirectoryListingResponse']
|
||||
export type TaskStats = components['schemas']['TaskStatsResponse']
|
||||
export type ScheduledTask = components['schemas']['ScheduledTaskResponse']
|
||||
|
||||
export interface TaskFilters {
|
||||
q?: string
|
||||
@@ -77,20 +79,27 @@ 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 mutating = !['GET', 'HEAD', 'OPTIONS'].includes(method)
|
||||
|
||||
const send = async (): Promise<Response> => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (init.body) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
}
|
||||
if (mutating) {
|
||||
headers.set('X-TaskPilot-Token', await fetchSessionToken())
|
||||
}
|
||||
return fetch(path, { ...init, headers })
|
||||
}
|
||||
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
let response = await send()
|
||||
if (mutating && response.status === 403) {
|
||||
// 后端重启会轮换管理令牌:丢弃缓存后重试一次。
|
||||
invalidateSessionToken()
|
||||
response = await send()
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: ErrorResponse | undefined
|
||||
@@ -108,15 +117,34 @@ async function requestJson<T>(
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
let cachedSessionToken: string | null = null
|
||||
let pendingSessionToken: Promise<string> | null = null
|
||||
|
||||
export function invalidateSessionToken(): void {
|
||||
cachedSessionToken = null
|
||||
}
|
||||
|
||||
async function fetchSessionToken(): Promise<string> {
|
||||
const response = await fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
if (cachedSessionToken) {
|
||||
return cachedSessionToken
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
return session.token
|
||||
if (!pendingSessionToken) {
|
||||
pendingSessionToken = fetch('/api/v1/session', {
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new ApiRequestError(response.status)
|
||||
}
|
||||
const session = (await response.json()) as SessionResponse
|
||||
cachedSessionToken = session.token
|
||||
return session.token
|
||||
})
|
||||
.finally(() => {
|
||||
pendingSessionToken = null
|
||||
})
|
||||
}
|
||||
return pendingSessionToken
|
||||
}
|
||||
|
||||
function withQuery(path: string, values: object): string {
|
||||
@@ -265,3 +293,13 @@ export function listDirectories(
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
/** 仪表盘计数:一次请求拿到全部指标。 */
|
||||
export function fetchStats(signal?: AbortSignal): Promise<TaskStats> {
|
||||
return requestJson<TaskStats>('/api/v1/stats', { signal })
|
||||
}
|
||||
|
||||
/** 已启用任务的下次触发时间。 */
|
||||
export function fetchSchedule(signal?: AbortSignal): Promise<ScheduledTask[]> {
|
||||
return requestJson<ScheduledTask[]>('/api/v1/schedule', { signal })
|
||||
}
|
||||
|
||||
@@ -132,6 +132,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/schedule": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["list_schedule"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/session": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -148,6 +164,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/stats": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["task_stats"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/system/directories": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -338,6 +370,15 @@ export interface components {
|
||||
RunStatus: "queued" | "running" | "succeeded" | "failed" | "timed_out" | "cancelled" | "skipped";
|
||||
/** @enum {string} */
|
||||
RunTrigger: "schedule" | "manual" | "retry";
|
||||
/** @description 已注册调度的任务及其下次触发时间。 */
|
||||
ScheduledTaskResponse: {
|
||||
/**
|
||||
* Format: date-time
|
||||
* @description 下次触发时间;调度正在重建时可能为空
|
||||
*/
|
||||
next_run_at?: string | null;
|
||||
task_id: string;
|
||||
};
|
||||
SessionResponse: {
|
||||
/** @description Random per-process token required for mutating browser requests. */
|
||||
token: string;
|
||||
@@ -427,6 +468,39 @@ export interface components {
|
||||
task_id: string;
|
||||
trigger: components["schemas"]["RunTrigger"];
|
||||
};
|
||||
/** @description 仪表盘指标:一次请求拿到全部计数,避免为每个指标各发一次列表查询。 */
|
||||
TaskStatsResponse: {
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 已启用任务数
|
||||
*/
|
||||
enabled: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description HTTP 类任务数
|
||||
*/
|
||||
http: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 进程类任务数
|
||||
*/
|
||||
process: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 当前正在执行的任务数
|
||||
*/
|
||||
running: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 脚本类任务数
|
||||
*/
|
||||
script: number;
|
||||
/**
|
||||
* Format: int64
|
||||
* @description 任务总数
|
||||
*/
|
||||
total: number;
|
||||
};
|
||||
UpdateTaskRequest: {
|
||||
action: components["schemas"]["TaskAction"];
|
||||
concurrency_policy?: components["schemas"]["ConcurrencyPolicy"];
|
||||
@@ -718,6 +792,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_schedule: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Next trigger time per enabled task */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ScheduledTaskResponse"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
session: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -738,6 +832,26 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
task_stats: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Dashboard counters */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TaskStatsResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_directories: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -17,6 +17,21 @@ export function formatDateTime(value: string | null | undefined): string {
|
||||
return Number.isNaN(date.getTime()) ? '--' : dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
const shortDateTimeFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export function formatShortDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '--'
|
||||
}
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '--' : shortDateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
export function formatDuration(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined) {
|
||||
return '--'
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
createTask,
|
||||
deleteTask,
|
||||
fetchHealth,
|
||||
fetchSchedule,
|
||||
fetchStats,
|
||||
listTasks,
|
||||
runTask,
|
||||
setTaskEnabled,
|
||||
@@ -24,7 +26,7 @@ import {
|
||||
EnabledBadge,
|
||||
} from '../components/StatusBadge'
|
||||
import { TaskEditorModal } from '../components/TaskEditorModal'
|
||||
import { actionTypeLabel } from '../lib/format'
|
||||
import { actionTypeLabel, formatShortDateTime } from '../lib/format'
|
||||
import {
|
||||
type TaskFormValues,
|
||||
valuesToCreateRequest,
|
||||
@@ -75,23 +77,19 @@ export function TaskDashboard() {
|
||||
queryFn: ({ signal }) => listTasks(filters, signal),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const totalTasksQuery = useQuery({
|
||||
queryKey: ['tasks', 'summary', 'total'],
|
||||
queryFn: ({ signal }) => listTasks({ page: 1, page_size: 1 }, signal),
|
||||
const statsQuery = useQuery({
|
||||
queryKey: ['stats'],
|
||||
queryFn: ({ signal }) => fetchStats(signal),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
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 scheduleQuery = useQuery({
|
||||
queryKey: ['schedule'],
|
||||
queryFn: ({ signal }) => fetchSchedule(signal),
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const nextRuns = new Map(
|
||||
(scheduleQuery.data ?? []).map((entry) => [entry.task_id, entry.next_run_at]),
|
||||
)
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
@@ -232,18 +230,18 @@ export function TaskDashboard() {
|
||||
<section className="metrics-grid" aria-label="任务统计">
|
||||
<MetricCard
|
||||
label="已托管任务"
|
||||
value={totalTasksQuery.data?.total}
|
||||
loading={totalTasksQuery.isPending}
|
||||
value={statsQuery.data?.total}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="正常运行中"
|
||||
value={enabledTasksQuery.data?.total}
|
||||
loading={enabledTasksQuery.isPending}
|
||||
label="运行中任务"
|
||||
value={statsQuery.data?.running}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<MetricCard
|
||||
label="可用脚本"
|
||||
value={scriptTasksQuery.data?.total}
|
||||
loading={scriptTasksQuery.isPending}
|
||||
value={statsQuery.data?.script}
|
||||
loading={statsQuery.isPending}
|
||||
/>
|
||||
<article className="metric-card">
|
||||
<span>Rust 引擎</span>
|
||||
@@ -407,6 +405,13 @@ export function TaskDashboard() {
|
||||
<td>
|
||||
<code>{task.cron}</code>
|
||||
<div className="cell-note">{task.timezone}</div>
|
||||
<div className="cell-note">
|
||||
{task.enabled
|
||||
? nextRuns.get(task.id)
|
||||
? `下次 ${formatShortDateTime(nextRuns.get(task.id))}`
|
||||
: '调度中…'
|
||||
: '未调度'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="type-badge">
|
||||
@@ -573,5 +578,7 @@ async function invalidateTaskData(queryClient: ReturnType<typeof useQueryClient>
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['task-runs'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['stats'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['schedule'] }),
|
||||
])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user