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:
Desmond Zhang
2026-04-30 10:17:25 +10:00
committed by GitHub
parent 6e5f15fd66
commit 2e87cb910c
19 changed files with 510 additions and 39 deletions
Regular → Executable
View File
@@ -0,0 +1,27 @@
import { request } from '../client'
export interface RunEntry {
jobId: string
fileName: string
runTime: string
size: number
}
export interface RunDetail {
jobId: string
fileName: string
runTime: string
content: string
}
export async function listCronRuns(jobId?: string): Promise<RunEntry[]> {
const params = new URLSearchParams()
if (jobId) params.set('jobId', jobId)
const qs = params.toString()
const res = await request<{ runs: RunEntry[] }>(`/api/cron-history${qs ? `?${qs}` : ''}`)
return res.runs
}
export async function readCronRun(jobId: string, fileName: string): Promise<RunDetail> {
return request<RunDetail>(`/api/cron-history/${encodeURIComponent(jobId)}/${encodeURIComponent(fileName)}`)
}
+2
View File
@@ -52,6 +52,8 @@ export interface UpdateJobRequest {
skill?: string skill?: string
repeat?: number repeat?: number
enabled?: boolean enabled?: boolean
model?: string
provider?: string
} }
function unwrap(res: { job: Job }): Job { function unwrap(res: { job: Job }): Job {
@@ -5,9 +5,14 @@ import type { Job } from '@/api/hermes/jobs'
import { useJobsStore } from '@/stores/hermes/jobs' import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const props = defineProps<{ job: Job }>() const props = defineProps<{
job: Job
selected?: boolean
}>()
const emit = defineEmits<{ const emit = defineEmits<{
edit: [jobId: string] edit: [jobId: string]
select: [jobId: string]
}>() }>()
const { t } = useI18n() const { t } = useI18n()
@@ -76,10 +81,16 @@ async function handleDelete() {
message.error(e.message) message.error(e.message)
} }
} }
function handleCardClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.card-actions')) return
emit('select', jobId.value)
}
</script> </script>
<template> <template>
<div class="job-card"> <div class="job-card" :class="{ selected }" @click="handleCardClick">
<div class="card-header"> <div class="card-header">
<h3 class="job-name">{{ job.name }}</h3> <h3 class="job-name">{{ job.name }}</h3>
<span class="status-badge" :class="statusType">{{ statusLabel }}</span> <span class="status-badge" :class="statusType">{{ statusLabel }}</span>
@@ -90,6 +101,10 @@ async function handleDelete() {
<span class="info-label">{{ t('jobs.info.schedule') }}</span> <span class="info-label">{{ t('jobs.info.schedule') }}</span>
<code class="info-value mono">{{ scheduleExpr }}</code> <code class="info-value mono">{{ scheduleExpr }}</code>
</div> </div>
<div class="info-row">
<span class="info-label">{{ t('jobs.info.model') }}</span>
<span class="info-value mono">{{ job.model || '—' }}</span>
</div>
<div class="info-row"> <div class="info-row">
<span class="info-label">{{ t('jobs.info.lastRun') }}</span> <span class="info-label">{{ t('jobs.info.lastRun') }}</span>
<span class="info-value"> <span class="info-value">
@@ -119,24 +134,24 @@ async function handleDelete() {
<div class="card-actions"> <div class="card-actions">
<NTooltip v-if="job.state !== 'paused' && job.enabled"> <NTooltip v-if="job.state !== 'paused' && job.enabled">
<template #trigger> <template #trigger>
<NButton size="tiny" quaternary @click="handlePause">{{ t('jobs.action.pause') }}</NButton> <NButton size="tiny" quaternary @click.stop="handlePause">{{ t('jobs.action.pause') }}</NButton>
</template> </template>
{{ t('jobs.action.pauseJob') }} {{ t('jobs.action.pauseJob') }}
</NTooltip> </NTooltip>
<NTooltip v-else-if="job.state === 'paused'"> <NTooltip v-else-if="job.state === 'paused'">
<template #trigger> <template #trigger>
<NButton size="tiny" quaternary @click="handleResume">{{ t('jobs.action.resume') }}</NButton> <NButton size="tiny" quaternary @click.stop="handleResume">{{ t('jobs.action.resume') }}</NButton>
</template> </template>
{{ t('jobs.action.resumeJob') }} {{ t('jobs.action.resumeJob') }}
</NTooltip> </NTooltip>
<NTooltip> <NTooltip>
<template #trigger> <template #trigger>
<NButton size="tiny" quaternary @click="handleRun">{{ t('jobs.action.runNow') }}</NButton> <NButton size="tiny" quaternary @click.stop="handleRun">{{ t('jobs.action.runNow') }}</NButton>
</template> </template>
{{ t('jobs.action.triggerImmediately') }} {{ t('jobs.action.triggerImmediately') }}
</NTooltip> </NTooltip>
<NButton size="tiny" quaternary @click="emit('edit', jobId)">{{ t('common.edit') }}</NButton> <NButton size="tiny" quaternary @click.stop="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton> <NButton size="tiny" quaternary type="error" @click.stop="handleDelete">{{ t('common.delete') }}</NButton>
</div> </div>
</div> </div>
</template> </template>
@@ -150,10 +165,16 @@ async function handleDelete() {
border-radius: $radius-md; border-radius: $radius-md;
padding: 16px; padding: 16px;
transition: border-color $transition-fast; transition: border-color $transition-fast;
cursor: pointer;
&:hover { &:hover {
border-color: rgba(var(--accent-primary-rgb), 0.3); border-color: rgba(var(--accent-primary-rgb), 0.3);
} }
&.selected {
border-color: rgba(var(--accent-primary-rgb), 0.6);
background-color: rgba(var(--accent-primary-rgb), 0.04);
}
} }
.card-header { .card-header {
@@ -2,6 +2,7 @@
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui' import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
import { useJobsStore } from '@/stores/hermes/jobs' import { useJobsStore } from '@/stores/hermes/jobs'
import type { CreateJobRequest, UpdateJobRequest } from '@/api/hermes/jobs'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const { t } = useI18n()
@@ -83,26 +84,32 @@ async function handleSave() {
loading.value = true loading.value = true
try { try {
const payload = { if (isEdit.value) {
const payload: UpdateJobRequest = {
name: formData.value.name,
prompt: formData.value.prompt,
deliver: formData.value.deliver,
repeat: formData.value.repeat_times ?? undefined,
}
if (originalSchedule.value) {
payload.schedule = {
kind: originalSchedule.value.kind,
expr: formData.value.schedule,
display: formData.value.schedule,
}
} else {
payload.schedule = formData.value.schedule
}
await jobsStore.updateJob(props.jobId!, payload)
message.success(t('jobs.jobUpdated'))
} else {
const payload: CreateJobRequest = {
name: formData.value.name, name: formData.value.name,
schedule: formData.value.schedule, schedule: formData.value.schedule,
prompt: formData.value.prompt, prompt: formData.value.prompt,
deliver: formData.value.deliver, deliver: formData.value.deliver,
repeat: formData.value.repeat_times ?? undefined, repeat: formData.value.repeat_times ?? undefined,
} }
if (isEdit.value && originalSchedule.value) {
(payload as any).schedule = {
kind: originalSchedule.value.kind,
expr: formData.value.schedule,
display: formData.value.schedule,
}
}
if (isEdit.value) {
await jobsStore.updateJob(props.jobId!, payload)
message.success(t('jobs.jobUpdated'))
} else {
await jobsStore.createJob(payload) await jobsStore.createJob(payload)
message.success(t('jobs.jobCreated')) message.success(t('jobs.jobCreated'))
} }
@@ -0,0 +1,150 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NSpin, NEmpty, NCollapse, NCollapseItem } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { listCronRuns, readCronRun } from '@/api/hermes/cron-history'
import type { RunEntry, RunDetail } from '@/api/hermes/cron-history'
import MarkdownRenderer from '@/components/hermes/chat/MarkdownRenderer.vue'
const props = defineProps<{
selectedJobId: string | null
jobNameMap: Record<string, string>
}>()
const { t } = useI18n()
const loading = ref(false)
const runs = ref<RunEntry[]>([])
const expandedContent = ref<Record<string, string>>({})
const loadingContent = ref<Record<string, boolean>>({})
const filteredRuns = computed(() => {
if (!props.selectedJobId) return runs.value
return runs.value.filter(r => r.jobId === props.selectedJobId)
})
async function fetchRuns() {
loading.value = true
try {
runs.value = await listCronRuns(props.selectedJobId ?? undefined)
} catch (err) {
console.error('Failed to fetch cron runs:', err)
runs.value = []
} finally {
loading.value = false
}
}
async function handleExpand(key: string | number | Array<string | number>) {
// accordion mode emits a single value; non-accordion emits an array
const keys = Array.isArray(key) ? key : key != null ? [key] : []
for (const raw of keys) {
const k = String(raw)
if (expandedContent.value[k] || loadingContent.value[k]) continue
const run = filteredRuns.value.find(r => `${r.jobId}/${r.fileName}` === k)
if (!run) continue
loadingContent.value[k] = true
try {
const detail: RunDetail = await readCronRun(run.jobId, run.fileName)
expandedContent.value[k] = detail.content
} catch (err) {
expandedContent.value[k] = `[Error loading content]`
} finally {
loadingContent.value[k] = false
}
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
}
function getJobName(jobId: string): string {
return props.jobNameMap[jobId] || jobId
}
watch(() => props.selectedJobId, () => {
expandedContent.value = {}
fetchRuns()
}, { immediate: true })
</script>
<template>
<div class="run-history">
<div class="history-header">
<span class="history-title">{{ t('jobs.runHistory.title') }}</span>
<span class="history-count">{{ filteredRuns.length }} {{ t('jobs.runHistory.runs') }}</span>
</div>
<div class="history-body">
<NSpin :show="loading">
<NEmpty v-if="!loading && filteredRuns.length === 0" :description="t('jobs.runHistory.noRuns')" />
<NCollapse
v-else
accordion
@update:expanded-names="handleExpand"
>
<NCollapseItem
v-for="run in filteredRuns"
:key="`${run.jobId}/${run.fileName}`"
:title="`${getJobName(run.jobId)} — ${run.runTime}`"
:name="`${run.jobId}/${run.fileName}`"
>
<template #header-extra>
<span class="run-meta">{{ formatSize(run.size) }}</span>
</template>
<NSpin v-if="loadingContent[`${run.jobId}/${run.fileName}`]" size="small" />
<MarkdownRenderer v-else-if="expandedContent[`${run.jobId}/${run.fileName}`]" :content="expandedContent[`${run.jobId}/${run.fileName}`]" />
</NCollapseItem>
</NCollapse>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.run-history {
height: 100%;
display: flex;
flex-direction: column;
}
.history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid $border-light;
flex-shrink: 0;
}
.history-title {
font-size: 14px;
font-weight: 600;
color: $text-primary;
}
.history-count {
font-size: 12px;
color: $text-muted;
}
.history-body {
flex: 1;
overflow-y: auto;
padding: 8px 20px 20px;
}
.run-meta {
font-size: 11px;
color: $text-muted;
font-family: $font-code;
}
</style>
@@ -3,13 +3,28 @@ import JobCard from './JobCard.vue'
import { useJobsStore } from '@/stores/hermes/jobs' import { useJobsStore } from '@/stores/hermes/jobs'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { t } = useI18n() const props = defineProps<{
selectedJobId: string | null
}>()
const emit = defineEmits<{ const emit = defineEmits<{
edit: [jobId: string] edit: [jobId: string]
select: [jobId: string | null]
}>() }>()
const { t } = useI18n()
const jobsStore = useJobsStore() const jobsStore = useJobsStore()
function handleSelect(jobId: string) {
emit('select', props.selectedJobId === jobId ? null : jobId)
}
function handleDeselect() {
if (props.selectedJobId) {
emit('select', null)
}
}
</script> </script>
<template> <template>
@@ -27,9 +42,17 @@ const jobsStore = useJobsStore()
v-for="job in jobsStore.jobs" v-for="job in jobsStore.jobs"
:key="job.id" :key="job.id"
:job="job" :job="job"
:selected="selectedJobId === (job.job_id || job.id)"
@edit="emit('edit', job.id)" @edit="emit('edit', job.id)"
@select="handleSelect"
/> />
</div> </div>
<!-- Click outside cards to deselect -->
<div
v-if="selectedJobId"
class="deselect-overlay"
@click="handleDeselect"
/>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -58,4 +81,8 @@ const jobsStore = useJobsStore()
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr)); grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
gap: 14px; gap: 14px;
} }
.deselect-overlay {
display: none;
}
</style> </style>
+10 -2
View File
@@ -167,6 +167,7 @@ export default {
origin: 'Herkunft', origin: 'Herkunft',
local: 'Lokal', local: 'Lokal',
repeatCount: 'Wiederholungsanzahl (optional)', repeatCount: 'Wiederholungsanzahl (optional)',
modelPlaceholder: 'Standardmodell',
repeatPlaceholder: 'Leer lassen fur unendlich', repeatPlaceholder: 'Leer lassen fur unendlich',
jobCreated: 'Aufgabe erstellt', jobCreated: 'Aufgabe erstellt',
jobUpdated: 'Aufgabe aktualisiert', jobUpdated: 'Aufgabe aktualisiert',
@@ -175,7 +176,8 @@ export default {
loadFailed: 'Laden der Aufgabe fehlgeschlagen', loadFailed: 'Laden der Aufgabe fehlgeschlagen',
jobPaused: 'Aufgabe pausiert', jobPaused: 'Aufgabe pausiert',
jobResumed: 'Aufgabe fortgesetzt', jobResumed: 'Aufgabe fortgesetzt',
jobTriggered: 'Aufgabe ausgelost', jobTriggered: 'Job ausgelost',
modelUpdated: 'Modell aktualisiert',
jobDeleted: 'Aufgabe geloscht', jobDeleted: 'Aufgabe geloscht',
status: { status: {
running: 'Lauft', running: 'Lauft',
@@ -184,6 +186,7 @@ export default {
scheduled: 'Geplant', scheduled: 'Geplant',
}, },
info: { info: {
model: 'Modell',
schedule: 'Zeitplan', schedule: 'Zeitplan',
lastRun: 'Letzte Ausfuhrung', lastRun: 'Letzte Ausfuhrung',
nextRun: 'Nachste Ausfuhrung', nextRun: 'Nachste Ausfuhrung',
@@ -196,7 +199,12 @@ export default {
resume: 'Fortsetzen', resume: 'Fortsetzen',
resumeJob: 'Aufgabe fortsetzen', resumeJob: 'Aufgabe fortsetzen',
runNow: 'Jetzt ausfuhren', runNow: 'Jetzt ausfuhren',
triggerImmediately: 'Sofort auslosen', triggerImmediately: 'Sofort auslösen',
},
runHistory: {
title: 'Verlauf',
runs: 'Läufe',
noRuns: 'Kein Verlauf gefunden.',
}, },
}, },
+8
View File
@@ -190,6 +190,7 @@ export default {
origin: 'Origin', origin: 'Origin',
local: 'Local', local: 'Local',
repeatCount: 'Repeat Count (optional)', repeatCount: 'Repeat Count (optional)',
modelPlaceholder: 'Default model',
repeatPlaceholder: 'Leave empty for infinite', repeatPlaceholder: 'Leave empty for infinite',
jobCreated: 'Job created', jobCreated: 'Job created',
jobUpdated: 'Job updated', jobUpdated: 'Job updated',
@@ -199,6 +200,7 @@ export default {
jobPaused: 'Job paused', jobPaused: 'Job paused',
jobResumed: 'Job resumed', jobResumed: 'Job resumed',
jobTriggered: 'Job triggered', jobTriggered: 'Job triggered',
modelUpdated: 'Model updated',
jobDeleted: 'Job deleted', jobDeleted: 'Job deleted',
status: { status: {
running: 'Running', running: 'Running',
@@ -207,6 +209,7 @@ export default {
scheduled: 'Scheduled', scheduled: 'Scheduled',
}, },
info: { info: {
model: 'Model',
schedule: 'Schedule', schedule: 'Schedule',
lastRun: 'Last Run', lastRun: 'Last Run',
nextRun: 'Next Run', nextRun: 'Next Run',
@@ -221,6 +224,11 @@ export default {
runNow: 'Run Now', runNow: 'Run Now',
triggerImmediately: 'Trigger immediately', triggerImmediately: 'Trigger immediately',
}, },
runHistory: {
title: 'Run History',
runs: 'runs',
noRuns: 'No run history found.',
},
}, },
// Skills // Skills
+9 -1
View File
@@ -167,6 +167,7 @@ export default {
origin: 'Origen', origin: 'Origen',
local: 'Local', local: 'Local',
repeatCount: 'Repeticiones (opcional)', repeatCount: 'Repeticiones (opcional)',
modelPlaceholder: 'Modelo por defecto',
repeatPlaceholder: 'Dejar vacio para infinito', repeatPlaceholder: 'Dejar vacio para infinito',
jobCreated: 'Tarea creada', jobCreated: 'Tarea creada',
jobUpdated: 'Tarea actualizada', jobUpdated: 'Tarea actualizada',
@@ -175,7 +176,8 @@ export default {
loadFailed: 'Error al cargar la tarea', loadFailed: 'Error al cargar la tarea',
jobPaused: 'Tarea en pausa', jobPaused: 'Tarea en pausa',
jobResumed: 'Tarea reanudada', jobResumed: 'Tarea reanudada',
jobTriggered: 'Tarea ejecutada', jobTriggered: 'Job ejecutado',
modelUpdated: 'Modelo actualizado',
jobDeleted: 'Tarea eliminada', jobDeleted: 'Tarea eliminada',
status: { status: {
running: 'En ejecucion', running: 'En ejecucion',
@@ -184,6 +186,7 @@ export default {
scheduled: 'Programada', scheduled: 'Programada',
}, },
info: { info: {
model: 'Modelo',
schedule: 'Programacion', schedule: 'Programacion',
lastRun: 'Ultima ejecucion', lastRun: 'Ultima ejecucion',
nextRun: 'Proxima ejecucion', nextRun: 'Proxima ejecucion',
@@ -198,6 +201,11 @@ export default {
runNow: 'Ejecutar ahora', runNow: 'Ejecutar ahora',
triggerImmediately: 'Ejecutar inmediatamente', triggerImmediately: 'Ejecutar inmediatamente',
}, },
runHistory: {
title: 'Historial',
runs: 'ejecuciones',
noRuns: 'No se encontró historial.',
},
}, },
// Skills // Skills
+10 -2
View File
@@ -167,6 +167,7 @@ export default {
origin: 'Origine', origin: 'Origine',
local: 'Local', local: 'Local',
repeatCount: 'Nombre de repetitions (facultatif)', repeatCount: 'Nombre de repetitions (facultatif)',
modelPlaceholder: 'Modele par defaut',
repeatPlaceholder: 'Laisser vide pour infini', repeatPlaceholder: 'Laisser vide pour infini',
jobCreated: 'Tache creee', jobCreated: 'Tache creee',
jobUpdated: 'Tache mise a jour', jobUpdated: 'Tache mise a jour',
@@ -175,7 +176,8 @@ export default {
loadFailed: 'Echec du chargement de la tache', loadFailed: 'Echec du chargement de la tache',
jobPaused: 'Tache en pause', jobPaused: 'Tache en pause',
jobResumed: 'Tache reprise', jobResumed: 'Tache reprise',
jobTriggered: 'Tache declenchee', jobTriggered: 'Job declenche',
modelUpdated: 'Modele mis a jour',
jobDeleted: 'Tache supprimee', jobDeleted: 'Tache supprimee',
status: { status: {
running: 'En cours', running: 'En cours',
@@ -184,6 +186,7 @@ export default {
scheduled: 'Planifiee', scheduled: 'Planifiee',
}, },
info: { info: {
model: 'Modele',
schedule: 'Planification', schedule: 'Planification',
lastRun: 'Derniere execution', lastRun: 'Derniere execution',
nextRun: 'Prochaine execution', nextRun: 'Prochaine execution',
@@ -196,7 +199,12 @@ export default {
resume: 'Reprendre', resume: 'Reprendre',
resumeJob: 'Reprendre la tache', resumeJob: 'Reprendre la tache',
runNow: 'Executer maintenant', runNow: 'Executer maintenant',
triggerImmediately: 'Declencher immediatement', triggerImmediately: 'Déclencher immédiatement',
},
runHistory: {
title: 'Historique',
runs: 'exécutions',
noRuns: 'Aucun historique trouvé.',
}, },
}, },
+9 -1
View File
@@ -167,6 +167,7 @@ export default {
origin: '配信元', origin: '配信元',
local: 'ローカル', local: 'ローカル',
repeatCount: '繰り返し回数(任意)', repeatCount: '繰り返し回数(任意)',
modelPlaceholder: 'Default model',
repeatPlaceholder: '空白の場合は無制限', repeatPlaceholder: '空白の場合は無制限',
jobCreated: 'ジョブを作成しました', jobCreated: 'ジョブを作成しました',
jobUpdated: 'ジョブを更新しました', jobUpdated: 'ジョブを更新しました',
@@ -176,6 +177,7 @@ export default {
jobPaused: 'ジョブを一時停止しました', jobPaused: 'ジョブを一時停止しました',
jobResumed: 'ジョブを再開しました', jobResumed: 'ジョブを再開しました',
jobTriggered: 'ジョブをトリガーしました', jobTriggered: 'ジョブをトリガーしました',
modelUpdated: 'Model updated',
jobDeleted: 'ジョブを削除しました', jobDeleted: 'ジョブを削除しました',
status: { status: {
running: '実行中', running: '実行中',
@@ -184,7 +186,8 @@ export default {
scheduled: 'スケジュール済み', scheduled: 'スケジュール済み',
}, },
info: { info: {
schedule: 'スケジュール', model: 'Model',
schedule: 'Schedule',
lastRun: '前回実行', lastRun: '前回実行',
nextRun: '次回実行', nextRun: '次回実行',
deliver: '配信', deliver: '配信',
@@ -198,6 +201,11 @@ export default {
runNow: '今すぐ実行', runNow: '今すぐ実行',
triggerImmediately: 'すぐにトリガー', triggerImmediately: 'すぐにトリガー',
}, },
runHistory: {
title: '実行履歴',
runs: '件',
noRuns: '実行履歴がありません。',
},
}, },
// スキル // スキル
+9 -1
View File
@@ -167,6 +167,7 @@ export default {
origin: '출처', origin: '출처',
local: '로컬', local: '로컬',
repeatCount: '반복 횟수 (선택)', repeatCount: '반복 횟수 (선택)',
modelPlaceholder: 'Default model',
repeatPlaceholder: '비워두면 무한 반복', repeatPlaceholder: '비워두면 무한 반복',
jobCreated: '작업이 생성되었습니다', jobCreated: '작업이 생성되었습니다',
jobUpdated: '작업이 업데이트되었습니다', jobUpdated: '작업이 업데이트되었습니다',
@@ -176,6 +177,7 @@ export default {
jobPaused: '작업이 일시 정지되었습니다', jobPaused: '작업이 일시 정지되었습니다',
jobResumed: '작업이 재개되었습니다', jobResumed: '작업이 재개되었습니다',
jobTriggered: '작업이 실행되었습니다', jobTriggered: '작업이 실행되었습니다',
modelUpdated: 'Model updated',
jobDeleted: '작업이 삭제되었습니다', jobDeleted: '작업이 삭제되었습니다',
status: { status: {
running: '실행 중', running: '실행 중',
@@ -184,7 +186,8 @@ export default {
scheduled: '예약됨', scheduled: '예약됨',
}, },
info: { info: {
schedule: '스케줄', model: 'Model',
schedule: 'Schedule',
lastRun: '마지막 실행', lastRun: '마지막 실행',
nextRun: '다음 실행', nextRun: '다음 실행',
deliver: '전송', deliver: '전송',
@@ -198,6 +201,11 @@ export default {
runNow: '즉시 실행', runNow: '즉시 실행',
triggerImmediately: '즉시 실행', triggerImmediately: '즉시 실행',
}, },
runHistory: {
title: '실행 기록',
runs: '회 실행',
noRuns: '실행 기록이 없습니다.',
},
}, },
// 스킬 // 스킬
+9 -1
View File
@@ -167,6 +167,7 @@ export default {
origin: 'Origem', origin: 'Origem',
local: 'Local', local: 'Local',
repeatCount: 'Contagem de repeticoes (opcional)', repeatCount: 'Contagem de repeticoes (opcional)',
modelPlaceholder: 'Modelo padrao',
repeatPlaceholder: 'Deixar vazio para infinito', repeatPlaceholder: 'Deixar vazio para infinito',
jobCreated: 'Tarefa criada', jobCreated: 'Tarefa criada',
jobUpdated: 'Tarefa atualizada', jobUpdated: 'Tarefa atualizada',
@@ -175,7 +176,8 @@ export default {
loadFailed: 'Falha ao carregar a tarefa', loadFailed: 'Falha ao carregar a tarefa',
jobPaused: 'Tarefa pausada', jobPaused: 'Tarefa pausada',
jobResumed: 'Tarefa retomada', jobResumed: 'Tarefa retomada',
jobTriggered: 'Tarefa acionada', jobTriggered: 'Job acionado',
modelUpdated: 'Modelo atualizado',
jobDeleted: 'Tarefa excluida', jobDeleted: 'Tarefa excluida',
status: { status: {
running: 'Em execucao', running: 'Em execucao',
@@ -184,6 +186,7 @@ export default {
scheduled: 'Agendada', scheduled: 'Agendada',
}, },
info: { info: {
model: 'Modelo',
schedule: 'Agendamento', schedule: 'Agendamento',
lastRun: 'Ultima execucao', lastRun: 'Ultima execucao',
nextRun: 'Proxima execucao', nextRun: 'Proxima execucao',
@@ -198,6 +201,11 @@ export default {
runNow: 'Executar agora', runNow: 'Executar agora',
triggerImmediately: 'Acionar imediatamente', triggerImmediately: 'Acionar imediatamente',
}, },
runHistory: {
title: 'Histórico',
runs: 'execuções',
noRuns: 'Nenhum histórico encontrado.',
},
}, },
// Skills // Skills
+9 -1
View File
@@ -190,6 +190,7 @@ export default {
origin: '来源', origin: '来源',
local: '本地', local: '本地',
repeatCount: '重复次数(可选)', repeatCount: '重复次数(可选)',
modelPlaceholder: 'Default model',
repeatPlaceholder: '留空表示无限重复', repeatPlaceholder: '留空表示无限重复',
jobCreated: '任务已创建', jobCreated: '任务已创建',
jobUpdated: '任务已更新', jobUpdated: '任务已更新',
@@ -199,6 +200,7 @@ export default {
jobPaused: '任务已暂停', jobPaused: '任务已暂停',
jobResumed: '任务已恢复', jobResumed: '任务已恢复',
jobTriggered: '任务已触发', jobTriggered: '任务已触发',
modelUpdated: 'Model updated',
jobDeleted: '任务已删除', jobDeleted: '任务已删除',
status: { status: {
running: '运行中', running: '运行中',
@@ -207,7 +209,8 @@ export default {
scheduled: '已调度', scheduled: '已调度',
}, },
info: { info: {
schedule: '调度', model: 'Model',
schedule: 'Schedule',
lastRun: '上次运行', lastRun: '上次运行',
nextRun: '下次运行', nextRun: '下次运行',
deliver: '投递', deliver: '投递',
@@ -221,6 +224,11 @@ export default {
runNow: '立即运行', runNow: '立即运行',
triggerImmediately: '立即触发', triggerImmediately: '立即触发',
}, },
runHistory: {
title: '运行历史',
runs: '次运行',
noRuns: '暂无运行历史。',
},
}, },
// 技能 // 技能
+54 -4
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { NButton, NSpin } from 'naive-ui' import { NButton, NSpin } from 'naive-ui'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import JobsPanel from '@/components/hermes/jobs/JobsPanel.vue' import JobsPanel from '@/components/hermes/jobs/JobsPanel.vue'
import JobRunHistory from '@/components/hermes/jobs/JobRunHistory.vue'
import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue' import JobFormModal from '@/components/hermes/jobs/JobFormModal.vue'
import { useJobsStore } from '@/stores/hermes/jobs' import { useJobsStore } from '@/stores/hermes/jobs'
@@ -10,6 +11,16 @@ const { t } = useI18n()
const jobsStore = useJobsStore() const jobsStore = useJobsStore()
const showModal = ref(false) const showModal = ref(false)
const editingJob = ref<string | null>(null) const editingJob = ref<string | null>(null)
const selectedJobId = ref<string | null>(null)
const jobNameMap = computed(() => {
const map: Record<string, string> = {}
for (const job of jobsStore.jobs) {
const id = job.job_id || job.id
map[id] = job.name
}
return map
})
onMounted(() => { onMounted(() => {
jobsStore.fetchJobs() jobsStore.fetchJobs()
@@ -34,6 +45,10 @@ async function handleSave() {
await jobsStore.fetchJobs() await jobsStore.fetchJobs()
handleModalClose() handleModalClose()
} }
function handleSelectJob(jobId: string | null) {
selectedJobId.value = selectedJobId.value === jobId ? null : jobId
}
</script> </script>
<template> <template>
@@ -48,12 +63,27 @@ async function handleSave() {
</NButton> </NButton>
</header> </header>
<div class="jobs-content"> <div class="jobs-split">
<div class="jobs-top">
<NSpin :show="jobsStore.loading && jobsStore.jobs.length === 0"> <NSpin :show="jobsStore.loading && jobsStore.jobs.length === 0">
<JobsPanel @edit="openEditModal" /> <JobsPanel
:selected-job-id="selectedJobId"
@edit="openEditModal"
@select="handleSelectJob"
/>
</NSpin> </NSpin>
</div> </div>
<div class="splitter" />
<div class="jobs-bottom">
<JobRunHistory
:selected-job-id="selectedJobId"
:job-name-map="jobNameMap"
/>
</div>
</div>
<JobFormModal <JobFormModal
v-if="showModal" v-if="showModal"
:job-id="editingJob" :job-id="editingJob"
@@ -72,9 +102,29 @@ async function handleSave() {
flex-direction: column; flex-direction: column;
} }
.jobs-content { .jobs-split {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.jobs-top {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 20px; padding: 20px;
min-height: 120px;
}
.splitter {
height: 1px;
background: $border-light;
flex-shrink: 0;
}
.jobs-bottom {
flex: 1;
min-height: 120px;
overflow: hidden;
} }
</style> </style>
@@ -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)
+2
View File
@@ -24,6 +24,7 @@ import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files' import { fileRoutes } from './hermes/files'
import { downloadRoutes } from './hermes/download' import { downloadRoutes } from './hermes/download'
import { jobRoutes } from './hermes/jobs' import { jobRoutes } from './hermes/jobs'
import { cronHistoryRoutes } from './hermes/cron-history'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy' import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat' 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(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
app.use(downloadRoutes.routes()) // Must be before proxy app.use(downloadRoutes.routes()) // Must be before proxy
app.use(jobRoutes.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()) app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last) // Proxy catch-all middleware (must be last)