feat: Frontend gap closure — Schedules, Alerts, Migration, Changelog views
Implements missing frontend views and API integrations: New Views: - SchedulesView: CRUD for scheduled tasks (restart/announcement/command/plugin_reload) - MigrationView: Export/import interface with file upload and history tracking - ChangelogView: Paginated changelog feed with category badges - ForgotPasswordView: Password reset flow with email submission - AlertsView: Alert config dashboard with threshold settings and history Component Updates: - ErrorBoundary: Global error handler with retry functionality - DashboardLayout: Mobile responsive sidebar, permission-based nav, new menu items - ServerInfoView: Complete rewrite for public server info display Infrastructure: - useApi: Token refresh interceptor with 401 retry and infinite loop prevention - plugins store: Implemented all stubbed methods with real API calls - auth store: Added hasPermission() helper for RBAC UI visibility - Router: Added new routes with catch-all fallback Purpose: Closes frontend implementation gaps. Hardens auth flow, improves mobile UX, enables server automation scheduling, alert configuration, and data migration tools. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
260
frontend/src/views/admin/SchedulesView.vue
Normal file
260
frontend/src/views/admin/SchedulesView.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { Clock, Plus, Edit, Trash2, Power, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
interface ScheduledTask {
|
||||
id: string
|
||||
task_name: string
|
||||
task_type: 'restart' | 'announcement' | 'command' | 'plugin_reload'
|
||||
cron_expression: string
|
||||
timezone: string
|
||||
is_active: boolean
|
||||
next_run: string | null
|
||||
task_config: Record<string, any>
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
const tasks = ref<ScheduledTask[]>([])
|
||||
const isLoading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editingTask = ref<ScheduledTask | null>(null)
|
||||
|
||||
const formData = ref({
|
||||
task_name: '',
|
||||
task_type: 'restart' as 'restart' | 'announcement' | 'command' | 'plugin_reload',
|
||||
cron_expression: '0 0 * * *',
|
||||
timezone: 'UTC',
|
||||
task_config: '{}',
|
||||
})
|
||||
|
||||
const timezones = ['UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Europe/London', 'Europe/Paris', 'Asia/Tokyo']
|
||||
|
||||
async function fetchTasks() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tasks.value = await api.get<ScheduledTask[]>('/schedules/tasks')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editingTask.value = null
|
||||
formData.value = {
|
||||
task_name: '',
|
||||
task_type: 'restart',
|
||||
cron_expression: '0 0 * * *',
|
||||
timezone: 'UTC',
|
||||
task_config: '{}',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(task: ScheduledTask) {
|
||||
editingTask.value = task
|
||||
formData.value = {
|
||||
task_name: task.task_name,
|
||||
task_type: task.task_type,
|
||||
cron_expression: task.cron_expression,
|
||||
timezone: task.timezone,
|
||||
task_config: JSON.stringify(task.task_config, null, 2),
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function saveTask() {
|
||||
try {
|
||||
const payload = {
|
||||
...formData.value,
|
||||
task_config: JSON.parse(formData.value.task_config),
|
||||
}
|
||||
|
||||
if (editingTask.value) {
|
||||
await api.put(`/schedules/tasks/${editingTask.value.id}`, payload)
|
||||
} else {
|
||||
await api.post('/schedules/tasks', payload)
|
||||
}
|
||||
|
||||
showModal.value = false
|
||||
await fetchTasks()
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to save task')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
if (!confirm('Delete this scheduled task?')) return
|
||||
await api.del(`/schedules/tasks/${id}`)
|
||||
await fetchTasks()
|
||||
}
|
||||
|
||||
async function toggleActive(task: ScheduledTask) {
|
||||
await api.put(`/schedules/tasks/${task.id}`, { is_active: !task.is_active })
|
||||
await fetchTasks()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Clock class="w-5 h-5 text-oxide-500" />
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Scheduled Tasks</h1>
|
||||
</div>
|
||||
<button
|
||||
@click="openCreateModal"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-500 hover:bg-oxide-600 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
New Task
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tasks Table -->
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div v-if="isLoading" class="p-8 flex justify-center">
|
||||
<Loader2 class="w-6 h-6 text-oxide-500 animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="tasks.length === 0" class="p-8 text-center text-neutral-500">
|
||||
No scheduled tasks configured.
|
||||
</div>
|
||||
<table v-else class="w-full">
|
||||
<thead class="bg-neutral-800/50 border-b border-neutral-800">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Task Name</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Schedule</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Timezone</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Next Run</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="task in tasks" :key="task.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-200">{{ task.task_name }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ task.task_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3 text-sm font-mono text-neutral-400">{{ task.cron_expression }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ task.timezone }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ task.next_run ? new Date(task.next_run).toLocaleString() : '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="task.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ task.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="toggleActive(task)"
|
||||
class="text-neutral-400 hover:text-oxide-400 transition-colors"
|
||||
:title="task.is_active ? 'Pause' : 'Activate'"
|
||||
>
|
||||
<Power class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openEditModal(task)"
|
||||
class="text-neutral-400 hover:text-oxide-400 transition-colors"
|
||||
>
|
||||
<Edit class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteTask(task.id)"
|
||||
class="text-neutral-400 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
@click.self="showModal = false"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg w-full max-w-lg">
|
||||
<div class="p-5 border-b border-neutral-800">
|
||||
<h2 class="text-lg font-bold text-neutral-100">{{ editingTask ? 'Edit Task' : 'New Task' }}</h2>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Task Name</label>
|
||||
<input
|
||||
v-model="formData.task_name"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
placeholder="Daily restart"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Task Type</label>
|
||||
<select
|
||||
v-model="formData.task_type"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
>
|
||||
<option value="restart">Restart</option>
|
||||
<option value="announcement">Announcement</option>
|
||||
<option value="command">Command</option>
|
||||
<option value="plugin_reload">Plugin Reload</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Cron Expression</label>
|
||||
<input
|
||||
v-model="formData.cron_expression"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
placeholder="0 0 * * *"
|
||||
/>
|
||||
<p class="text-xs text-neutral-500 mt-1">Example: "0 0 * * *" = daily at midnight</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Timezone</label>
|
||||
<select
|
||||
v-model="formData.timezone"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
>
|
||||
<option v-for="tz in timezones" :key="tz" :value="tz">{{ tz }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-neutral-400 mb-2">Task Config (JSON)</label>
|
||||
<textarea
|
||||
v-model="formData.task_config"
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-neutral-200 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-oxide-500"
|
||||
placeholder='{"message": "Server restarting..."}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-5 border-t border-neutral-800 flex justify-end gap-3">
|
||||
<button
|
||||
@click="showModal = false"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveTask"
|
||||
class="px-4 py-2 text-sm font-medium bg-oxide-500 hover:bg-oxide-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
{{ editingTask ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user