Files
nexus/frontend/src/views/CalendarView.vue
T
bao eeb6174de0 Initial commit: Nexus Mission Control Platform
- 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
2026-06-09 16:31:56 +02:00

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>