astrbbbb / dashboard /src /views /CronJobPage.vue
qa1145's picture
Upload 1245 files
8ede856 verified
<template>
<div class="cron-page">
<div class="d-flex align-center justify-space-between mb-4">
<div>
<div class="d-flex align-center" style="gap: 8px;">
<h2 class="text-h5 font-weight-bold">{{ tm('page.title') }}</h2>
<v-chip size="x-small" color="orange-darken-2" variant="tonal" label>{{ tm('page.beta') }}</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis">
{{ tm('page.subtitle') }}
<span v-if="proactivePlatforms.length">
{{ tm('page.proactive.supported', { platforms: proactivePlatformText }) }}
</span>
<span v-else>{{ tm('page.proactive.unsupported') }}</span>
</div>
</div>
<div class="d-flex align-center" style="gap: 8px;">
<v-btn variant="tonal" color="primary" @click="openCreate">{{ tm('actions.create') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="loading" @click="loadJobs">{{ tm('actions.refresh') }}</v-btn>
</div>
</div>
<v-card class="rounded-lg" variant="flat">
<v-card-text>
<div class="d-flex align-center justify-space-between mb-3">
<div class="text-subtitle-1 font-weight-bold">{{ tm('table.title') }}</div>
</div>
<v-alert v-if="!jobs.length && !loading" type="info" variant="tonal">{{ tm('table.empty') }}</v-alert>
<v-data-table :items="jobs" :headers="headers" :loading="loading" item-key="job_id" density="comfortable"
class="elevation-0">
<template #item.name="{ item }">
<div class="py-4">
<div class="font-weight-medium">{{ item.name }}</div>
<div class="text-caption text-medium-emphasis">{{ item.description }}</div>
</div>
</template>
<template #item.type="{ item }">
<v-chip size="small" :color="item.run_once ? 'orange' : 'primary'" variant="tonal">
{{ jobTypeLabel(item) }}
</v-chip>
</template>
<template #item.cron_expression="{ item }">
<div v-if="item.run_once">{{ formatTime(item.run_at) }}</div>
<div v-else>
<div>{{ item.cron_expression || tm('table.notAvailable') }}</div>
<div class="text-caption text-medium-emphasis">{{ item.timezone || tm('table.timezoneLocal') }}</div>
</div>
</template>
<template #item.session="{ item }">
<div>{{ item.session || tm('table.notAvailable') }}</div>
</template>
<template #item.next_run_time="{ item }">{{ formatTime(item.next_run_time) }}</template>
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
<template #item.actions="{ item }">
<div class="d-flex align-center flex-nowrap" style="gap: 12px; min-width: 140px;">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</template>
</v-data-table>
</v-card-text>
</v-card>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" timeout="2600">
{{ snackbar.message }}
</v-snackbar>
<v-dialog v-model="createDialog" max-width="560">
<v-card>
<v-card-title class="text-h6">{{ tm('form.title') }}</v-card-title>
<v-card-subtitle class="text-body-2 text-medium-emphasis">
{{ tm('form.chatHint') }}
</v-card-subtitle>
<v-card-text>
<v-switch v-model="newJob.run_once" :label="tm('form.runOnce')" inset color="primary" hide-details />
<v-text-field v-model="newJob.name" :label="tm('form.name')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.note" :label="tm('form.note')" variant="outlined" density="comfortable" />
<v-text-field v-if="!newJob.run_once" v-model="newJob.cron_expression" :label="tm('form.cron')"
:placeholder="tm('form.cronPlaceholder')" variant="outlined" density="comfortable" />
<v-text-field v-else v-model="newJob.run_at" :label="tm('form.runAt')" type="datetime-local"
variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.session" :label="tm('form.session')" variant="outlined" density="comfortable" />
<v-text-field v-model="newJob.timezone" :label="tm('form.timezone')" variant="outlined"
density="comfortable" />
<v-switch v-model="newJob.enabled" :label="tm('form.enabled')" inset color="primary" hide-details />
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">{{ tm('actions.submit')
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
const { tm } = useModuleI18n('features/cron')
const loading = ref(false)
const jobs = ref<any[]>([])
const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
const createDialog = ref(false)
const creating = ref(false)
const newJob = ref({
run_once: false,
name: '',
note: '',
cron_expression: '',
run_at: '',
session: '',
timezone: '',
enabled: true
})
const snackbar = ref({ show: false, message: '', color: 'success' })
const proactivePlatformText = computed(() =>
proactivePlatforms.value.map((p) => `${p.display_name || p.name}(${p.id})`).join(' / ')
)
const headers = computed(() => [
{ title: tm('table.headers.name'), key: 'name', minWidth: '200px' },
{ title: tm('table.headers.type'), key: 'type', width: 110 },
{ title: tm('table.headers.cron'), key: 'cron_expression', minWidth: '160px' },
{ title: tm('table.headers.session'), key: 'session', minWidth: '200px' },
{ title: tm('table.headers.nextRun'), key: 'next_run_time', minWidth: '160px' },
{ title: tm('table.headers.lastRun'), key: 'last_run_at', minWidth: '160px' },
{ title: tm('table.headers.note'), key: 'note', minWidth: '220px' },
{ title: tm('table.headers.actions'), key: 'actions', width: 160, sortable: false }
])
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
snackbar.value = { show: true, message, color }
}
function formatTime(val: any): string {
if (!val) return tm('table.notAvailable')
try {
return new Date(val).toLocaleString()
} catch (e) {
return String(val)
}
}
function jobTypeLabel(item: any): string {
if (item.run_once) return tm('table.type.once')
const type = item.job_type || 'active_agent'
const map: Record<string, string> = {
active_agent: tm('table.type.activeAgent'),
workflow: tm('table.type.workflow')
}
return map[type] || tm('table.type.unknown', { type })
}
async function loadJobs() {
loading.value = true
try {
const res = await axios.get('/api/cron/jobs')
if (res.data.status === 'ok') {
const data = Array.isArray(res.data.data) ? res.data.data : []
jobs.value = data.map((job: any) => ({
...job,
session: job?.payload?.session || job?.session || ''
}))
} else {
toast(res.data.message || tm('messages.loadFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.loadFailed'), 'error')
} finally {
loading.value = false
}
}
async function loadPlatforms() {
try {
const res = await axios.get('/api/platform/stats')
if (res.data.status === 'ok' && Array.isArray(res.data.data?.platforms)) {
proactivePlatforms.value = res.data.data.platforms
.filter((p: any) => p?.meta?.support_proactive_message)
.map((p: any) => ({
id: p?.id || p?.meta?.id || 'unknown',
name: p?.meta?.name || p?.type || '',
display_name: p?.meta?.display_name || p?.display_name
}))
}
} catch (e) {
// ignore platform fetch errors in UI; subtitle will show fallback
}
}
async function toggleJob(job: any) {
try {
const res = await axios.patch(`/api/cron/jobs/${job.job_id}`, { enabled: job.enabled })
if (res.data.status !== 'ok') {
toast(res.data.message || tm('messages.updateFailed'), 'error')
await loadJobs()
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error')
await loadJobs()
}
}
async function deleteJob(job: any) {
try {
const res = await axios.delete(`/api/cron/jobs/${job.job_id}`)
if (res.data.status === 'ok') {
toast(tm('messages.deleteSuccess'))
jobs.value = jobs.value.filter((j) => j.job_id !== job.job_id)
} else {
toast(res.data.message || tm('messages.deleteFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.deleteFailed'), 'error')
}
}
function openCreate() {
resetNewJob()
createDialog.value = true
}
function resetNewJob() {
newJob.value = {
run_once: false,
name: '',
note: '',
cron_expression: '',
run_at: '',
session: '',
timezone: '',
enabled: true
}
}
async function createJob() {
if (!newJob.value.session) {
toast(tm('messages.sessionRequired'), 'warning')
return
}
if (!newJob.value.note) {
toast(tm('messages.noteRequired'), 'warning')
return
}
if (!newJob.value.run_once && !newJob.value.cron_expression) {
toast(tm('messages.cronRequired'), 'warning')
return
}
if (newJob.value.run_once && !newJob.value.run_at) {
toast(tm('messages.runAtRequired'), 'warning')
return
}
creating.value = true
try {
const payload: any = { ...newJob.value }
const res = await axios.post('/api/cron/jobs', payload)
if (res.data.status === 'ok') {
toast(tm('messages.createSuccess'))
createDialog.value = false
resetNewJob()
await loadJobs()
} else {
toast(res.data.message || tm('messages.createFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.createFailed'), 'error')
} finally {
creating.value = false
}
}
onMounted(() => {
loadJobs()
loadPlatforms()
})
</script>
<style scoped>
.cron-page {
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
</style>