feat: Waves 3+4 — frontend wiring, NATS integration, stores (19 files)
All checks were successful
Test Asgard Runner / test (push) Successful in 2s

Frontend:
- Wire Dashboard quick actions (start/stop/trigger wipe) + next wipe schedule
- Wire Console WebSocket streaming for real-time output
- Implement TOTP 2FA challenge flow in LoginView
- Wire Plugin load/unload toggle + uninstall buttons with confirmations
- Wire WipesView profile selector, disable trigger when no profiles
- Build full WipeProfiles create/edit modal with all config fields
- Wire MapsView file upload with multipart FormData
- Fix SettingsView empty catch blocks → toast error messages
- Fix stale localStorage token reads in CSV exports → auth store
- Fix auth store hardcoded permissions → JWT-decoded role permissions
- Fix wipe store onMounted lifecycle bug → explicit subscribe action
- Update EarlyAccessView from countdown to "Now Live" state

Backend:
- Wire wipe trigger to publish NATS cmd (corrosion.{id}.cmd.wipe)
- Wire plugin reload/uninstall to publish NATS cmd
- Expand NatsBridgeService: add files, wipe status, server status subs
- Add PATCH schedules/:id/toggle endpoint for task toggling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-21 13:34:09 -05:00
parent a181ed7ded
commit 8bb6cc0890
19 changed files with 776 additions and 139 deletions

View File

