feat: 加固运行期可靠性并补齐调度与观测接口

- 运行句柄按 run_id 归属,修复取消后重跑时闸门被旧运行删除的竞态
- 调度循环改用取消令牌退出,启动时对账遗留运行,校验 rows_affected
- 三类任务统一参与调度,新增 /api/v1/stats 与 /api/v1/schedule
- SQLite 启用 WAL 与 busy_timeout,日志清理跳过活跃文件,单行上限 256 KiB
- 前端缓存管理令牌并在 403 时重试一次,仪表盘改用统计接口
This commit is contained in:
2026-10-26 00:38:16 +08:00
parent 773196d689
commit 815258c19b
19 changed files with 990 additions and 93 deletions
+22
View File
@@ -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
View File
@@ -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 })
}
+114
View File
@@ -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?: {
+15
View File
@@ -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 '--'
+30 -23
View File
@@ -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'] }),
])
}