feat: cron job run history panel and job model display (#319)
- Jobs page: cron run history panel with job selection and filtering - Jobs page: model shown as read-only on job cards - Job form modal: properly typed payloads - i18n: added runHistory, model keys to all 8 locales
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import type { Context } from 'koa'
|
||||
import { readdir, stat, readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { existsSync } from 'fs'
|
||||
|
||||
const HERMES_BASE = join(homedir(), '.hermes')
|
||||
const CRON_OUTPUT = join(HERMES_BASE, 'cron', 'output')
|
||||
|
||||
export interface RunEntry {
|
||||
jobId: string
|
||||
fileName: string
|
||||
runTime: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface RunDetail {
|
||||
jobId: string
|
||||
fileName: string
|
||||
runTime: string
|
||||
content: string
|
||||
}
|
||||
|
||||
/** List all run output files, optionally filtered by job ID */
|
||||
export async function listRuns(ctx: Context) {
|
||||
const jobId = ctx.query.jobId as string | undefined
|
||||
|
||||
if (!existsSync(CRON_OUTPUT)) {
|
||||
ctx.body = { runs: [] }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const dirs = await readdir(CRON_OUTPUT)
|
||||
const runs: RunEntry[] = []
|
||||
|
||||
const targetDirs = jobId ? dirs.filter(d => d === jobId) : dirs
|
||||
|
||||
for (const dir of targetDirs) {
|
||||
const dirPath = join(CRON_OUTPUT, dir)
|
||||
try {
|
||||
const dirStat = await stat(dirPath)
|
||||
if (!dirStat.isDirectory()) continue
|
||||
|
||||
const files = await readdir(dirPath)
|
||||
// Sort by filename descending (newest first, since filenames are timestamps)
|
||||
const sorted = files.sort().reverse()
|
||||
|
||||
for (const file of sorted) {
|
||||
if (!file.endsWith('.md')) continue
|
||||
const filePath = join(dirPath, file)
|
||||
try {
|
||||
const fileStat = await stat(filePath)
|
||||
// Parse run time from filename: 2026-04-18_12-01-40.md → 2026-04-18 12:01:40
|
||||
const match = file.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.md$/)
|
||||
const runTime = match ? `${match[1]} ${match[2].replace(/-/g, ':')}` : file
|
||||
|
||||
runs.push({
|
||||
jobId: dir,
|
||||
fileName: file,
|
||||
runTime,
|
||||
size: fileStat.size,
|
||||
})
|
||||
} catch { /* skip unreadable files */ }
|
||||
}
|
||||
} catch { /* skip unreadable dirs */ }
|
||||
}
|
||||
|
||||
// Sort all runs by runTime descending
|
||||
runs.sort((a, b) => b.runTime.localeCompare(a.runTime))
|
||||
|
||||
ctx.body = { runs }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a specific run output file */
|
||||
export async function readRun(ctx: Context) {
|
||||
const { jobId, fileName } = ctx.params
|
||||
|
||||
if (!jobId || !fileName) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'jobId and fileName are required' }
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
if (jobId.includes('..') || fileName.includes('..') || jobId.includes('/') || fileName.includes('/')) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Invalid path' }
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = join(CRON_OUTPUT, jobId, fileName)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Run output not found' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf-8')
|
||||
const match = fileName.match(/^(\d{4}-\d{2}-\d{2})_(\d{2}-\d{2}-\d{2})\.md$/)
|
||||
const runTime = match ? `${match[1]} ${match[2].replace(/-/g, ':')}` : fileName
|
||||
|
||||
ctx.body = { jobId, fileName, runTime, content } satisfies RunDetail
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/cron-history'
|
||||
|
||||
export const cronHistoryRoutes = new Router()
|
||||
|
||||
cronHistoryRoutes.get('/api/cron-history', ctrl.listRuns)
|
||||
cronHistoryRoutes.get('/api/cron-history/:jobId/:fileName', ctrl.readRun)
|
||||
@@ -24,6 +24,7 @@ import { weixinRoutes } from './hermes/weixin'
|
||||
import { fileRoutes } from './hermes/files'
|
||||
import { downloadRoutes } from './hermes/download'
|
||||
import { jobRoutes } from './hermes/jobs'
|
||||
import { cronHistoryRoutes } from './hermes/cron-history'
|
||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
|
||||
|
||||
@@ -62,6 +63,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
|
||||
app.use(downloadRoutes.routes()) // Must be before proxy
|
||||
app.use(jobRoutes.routes()) // Must be before proxy
|
||||
app.use(cronHistoryRoutes.routes()) // Must be before proxy
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// Proxy catch-all middleware (must be last)
|
||||
|
||||
Reference in New Issue
Block a user