@@ -4,10 +4,12 @@ import { BarChart3, TrendingUp, Users, Clock, Download } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import type { AnalyticsSummary, TimeseriesData } from '@/types'
import { safeFixed } from '@/utils/formatters'
const api = useApi()
const authStore = useAuthStore()
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
const loading = ref(true)
@@ -194,7 +196,7 @@ const downloadCSV = async () => {
const hours = rangeToHours(timeRange.value)
const response = await fetch(`/api/analytics/export?range=${hours}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
'Authorization': `Bearer ${authStore.accessToken}`
}
})
const blob = await response.blob()

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue'
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useServerStore } from '@/stores/server'
import { useWebSocket, type WebSocketMessage } from '@/composables/useWebSocket'
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
const server = useServerStore()
const ws = useWebSocket()
interface ConsoleLine {
timestamp: string
@@ -68,12 +70,32 @@ function lineColor(type: ConsoleLine['type']): string {
}
}
function handleWebSocketMessage(message: WebSocketMessage) {
if (message.type !== 'event') return
if (message.event !== 'console_output') return
const text = message.data?.line ?? message.data?.output ?? message.raw ?? ''
if (text) {
addLine(text, 'info')
}
}
let unsubscribe: (() => void) | null = null
onMounted(() => {
addLine('Corrosion Console initialized.', 'system')
addLine('Type a command and press Enter to send it to the server.', 'system')
if (server.connection?.connection_status !== 'connected') {
addLine('WARNING: Server is not connected. Commands will fail.', 'warning')
}
unsubscribe = ws.subscribe(handleWebSocketMessage)
})
onUnmounted(() => {
if (unsubscribe) {
unsubscribe()
unsubscribe = null
}
})
</script>

View File

@@ -1,13 +1,38 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useServerStore } from '@/stores/server'
import { useWipeStore } from '@/stores/wipe'
const router = useRouter()
const auth = useAuthStore()
const server = useServerStore()
const wipe = useWipeStore()
onMounted(() => {
onMounted(async () => {
server.fetchServer()
try {
await wipe.fetchSchedules()
} catch {
// Non-critical — dashboard still loads without wipe data
}
})
const nextWipeDate = computed<string>(() => {
const upcoming = wipe.schedules
.filter(s => s.is_active && s.next_scheduled_run)
.map(s => new Date(s.next_scheduled_run!))
.sort((a, b) => a.getTime() - b.getTime())
if (upcoming.length === 0) return 'Not Scheduled'
return upcoming[0].toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
})
function statusColor(status: string | undefined): string {
@@ -67,7 +92,7 @@ function formatUptime(seconds: number | undefined): string {
<!-- Next Wipe -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<p class="text-sm text-neutral-400 mb-2">Next Wipe</p>
<p class="text-2xl font-bold text-neutral-100">Not Scheduled</p>
<p class="text-2xl font-bold text-neutral-100">{{ nextWipeDate }}</p>
</div>
<!-- Uptime -->
@@ -83,17 +108,20 @@ function formatUptime(seconds: number | undefined): string {
<div class="flex flex-wrap gap-3">
<button
:disabled="server.connection?.connection_status === 'connected'"
@click="server.startServer()"
class="px-4 py-2.5 bg-green-600/20 hover:bg-green-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-green-400 border border-green-600/30 rounded-lg text-sm font-medium transition-colors"
>
Start Server
</button>
<button
:disabled="server.connection?.connection_status !== 'connected'"
@click="server.stopServer()"
class="px-4 py-2.5 bg-red-600/20 hover:bg-red-600/30 disabled:opacity-30 disabled:cursor-not-allowed text-red-400 border border-red-600/30 rounded-lg text-sm font-medium transition-colors"
>
Stop Server
</button>
<button
@click="router.push('/wipes')"
class="px-4 py-2.5 bg-neutral-800 hover:bg-neutral-700 text-neutral-300 rounded-lg text-sm font-medium transition-colors"
>
Trigger Wipe

View File

@@ -1,14 +1,20 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import type { MapEntry } from '@/types'
import { Map, Upload, Trash2, RefreshCw } from 'lucide-vue-next'
import { Map, Upload, Trash2, RefreshCw, Loader2 } from 'lucide-vue-next'
import { safeFileSize } from '@/utils/formatters'
const api = useApi()
const auth = useAuthStore()
const toast = useToastStore()
const maps = ref<MapEntry[]>([])
const isLoading = ref(false)
const isUploading = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
function formatSize(bytes: number): string {
return safeFileSize(bytes)
@@ -35,8 +41,48 @@ async function deleteMap(map: MapEntry) {
try {
await api.del(`/maps/${map.id}`)
await fetchMaps()
} catch {
// Handle error
toast.success(`${map.display_name} deleted`)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete map')
}
}
function triggerFileInput() {
fileInputRef.value?.click()
}
async function handleFileSelected(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
// Reset the input so the same file can be re-selected if needed
input.value = ''
isUploading.value = true
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/maps', {
method: 'POST',
headers: {
Authorization: `Bearer ${auth.accessToken}`,
},
body: formData,
})
if (!response.ok) {
const err = await response.json().catch(() => ({ message: 'Upload failed' }))
throw new Error(err.message || `HTTP ${response.status}`)
}
toast.success(`${file.name} uploaded successfully`)
await fetchMaps()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
isUploading.value = false
}
}
@@ -64,9 +110,21 @@ onMounted(() => {
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
</button>
<button class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">
<Upload class="w-4 h-4" />
Upload Map
<input
ref="fileInputRef"
type="file"
accept=".map"
class="hidden"
@change="handleFileSelected"
/>
<button
@click="triggerFileInput"
:disabled="isUploading"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-colors"
>
<Loader2 v-if="isUploading" class="w-4 h-4 animate-spin" />
<Upload v-else class="w-4 h-4" />
{{ isUploading ? 'Uploading...' : 'Upload Map' }}
</button>
</div>
</div>

View File

@@ -4,9 +4,11 @@ import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { safeFixed } from '@/utils/formatters'
const api = useApi()
const authStore = useAuthStore()
interface WipeRetentionMetric {
wipe_id: string
@@ -160,7 +162,7 @@ const downloadCSV = async () => {
try {
const response = await fetch(`/api/analytics/retention/export?wipe_count=${wipeCount.value}`, {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`
Authorization: `Bearer ${authStore.accessToken}`
}
})
const blob = await response.blob()

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { usePluginStore } from '@/stores/plugins'
import { useToastStore } from '@/stores/toast'
import type { PluginEntry } from '@/types'
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2 } from 'lucide-vue-next'
const pluginStore = usePluginStore()
const toast = useToastStore()
const searchQuery = ref('')
const tab = ref<'installed' | 'browse'>('installed')
@@ -37,6 +39,26 @@ function sourceBadgeClass(source: string): string {
}
}
async function handleToggleLoad(plugin: PluginEntry) {
try {
await pluginStore.reloadPlugin(plugin.id)
toast.success(`${plugin.plugin_name} ${plugin.is_loaded ? 'unloaded' : 'loaded'} successfully`)
await pluginStore.fetchPlugins()
} catch {
toast.error(`Failed to toggle ${plugin.plugin_name}`)
}
}
async function handleUninstall(plugin: PluginEntry) {
if (!confirm(`Uninstall ${plugin.plugin_name}? This cannot be undone.`)) return
try {
await pluginStore.uninstallPlugin(plugin.id)
toast.success(`${plugin.plugin_name} uninstalled`)
} catch {
toast.error(`Failed to uninstall ${plugin.plugin_name}`)
}
}
onMounted(() => {
pluginStore.fetchPlugins()
})
@@ -144,13 +166,18 @@ onMounted(() => {
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
@click="handleToggleLoad(plugin)"
class="p-1.5 rounded transition-colors"
:class="plugin.is_loaded ? 'text-neutral-500 hover:text-yellow-400' : 'text-neutral-500 hover:text-green-400'"
:title="plugin.is_loaded ? 'Unload' : 'Load'"
>
<component :is="plugin.is_loaded ? PowerOff : Power" class="w-4 h-4" />
</button>
<button class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors" title="Uninstall">
<button
@click="handleUninstall(plugin)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Uninstall"
>
<Trash2 class="w-4 h-4" />
</button>
</div>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useToastStore } from '@/stores/toast'
import { useApi } from '@/composables/useApi'
import { Settings, Key, Globe, User, Save, Loader2, Eye } from 'lucide-vue-next'
const auth = useAuthStore()
const toast = useToastStore()
const api = useApi()
const saving = ref(false)
@@ -49,8 +51,9 @@ async function saveAccount() {
saving.value = true
try {
await api.put('/auth/profile', accountForm.value)
} catch {
// Handle error
toast.success('Account saved successfully')
} catch (err) {
toast.error('Failed to save: ' + (err as Error).message)
} finally {
saving.value = false
}
@@ -60,8 +63,9 @@ async function saveDomain() {
saving.value = true
try {
await api.put('/settings/domain', domainForm.value)
} catch {
// Handle error
toast.success('Domain settings saved successfully')
} catch (err) {
toast.error('Failed to save: ' + (err as Error).message)
} finally {
saving.value = false
}
@@ -71,8 +75,9 @@ async function savePublicSite() {
saving.value = true
try {
await api.put('/settings/public-site', publicSiteForm.value)
} catch {
// Handle error
toast.success('Public site settings saved successfully')
} catch (err) {
toast.error('Failed to save: ' + (err as Error).message)
} finally {
saving.value = false
}

View File

@@ -1,15 +1,134 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { FileText, Plus, ChevronDown, ChevronRight } from 'lucide-vue-next'
import { useToastStore } from '@/stores/toast'
import type { WipeProfile } from '@/types'
import { FileText, Plus, ChevronDown, ChevronRight, Edit2, Trash2, X } from 'lucide-vue-next'
const wipeStore = useWipeStore()
const toast = useToastStore()
const expandedId = ref<string | null>(null)
const showModal = ref(false)
const editingProfile = ref<WipeProfile | null>(null)
const isSaving = ref(false)
const defaultForm = () => ({
profile_name: '',
description: '',
pre_wipe_config: {
enabled: true,
backup_before_wipe: true,
countdown_warnings: [30, 10, 5],
countdown_unit: 'minutes',
countdown_messages: {} as Record<string, string>,
kick_players_before_wipe: false,
kick_message: 'Server is wiping, back soon!',
run_final_save: true,
discord_pre_announce: false,
pushbullet_notify: false,
custom_commands_before: [] as string[],
},
post_wipe_config: {
enabled: true,
verify_server_started: true,
verify_correct_map: true,
verify_plugins_loaded: true,
verify_player_slots_open: false,
max_restart_attempts: 3,
health_check_timeout_seconds: 120,
discord_post_announce: false,
pushbullet_notify: false,
rollback_on_failure: true,
post_wipe_commands: [] as string[],
},
})
const form = ref(defaultForm())
function toggle(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
function openCreateModal() {
editingProfile.value = null
form.value = defaultForm()
showModal.value = true
}
function openEditModal(profile: WipeProfile) {
editingProfile.value = profile
form.value = {
profile_name: profile.profile_name,
description: profile.description || '',
pre_wipe_config: {
enabled: profile.pre_wipe_config.enabled,
backup_before_wipe: profile.pre_wipe_config.backup_before_wipe,
countdown_warnings: [...profile.pre_wipe_config.countdown_warnings],
countdown_unit: profile.pre_wipe_config.countdown_unit,
countdown_messages: { ...profile.pre_wipe_config.countdown_messages },
kick_players_before_wipe: profile.pre_wipe_config.kick_players_before_wipe,
kick_message: profile.pre_wipe_config.kick_message,
run_final_save: profile.pre_wipe_config.run_final_save,
discord_pre_announce: profile.pre_wipe_config.discord_pre_announce,
pushbullet_notify: profile.pre_wipe_config.pushbullet_notify,
custom_commands_before: [...profile.pre_wipe_config.custom_commands_before],
},
post_wipe_config: {
enabled: profile.post_wipe_config.enabled,
verify_server_started: profile.post_wipe_config.verify_server_started,
verify_correct_map: profile.post_wipe_config.verify_correct_map,
verify_plugins_loaded: profile.post_wipe_config.verify_plugins_loaded,
verify_player_slots_open: profile.post_wipe_config.verify_player_slots_open,
max_restart_attempts: profile.post_wipe_config.max_restart_attempts,
health_check_timeout_seconds: profile.post_wipe_config.health_check_timeout_seconds,
discord_post_announce: profile.post_wipe_config.discord_post_announce,
pushbullet_notify: profile.post_wipe_config.pushbullet_notify,
rollback_on_failure: profile.post_wipe_config.rollback_on_failure,
post_wipe_commands: [...profile.post_wipe_config.post_wipe_commands],
},
}
showModal.value = true
}
function closeModal() {
showModal.value = false
editingProfile.value = null
}
async function saveProfile() {
if (!form.value.profile_name.trim()) {
toast.error('Profile name is required')
return
}
isSaving.value = true
try {
if (editingProfile.value) {
await wipeStore.updateProfile(editingProfile.value.id, form.value)
toast.success(`Profile "${form.value.profile_name}" updated`)
} else {
await wipeStore.createProfile(form.value)
toast.success(`Profile "${form.value.profile_name}" created`)
}
closeModal()
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to save profile')
} finally {
isSaving.value = false
}
}
async function deleteProfile(profile: WipeProfile) {
if (!confirm(`Delete profile "${profile.profile_name}"? This cannot be undone.`)) return
try {
await wipeStore.deleteProfile(profile.id)
toast.success(`Profile "${profile.profile_name}" deleted`)
if (expandedId.value === profile.id) expandedId.value = null
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to delete profile')
}
}
onMounted(() => {
wipeStore.fetchProfiles()
})
@@ -23,7 +142,10 @@ onMounted(() => {
<FileText class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Wipe Profiles</h1>
</div>
<button class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors">
<button
@click="openCreateModal"
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<Plus class="w-4 h-4" />
New Profile
</button>
@@ -42,16 +164,34 @@ onMounted(() => {
:key="profile.id"
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden"
>
<button
@click="toggle(profile.id)"
class="w-full flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
>
<div>
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
<p class="text-xs text-neutral-500 mt-0.5">{{ profile.description || 'No description' }}</p>
<div class="flex items-center">
<button
@click="toggle(profile.id)"
class="flex-1 flex items-center justify-between p-4 text-left hover:bg-neutral-800/50 transition-colors"
>
<div>
<h3 class="text-sm font-medium text-neutral-100">{{ profile.profile_name }}</h3>
<p class="text-xs text-neutral-500 mt-0.5">{{ profile.description || 'No description' }}</p>
</div>
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
</button>
<div class="flex items-center gap-1 pr-4">
<button
@click="openEditModal(profile)"
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
title="Edit"
>
<Edit2 class="w-4 h-4" />
</button>
<button
@click="deleteProfile(profile)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
</button>
</div>
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
<div class="grid grid-cols-2 gap-6">
@@ -119,4 +259,164 @@ onMounted(() => {
</div>
</div>
</div>
<!-- Create / Edit Modal -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
@click.self="closeModal"
>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Modal header -->
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
<h2 class="text-xl font-bold text-neutral-100">{{ editingProfile ? 'Edit Profile' : 'New Profile' }}</h2>
<button @click="closeModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
<X class="w-5 h-5" />
</button>
</div>
<div class="p-6 space-y-6">
<!-- Basic Info -->
<div class="space-y-4">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Profile Name</label>
<input
v-model="form.profile_name"
type="text"
placeholder="Default Wipe Profile"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
<textarea
v-model="form.description"
rows="2"
placeholder="Standard wipe configuration for monthly force wipes"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
/>
</div>
</div>
<!-- Pre-Wipe Config -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Pre-Wipe</h3>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Enabled
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.backup_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Backup before wipe
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.run_final_save" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Run final save
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.kick_players_before_wipe" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Kick players before wipe
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.discord_pre_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Discord pre-announce
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.pre_wipe_config.pushbullet_notify" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Pushbullet notify
</label>
</div>
<div v-if="form.pre_wipe_config.kick_players_before_wipe">
<label class="block text-sm font-medium text-neutral-300 mb-2">Kick Message</label>
<input
v-model="form.pre_wipe_config.kick_message"
type="text"
placeholder="Server is wiping, back soon!"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
</div>
<!-- Post-Wipe Config -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-xs font-medium text-neutral-400 uppercase tracking-wider">Post-Wipe</h3>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.enabled" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Enabled
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_server_started" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify server started
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_correct_map" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify correct map
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_plugins_loaded" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify plugins loaded
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.verify_player_slots_open" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Verify player slots open
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.rollback_on_failure" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Rollback on failure
</label>
<label class="flex items-center gap-2 text-sm text-neutral-300 cursor-pointer">
<input v-model="form.post_wipe_config.discord_post_announce" type="checkbox" class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50" />
Discord post-announce
</label>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Max Restart Attempts</label>
<input
v-model.number="form.post_wipe_config.max_restart_attempts"
type="number"
min="1"
max="10"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
<div>
<label class="block text-sm font-medium text-neutral-300 mb-2">Health Check Timeout (seconds)</label>
<input
v-model.number="form.post_wipe_config.health_check_timeout_seconds"
type="number"
min="30"
max="600"
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
/>
</div>
</div>
</div>
</div>
<!-- Modal footer -->
<div class="sticky bottom-0 bg-neutral-900 border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
<button
@click="closeModal"
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="saveProfile"
:disabled="isSaving"
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 rounded-lg transition-colors"
>
{{ isSaving ? 'Saving...' : (editingProfile ? 'Save Changes' : 'Create Profile') }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -2,14 +2,17 @@
import { ref, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useServerStore } from '@/stores/server'
import { useToastStore } from '@/stores/toast'
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
import { RouterLink } from 'vue-router'
import { safeDate } from '@/utils/formatters'
const wipeStore = useWipeStore()
const server = useServerStore()
const toast = useToastStore()
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
const selectedProfileId = ref<string>('')
const triggerLoading = ref(false)
const dryRunLoading = ref(false)
@@ -17,9 +20,9 @@ async function triggerWipe() {
if (!confirm(`Trigger a ${triggerType.value} wipe? This cannot be undone.`)) return
triggerLoading.value = true
try {
await wipeStore.triggerWipe(triggerType.value, '')
} catch {
// Handle error
await wipeStore.triggerWipe(triggerType.value, selectedProfileId.value)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to trigger wipe')
} finally {
triggerLoading.value = false
}
@@ -28,15 +31,19 @@ async function triggerWipe() {
async function triggerDryRun() {
dryRunLoading.value = true
try {
await wipeStore.triggerDryRun(triggerType.value, '')
} catch {
// Handle error
await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to run dry-run')
} finally {
dryRunLoading.value = false
}
}
onMounted(() => {
onMounted(async () => {
await wipeStore.fetchProfiles()
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
selectedProfileId.value = wipeStore.profiles[0].id
}
wipeStore.fetchSchedules()
wipeStore.fetchHistory()
})
@@ -75,6 +82,10 @@ onMounted(() => {
<!-- Manual Trigger -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">Manual Wipe</h2>
<div v-if="wipeStore.profiles.length === 0" class="mb-4 flex items-center gap-2 text-sm text-yellow-400 bg-yellow-500/10 border border-yellow-500/20 rounded-lg px-4 py-2">
<AlertTriangle class="w-4 h-4 shrink-0" />
No wipe profiles found. <RouterLink to="/wipes/profiles" class="underline hover:text-yellow-300 ml-1">Create a profile</RouterLink> before triggering a wipe.
</div>
<div class="flex items-end gap-4">
<div>
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
@@ -90,9 +101,22 @@ onMounted(() => {
</button>
</div>
</div>
<div>
<label class="block text-xs text-neutral-500 mb-2">Profile</label>
<select
v-model="selectedProfileId"
:disabled="wipeStore.profiles.length === 0"
class="px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors disabled:opacity-50"
>
<option value="">No profile</option>
<option v-for="profile in wipeStore.profiles" :key="profile.id" :value="profile.id">
{{ profile.profile_name }}
</option>
</select>
</div>
<button
@click="triggerDryRun"
:disabled="dryRunLoading"
:disabled="dryRunLoading || wipeStore.profiles.length === 0"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 border border-neutral-700 rounded-lg transition-colors"
>
<Loader2 v-if="dryRunLoading" class="w-4 h-4 animate-spin" />
@@ -101,7 +125,7 @@ onMounted(() => {
</button>
<button
@click="triggerWipe"
:disabled="triggerLoading || server.connection?.connection_status !== 'connected'"
:disabled="triggerLoading || wipeStore.profiles.length === 0 || server.connection?.connection_status !== 'connected'"
class="flex items-center gap-2 px-4 py-2 text-sm font-medium bg-red-600 hover:bg-red-700 disabled:opacity-40 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
<Loader2 v-if="triggerLoading" class="w-4 h-4 animate-spin" />