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:
@@ -5,9 +5,14 @@ import type { Job } from '@/api/hermes/jobs'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps<{ job: Job }>()
|
||||
const props = defineProps<{
|
||||
job: Job
|
||||
selected?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [jobId: string]
|
||||
select: [jobId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -76,10 +81,16 @@ async function handleDelete() {
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="job-card">
|
||||
<div class="job-card" :class="{ selected }" @click="handleCardClick">
|
||||
<div class="card-header">
|
||||
<h3 class="job-name">{{ job.name }}</h3>
|
||||
<span class="status-badge" :class="statusType">{{ statusLabel }}</span>
|
||||
@@ -90,6 +101,10 @@ async function handleDelete() {
|
||||
<span class="info-label">{{ t('jobs.info.schedule') }}</span>
|
||||
<code class="info-value mono">{{ scheduleExpr }}</code>
|
||||
</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">
|
||||
<span class="info-label">{{ t('jobs.info.lastRun') }}</span>
|
||||
<span class="info-value">
|
||||
@@ -119,24 +134,24 @@ async function handleDelete() {
|
||||
<div class="card-actions">
|
||||
<NTooltip v-if="job.state !== 'paused' && job.enabled">
|
||||
<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>
|
||||
{{ t('jobs.action.pauseJob') }}
|
||||
</NTooltip>
|
||||
<NTooltip v-else-if="job.state === 'paused'">
|
||||
<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>
|
||||
{{ t('jobs.action.resumeJob') }}
|
||||
</NTooltip>
|
||||
<NTooltip>
|
||||
<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>
|
||||
{{ t('jobs.action.triggerImmediately') }}
|
||||
</NTooltip>
|
||||
<NButton size="tiny" quaternary @click="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
<NButton size="tiny" quaternary @click.stop="emit('edit', jobId)">{{ t('common.edit') }}</NButton>
|
||||
<NButton size="tiny" quaternary type="error" @click.stop="handleDelete">{{ t('common.delete') }}</NButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -150,10 +165,16 @@ async function handleDelete() {
|
||||
border-radius: $radius-md;
|
||||
padding: 16px;
|
||||
transition: border-color $transition-fast;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
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 {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
|
||||
import { useJobsStore } from '@/stores/hermes/jobs'
|
||||
import type { CreateJobRequest, UpdateJobRequest } from '@/api/hermes/jobs'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -83,26 +84,32 @@ async function handleSave() {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: formData.value.name,
|
||||
schedule: formData.value.schedule,
|
||||
prompt: formData.value.prompt,
|
||||
deliver: formData.value.deliver,
|
||||
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) {
|
||||
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,
|
||||
schedule: formData.value.schedule,
|
||||
prompt: formData.value.prompt,
|
||||
deliver: formData.value.deliver,
|
||||
repeat: formData.value.repeat_times ?? undefined,
|
||||
}
|
||||
await jobsStore.createJob(payload)
|
||||
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 { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = defineProps<{
|
||||
selectedJobId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [jobId: string]
|
||||
select: [jobId: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const jobsStore = useJobsStore()
|
||||
|
||||
function handleSelect(jobId: string) {
|
||||
emit('select', props.selectedJobId === jobId ? null : jobId)
|
||||
}
|
||||
|
||||
function handleDeselect() {
|
||||
if (props.selectedJobId) {
|
||||
emit('select', null)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -27,9 +42,17 @@ const jobsStore = useJobsStore()
|
||||
v-for="job in jobsStore.jobs"
|
||||
:key="job.id"
|
||||
:job="job"
|
||||
:selected="selectedJobId === (job.job_id || job.id)"
|
||||
@edit="emit('edit', job.id)"
|
||||
@select="handleSelect"
|
||||
/>
|
||||
</div>
|
||||
<!-- Click outside cards to deselect -->
|
||||
<div
|
||||
v-if="selectedJobId"
|
||||
class="deselect-overlay"
|
||||
@click="handleDeselect"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -58,4 +81,8 @@ const jobsStore = useJobsStore()
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.deselect-overlay {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user