eeb6174de0
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
398 lines
9.5 KiB
Vue
398 lines
9.5 KiB
Vue
<script setup lang="ts">
|
|
import { onMounted, ref, computed } from 'vue'
|
|
import { Calendar, Clock, RefreshCw, Loader2, AlertCircle, CheckCircle2, Timer } from '@lucide/vue'
|
|
import { apiFetch } from '../services/api'
|
|
|
|
interface CalendarJob {
|
|
id: string
|
|
name: string
|
|
schedule: string
|
|
lastRun: string | null
|
|
nextRun: string | null
|
|
status: string
|
|
}
|
|
|
|
interface UpcomingJob {
|
|
id: string
|
|
name: string
|
|
nextRun: string
|
|
schedule: string
|
|
}
|
|
|
|
const jobs = ref<CalendarJob[]>([])
|
|
const upcoming = ref<UpcomingJob[]>([])
|
|
const loading = ref(false)
|
|
const upcomingLoading = ref(false)
|
|
const error = ref('')
|
|
const upcomingError = ref('')
|
|
|
|
function formatSchedule(cron: string): string {
|
|
const map: Record<string, string> = {
|
|
'*/5 * * * *': 'Every 5 minutes',
|
|
'*/10 * * * *': 'Every 10 minutes',
|
|
'*/15 * * * *': 'Every 15 minutes',
|
|
'*/30 * * * *': 'Every 30 minutes',
|
|
'0 * * * *': 'Every hour',
|
|
'0 */6 * * *': 'Every 6 hours',
|
|
'0 */12 * * *': 'Every 12 hours',
|
|
'0 0 * * *': 'Daily at midnight',
|
|
'0 3 * * *': 'Daily at 03:00',
|
|
'0 4 * * *': 'Daily at 04:00',
|
|
'0 2 * * *': 'Daily at 02:00',
|
|
}
|
|
return map[cron] ?? cron
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
const d = new Date(dateStr)
|
|
const now = new Date()
|
|
const diffMs = d.getTime() - now.getTime()
|
|
const diffMin = Math.round(diffMs / 60000)
|
|
|
|
const dateFormatted = d.toLocaleDateString('de-DE', {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
})
|
|
|
|
if (diffMin < 0) {
|
|
return `${dateFormatted} (${Math.abs(diffMin)}m ago)`
|
|
}
|
|
if (diffMin < 60) {
|
|
return `${dateFormatted} (in ${diffMin}m)`
|
|
}
|
|
const diffHr = Math.round(diffMin / 60)
|
|
if (diffHr < 24) {
|
|
return `${dateFormatted} (in ${diffHr}h)`
|
|
}
|
|
return dateFormatted
|
|
}
|
|
|
|
function statusIcon(status: string) {
|
|
if (status === 'completed') return CheckCircle2
|
|
if (status === 'running') return Timer
|
|
if (status === 'failed') return AlertCircle
|
|
return Clock
|
|
}
|
|
|
|
function statusClass(status: string): string {
|
|
if (status === 'completed') return 'status-completed'
|
|
if (status === 'running') return 'status-running'
|
|
if (status === 'failed') return 'status-failed'
|
|
return 'status-idle'
|
|
}
|
|
|
|
async function loadJobs() {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const response = await apiFetch('/api/v1/calendar')
|
|
if (!response.ok) throw new Error('Failed to load jobs')
|
|
jobs.value = await response.json()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'Failed to load calendar'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadUpcoming() {
|
|
upcomingLoading.value = true
|
|
upcomingError.value = ''
|
|
try {
|
|
const response = await apiFetch('/api/v1/calendar/upcoming')
|
|
if (!response.ok) throw new Error('Failed to load upcoming jobs')
|
|
upcoming.value = await response.json()
|
|
} catch (e) {
|
|
upcomingError.value = e instanceof Error ? e.message : 'Failed to load upcoming'
|
|
} finally {
|
|
upcomingLoading.value = false
|
|
}
|
|
}
|
|
|
|
const sortedUpcoming = computed(() => {
|
|
return [...upcoming.value].sort((a, b) => {
|
|
return new Date(a.nextRun).getTime() - new Date(b.nextRun).getTime()
|
|
})
|
|
})
|
|
|
|
onMounted(() => {
|
|
loadJobs()
|
|
loadUpcoming()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="page-heading">
|
|
<div>
|
|
<span class="eyebrow">SCHEDULER</span>
|
|
<h1>Calendar</h1>
|
|
<p>Scheduled jobs and cron tasks across the OpenClaw gateway.</p>
|
|
</div>
|
|
<button class="refresh" @click="loadJobs(); loadUpcoming()">
|
|
<RefreshCw :size="15" :class="{ spin: loading || upcomingLoading }" />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<div class="calendar-layout">
|
|
<!-- Upcoming section -->
|
|
<section class="calendar-panel upcoming-panel">
|
|
<header class="calendar-panel-head">
|
|
<Calendar :size="16" />
|
|
<h2>Upcoming Executions</h2>
|
|
</header>
|
|
<template v-if="upcomingLoading">
|
|
<div class="calendar-status">
|
|
<Loader2 :size="16" class="spin" />
|
|
Loading upcoming jobs...
|
|
</div>
|
|
</template>
|
|
<template v-else-if="upcomingError">
|
|
<div class="calendar-status error">{{ upcomingError }}</div>
|
|
</template>
|
|
<template v-else-if="sortedUpcoming.length">
|
|
<div
|
|
v-for="job in sortedUpcoming.slice(0, 5)"
|
|
:key="job.id"
|
|
class="upcoming-item"
|
|
>
|
|
<div class="upcoming-item-icon">
|
|
<Clock :size="14" />
|
|
</div>
|
|
<div class="upcoming-item-info">
|
|
<strong>{{ job.name }}</strong>
|
|
<span class="upcoming-item-schedule">{{ formatSchedule(job.schedule) }}</span>
|
|
<span class="upcoming-item-next">{{ formatDate(job.nextRun) }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div v-else class="calendar-status">No upcoming jobs</div>
|
|
</section>
|
|
|
|
<!-- All jobs list -->
|
|
<section class="calendar-panel all-jobs-panel">
|
|
<header class="calendar-panel-head">
|
|
<Timer :size="16" />
|
|
<h2>All Scheduled Jobs</h2>
|
|
</header>
|
|
<template v-if="loading">
|
|
<div class="calendar-status">
|
|
<Loader2 :size="16" class="spin" />
|
|
Loading jobs...
|
|
</div>
|
|
</template>
|
|
<template v-else-if="error">
|
|
<div class="calendar-status error">{{ error }}</div>
|
|
</template>
|
|
<template v-else-if="jobs.length">
|
|
<div
|
|
v-for="job in jobs"
|
|
:key="job.id"
|
|
class="job-item"
|
|
>
|
|
<div class="job-item-icon" :class="statusClass(job.status)">
|
|
<component :is="statusIcon(job.status)" :size="14" />
|
|
</div>
|
|
<div class="job-item-info">
|
|
<strong>{{ job.name }}</strong>
|
|
<span class="job-item-id">{{ job.id }}</span>
|
|
<span class="job-item-schedule">{{ formatSchedule(job.schedule) }}</span>
|
|
</div>
|
|
<div class="job-item-meta">
|
|
<span :class="['job-status-badge', statusClass(job.status)]">{{ job.status }}</span>
|
|
<small v-if="job.lastRun">Last: {{ formatDate(job.lastRun) }}</small>
|
|
<small v-if="job.nextRun">Next: {{ formatDate(job.nextRun) }}</small>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div v-else class="calendar-status">No scheduled jobs</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.calendar-layout {
|
|
display: grid;
|
|
grid-template-columns: 1fr 2fr;
|
|
gap: 12px;
|
|
min-height: 360px;
|
|
}
|
|
.calendar-panel {
|
|
border: 1px solid var(--line);
|
|
border-radius: 9px;
|
|
background: var(--panel);
|
|
padding: 16px;
|
|
}
|
|
.calendar-panel-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding-bottom: 12px;
|
|
margin-bottom: 12px;
|
|
border-bottom: 1px solid var(--line);
|
|
}
|
|
.calendar-panel-head h2 {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
color: #e8eaf0;
|
|
}
|
|
.calendar-status {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 32px;
|
|
color: #7e8799;
|
|
font-size: 11px;
|
|
}
|
|
.calendar-status.error {
|
|
color: #e16e75;
|
|
}
|
|
|
|
/* Upcoming jobs */
|
|
.upcoming-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
border-radius: 7px;
|
|
}
|
|
.upcoming-item + .upcoming-item {
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
.upcoming-item-icon {
|
|
flex-shrink: 0;
|
|
width: 28px;
|
|
height: 28px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 6px;
|
|
color: #a99cf5;
|
|
background: rgba(139,124,246,.1);
|
|
}
|
|
.upcoming-item-info strong {
|
|
display: block;
|
|
font-size: 11px;
|
|
margin-bottom: 2px;
|
|
color: #e8eaf0;
|
|
}
|
|
.upcoming-item-schedule {
|
|
display: block;
|
|
font-size: 9px;
|
|
color: #7e8799;
|
|
margin-bottom: 2px;
|
|
}
|
|
.upcoming-item-next {
|
|
display: block;
|
|
font-size: 9px;
|
|
color: #a99cf5;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* All jobs */
|
|
.job-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
padding: 10px;
|
|
border-radius: 7px;
|
|
}
|
|
.job-item + .job-item {
|
|
border-top: 1px solid var(--line);
|
|
}
|
|
.job-item-icon {
|
|
flex-shrink: 0;
|
|
width: 28px;
|
|
height: 28px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 6px;
|
|
}
|
|
.job-item-icon.status-completed {
|
|
color: #27ae60;
|
|
background: rgba(39, 174, 96, 0.12);
|
|
}
|
|
.job-item-icon.status-running {
|
|
color: #3498db;
|
|
background: rgba(52, 152, 219, 0.12);
|
|
}
|
|
.job-item-icon.status-failed {
|
|
color: #e74c3c;
|
|
background: rgba(231, 76, 60, 0.12);
|
|
}
|
|
.job-item-icon.status-idle {
|
|
color: #7e8799;
|
|
background: rgba(126, 135, 153, 0.12);
|
|
}
|
|
.job-item-info {
|
|
flex: 1;
|
|
}
|
|
.job-item-info strong {
|
|
display: block;
|
|
font-size: 11px;
|
|
margin-bottom: 2px;
|
|
color: #e8eaf0;
|
|
}
|
|
.job-item-id {
|
|
display: block;
|
|
font-size: 9px;
|
|
color: #6b7385;
|
|
margin-bottom: 2px;
|
|
font-family: monospace;
|
|
}
|
|
.job-item-schedule {
|
|
display: block;
|
|
font-size: 9px;
|
|
color: #7e8799;
|
|
}
|
|
.job-item-meta {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
gap: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
.job-item-meta small {
|
|
font-size: 8px;
|
|
color: #6b7385;
|
|
text-align: right;
|
|
}
|
|
.job-status-badge {
|
|
font-size: 8px;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
}
|
|
.job-status-badge.status-completed {
|
|
background: rgba(39, 174, 96, 0.15);
|
|
color: #27ae60;
|
|
}
|
|
.job-status-badge.status-running {
|
|
background: rgba(52, 152, 219, 0.15);
|
|
color: #3498db;
|
|
}
|
|
.job-status-badge.status-failed {
|
|
background: rgba(231, 76, 60, 0.15);
|
|
color: #e74c3c;
|
|
}
|
|
.job-status-badge.status-idle {
|
|
background: rgba(126, 135, 153, 0.15);
|
|
color: #7e8799;
|
|
}
|
|
.spin {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.calendar-layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|