fix: Wire automation toggles, browse uMod, and error feedback across admin views
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
- ServerView: automation toggles (crash recovery, auto-update, force wipe eligible) now call updateConfig() on click with toast success/error; all catch blocks get toast feedback instead of silent swallow - PluginsView: Browse uMod tab wired to /plugins/search backend endpoint with debounced search, results table, and Install button that calls installPlugin(); shows install state and marks already-installed plugins - WipesView: dry-run results now displayed in a collapsible panel (would_delete / would_preserve lists + estimated duration); schedule enable/disable toggle wired to PUT /wipes/schedules/:id with loading state and toast feedback - AnalyticsView: catch blocks now show toast errors instead of console.log; Player Retention placeholder replaced with intentional placeholder cards - TeamView, ChatLogView, PlayersView, NotificationsView, MapsView: all empty catch blocks and '// API not wired yet' comments replaced with toast.error() calls; Notifications save now shows success toast Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,11 +5,13 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
|
||||
const loading = ref(true)
|
||||
@@ -45,8 +47,8 @@ const loadAnalytics = async () => {
|
||||
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
} catch (error) {
|
||||
console.error('Failed to load analytics:', error)
|
||||
} catch {
|
||||
toast.error('Failed to load analytics data')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -206,8 +208,8 @@ const downloadCSV = async () => {
|
||||
a.download = `server_stats_${timeRange.value}.csv`
|
||||
a.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to download CSV:', error)
|
||||
} catch {
|
||||
toast.error('Failed to download analytics export')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,12 +306,30 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player Retention (Phase 2.2 placeholder) -->
|
||||
<!-- Player Retention -->
|
||||
<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">Player Retention</h2>
|
||||
<div class="h-48 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
|
||||
<p class="text-sm text-neutral-600">Available in Phase 2.2 — New vs returning players, session duration</p>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Player Retention</h2>
|
||||
<span class="text-xs font-medium px-2 py-0.5 bg-neutral-800 text-neutral-500 rounded-full border border-neutral-700">Phase 2</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">New Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">First-time visitors</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Returning Players</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Seen more than once</p>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 rounded-lg p-4 border border-neutral-800">
|
||||
<p class="text-xs text-neutral-500 mb-1">Avg Session Duration</p>
|
||||
<p class="text-2xl font-bold text-neutral-600">\u2014</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Per visit</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-600 mt-4 text-center">Player retention analytics will be available in Phase 2</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { ChatMessage } from '@/types'
|
||||
import { MessageSquare, Search, Flag, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
@@ -53,7 +55,7 @@ async function fetchMessages() {
|
||||
const data = await api.get<{ messages: ChatMessage[] }>('/chat')
|
||||
messages.value = data.messages
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load chat messages')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -64,7 +66,7 @@ async function toggleFlag(msg: ChatMessage) {
|
||||
await api.put(`/chat/${msg.id}/flag`, { flagged: !msg.flagged })
|
||||
msg.flagged = !msg.flagged
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to update flag')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ async function fetchMaps() {
|
||||
const data = await api.get<{ maps: MapEntry[] }>('/maps')
|
||||
maps.value = data.maps
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load map library')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { NotificationConfig } from '@/types'
|
||||
import { Bell, Save, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const config = ref<NotificationConfig>({
|
||||
discord_webhook_url: null,
|
||||
@@ -38,7 +40,7 @@ async function fetchConfig() {
|
||||
const data = await api.get<{ config: NotificationConfig }>('/notifications/config')
|
||||
config.value = data.config
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load notification settings')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -48,8 +50,9 @@ async function saveConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
await api.put('/notifications/config', config.value)
|
||||
toast.success('Notification settings saved')
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to save notification settings')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const server = useServerStore()
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
interface Player {
|
||||
steam_id: string
|
||||
@@ -70,7 +72,7 @@ async function fetchPlayers() {
|
||||
const data = await api.get<{ players: Player[] }>('/players')
|
||||
players.value = data.players
|
||||
} catch {
|
||||
// API not wired yet — will show empty state
|
||||
toast.error('Failed to load player list')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -80,9 +82,10 @@ async function kickPlayer(steamId: string, name: string) {
|
||||
if (!confirm(`Kick ${name}?`)) return
|
||||
try {
|
||||
await server.sendCommand(`kick ${steamId}`)
|
||||
toast.success(`Kick command sent for ${name}`)
|
||||
await fetchPlayers()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to kick ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +93,10 @@ async function banPlayer(steamId: string, name: string) {
|
||||
if (!confirm(`Ban ${name}? This will also kick them.`)) return
|
||||
try {
|
||||
await server.sendCommand(`ban ${steamId}`)
|
||||
toast.success(`Ban command sent for ${name}`)
|
||||
await fetchPlayers()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to ban ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } 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'
|
||||
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2, Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const pluginStore = usePluginStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const tab = ref<'installed' | 'browse'>('installed')
|
||||
const browseQuery = ref('')
|
||||
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
const installing = ref<string | null>(null)
|
||||
|
||||
const filteredPlugins = computed(() => {
|
||||
let result = pluginStore.plugins
|
||||
@@ -59,6 +62,32 @@ async function handleUninstall(plugin: PluginEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBrowseSearch() {
|
||||
if (!browseQuery.value.trim()) return
|
||||
try {
|
||||
await pluginStore.searchPlugins(browseQuery.value.trim())
|
||||
} catch {
|
||||
toast.error('Failed to search uMod plugins')
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBrowseSearch() {
|
||||
if (browseDebounce.value) clearTimeout(browseDebounce.value)
|
||||
browseDebounce.value = setTimeout(handleBrowseSearch, 400)
|
||||
}
|
||||
|
||||
async function installFromBrowse(result: { name: string }) {
|
||||
installing.value = result.name
|
||||
try {
|
||||
await pluginStore.installPlugin({ plugin_name: result.name, source: 'umod' })
|
||||
toast.success(`${result.name} installed`)
|
||||
} catch {
|
||||
toast.error(`Failed to install ${result.name}`)
|
||||
} finally {
|
||||
installing.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pluginStore.fetchPlugins()
|
||||
})
|
||||
@@ -105,12 +134,23 @@ onMounted(() => {
|
||||
Browse uMod
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative flex-1 max-w-sm">
|
||||
<div v-if="tab === 'installed'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="tab === 'installed' ? 'Search installed plugins...' : 'Search uMod...'"
|
||||
placeholder="Search installed plugins..."
|
||||
class="w-full pl-10 pr-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 v-if="tab === 'browse'" class="relative flex-1 max-w-sm">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model="browseQuery"
|
||||
type="text"
|
||||
placeholder="Search uMod plugins..."
|
||||
@input="scheduleBrowseSearch"
|
||||
@keydown.enter="handleBrowseSearch"
|
||||
class="w-full pl-10 pr-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>
|
||||
@@ -187,11 +227,69 @@ onMounted(() => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Browse uMod (placeholder) -->
|
||||
<div v-if="tab === 'browse'" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">uMod Plugin Browser</h3>
|
||||
<p class="text-sm text-neutral-500">Search and install plugins directly from uMod. Coming soon.</p>
|
||||
<!-- Browse uMod -->
|
||||
<div v-if="tab === 'browse'">
|
||||
<!-- Empty state: no search yet -->
|
||||
<div v-if="!browseQuery.trim() && pluginStore.searchResults.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Search class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">Search uMod</h3>
|
||||
<p class="text-sm text-neutral-500">Type a plugin name above to search the uMod plugin directory.</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="pluginStore.isLoading" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Loader2 class="w-8 h-8 text-neutral-500 animate-spin mx-auto mb-3" />
|
||||
<p class="text-sm text-neutral-500">Searching uMod...</p>
|
||||
</div>
|
||||
|
||||
<!-- No results -->
|
||||
<div v-else-if="browseQuery.trim() && pluginStore.searchResults.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
|
||||
<Download class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
|
||||
<h3 class="text-lg font-medium text-neutral-300 mb-1">No plugins found</h3>
|
||||
<p class="text-sm text-neutral-500">No uMod plugins matched "{{ browseQuery }}". Try a different search term.</p>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-else class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Plugin</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Author</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Version</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr
|
||||
v-for="result in pluginStore.searchResults"
|
||||
:key="result.name"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ result.name }}</p>
|
||||
<p v-if="result.description" class="text-xs text-neutral-500 mt-0.5 truncate max-w-xs">{{ result.description }}</p>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ result.author ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ result.latest_version ?? '\u2014' }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
@click="installFromBrowse(result)"
|
||||
:disabled="installing === result.name || pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name)"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ml-auto"
|
||||
:class="pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name)
|
||||
? 'bg-neutral-700 text-neutral-500 cursor-not-allowed'
|
||||
: 'bg-oxide-600 hover:bg-oxide-700 disabled:opacity-50 text-white'"
|
||||
>
|
||||
<Loader2 v-if="installing === result.name" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Download v-else class="w-3.5 h-3.5" />
|
||||
{{ pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === result.name) ? 'Installed' : installing === result.name ? 'Installing...' : 'Install' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import {
|
||||
Server,
|
||||
Wifi,
|
||||
@@ -23,6 +24,7 @@ import { useWebSocket } from '@/composables/useWebSocket'
|
||||
|
||||
const server = useServerStore()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
|
||||
const editMode = ref(false)
|
||||
const saving = ref(false)
|
||||
@@ -107,7 +109,7 @@ async function startDeploy() {
|
||||
await server.deployServer(deployForm.value)
|
||||
showDeployForm.value = false
|
||||
} catch {
|
||||
// Error handled in store
|
||||
toast.error('Failed to start deployment')
|
||||
} finally {
|
||||
deployLoading.value = false
|
||||
}
|
||||
@@ -162,8 +164,9 @@ async function saveConfig() {
|
||||
try {
|
||||
await server.updateConfig(form.value)
|
||||
editMode.value = false
|
||||
toast.success('Server configuration saved')
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to save server configuration')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -176,13 +179,25 @@ async function serverAction(action: 'start' | 'stop' | 'restart') {
|
||||
else if (action === 'stop') await server.stopServer()
|
||||
else await server.restartServer()
|
||||
await server.fetchServer()
|
||||
toast.success(`Server ${action} command sent`)
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to ${action} server`)
|
||||
} finally {
|
||||
actionLoading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutomation(field: 'crash_recovery_enabled' | 'auto_update_on_force_wipe' | 'force_wipe_eligible') {
|
||||
if (!server.config) return
|
||||
const newValue = !server.config[field]
|
||||
try {
|
||||
await server.updateConfig({ [field]: newValue })
|
||||
toast.success('Automation setting saved')
|
||||
} catch {
|
||||
toast.error('Failed to save automation setting')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await server.fetchServer()
|
||||
loadFormFromConfig()
|
||||
@@ -631,45 +646,51 @@ onMounted(async () => {
|
||||
<p class="text-sm text-neutral-200">Auto-Restart</p>
|
||||
<p class="text-xs text-neutral-500">Restart on crash detection</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('crash_recovery_enabled')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.crash_recovery_enabled ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.crash_recovery_enabled ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">Auto-Update on Force Wipe</p>
|
||||
<p class="text-xs text-neutral-500">Update when Facepunch pushes</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('auto_update_on_force_wipe')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.auto_update_on_force_wipe ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.auto_update_on_force_wipe ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">Force Wipe Eligible</p>
|
||||
<p class="text-xs text-neutral-500">Server participates in force wipes</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer"
|
||||
<button
|
||||
@click="toggleAutomation('force_wipe_eligible')"
|
||||
:disabled="!server.config"
|
||||
class="w-9 h-5 rounded-full transition-colors cursor-pointer disabled:opacity-40"
|
||||
:class="server.config?.force_wipe_eligible ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="server.config?.force_wipe_eligible ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import type { TeamMember, Role } from '@/types'
|
||||
import { UserPlus, Shield, Mail, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const api = useApi()
|
||||
const toast = useToastStore()
|
||||
|
||||
const members = ref<TeamMember[]>([])
|
||||
const roles = ref<Role[]>([])
|
||||
@@ -30,7 +32,7 @@ async function fetchTeam() {
|
||||
members.value = data.members ?? []
|
||||
roles.value = data.roles ?? []
|
||||
} catch {
|
||||
// API not wired yet
|
||||
toast.error('Failed to load team members')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -47,9 +49,10 @@ async function sendInvite() {
|
||||
inviteEmail.value = ''
|
||||
inviteRole.value = ''
|
||||
showInvite.value = false
|
||||
toast.success('Invitation sent')
|
||||
await fetchTeam()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error('Failed to send invitation')
|
||||
} finally {
|
||||
inviting.value = false
|
||||
}
|
||||
@@ -59,9 +62,10 @@ async function removeMember(member: TeamMember) {
|
||||
if (!confirm(`Remove ${member.username} from the team?`)) return
|
||||
try {
|
||||
await api.del(`/team/${member.id}`)
|
||||
toast.success(`${member.username} removed from team`)
|
||||
await fetchTeam()
|
||||
} catch {
|
||||
// Handle error
|
||||
toast.error(`Failed to remove ${member.username}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,29 @@ 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 { useApi } from '@/composables/useApi'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2, Check, X } from 'lucide-vue-next'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const server = useServerStore()
|
||||
const toast = useToastStore()
|
||||
const api = useApi()
|
||||
|
||||
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
|
||||
const selectedProfileId = ref<string>('')
|
||||
const triggerLoading = ref(false)
|
||||
const dryRunLoading = ref(false)
|
||||
const scheduleToggling = ref<string | null>(null)
|
||||
|
||||
interface DryRunResult {
|
||||
would_delete: string[]
|
||||
would_preserve: string[]
|
||||
estimated_duration_seconds: number
|
||||
}
|
||||
|
||||
const dryRunResult = ref<DryRunResult | null>(null)
|
||||
|
||||
async function triggerWipe() {
|
||||
if (!confirm(`Trigger a ${triggerType.value} wipe? This cannot be undone.`)) return
|
||||
@@ -30,8 +41,10 @@ async function triggerWipe() {
|
||||
|
||||
async function triggerDryRun() {
|
||||
dryRunLoading.value = true
|
||||
dryRunResult.value = null
|
||||
try {
|
||||
await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||
const result = await wipeStore.triggerDryRun(triggerType.value, selectedProfileId.value)
|
||||
dryRunResult.value = result
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to run dry-run')
|
||||
} finally {
|
||||
@@ -39,6 +52,19 @@ async function triggerDryRun() {
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSchedule(scheduleId: string, currentlyActive: boolean) {
|
||||
scheduleToggling.value = scheduleId
|
||||
try {
|
||||
await api.put(`/wipes/schedules/${scheduleId}`, { is_active: !currentlyActive })
|
||||
await wipeStore.fetchSchedules()
|
||||
toast.success(`Schedule ${currentlyActive ? 'paused' : 'activated'}`)
|
||||
} catch {
|
||||
toast.error('Failed to update schedule')
|
||||
} finally {
|
||||
scheduleToggling.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await wipeStore.fetchProfiles()
|
||||
if (wipeStore.profiles.length > 0 && wipeStore.profiles[0]) {
|
||||
@@ -135,6 +161,54 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dry-Run Results -->
|
||||
<div v-if="dryRunResult" class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Dry-Run Results</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-neutral-500">
|
||||
Estimated: {{ Math.round(dryRunResult.estimated_duration_seconds) }}s
|
||||
</span>
|
||||
<button
|
||||
@click="dryRunResult = null"
|
||||
class="p-1 text-neutral-500 hover:text-neutral-300 rounded transition-colors"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-red-400 mb-2 flex items-center gap-1.5">
|
||||
<X class="w-3.5 h-3.5" />
|
||||
Would Delete ({{ dryRunResult.would_delete.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_delete.length === 0" class="text-xs text-neutral-600 italic">Nothing to delete</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_delete"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-red-500/5 border border-red-500/10 rounded px-2 py-1"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-green-400 mb-2 flex items-center gap-1.5">
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
Would Preserve ({{ dryRunResult.would_preserve.length }})
|
||||
</p>
|
||||
<div v-if="dryRunResult.would_preserve.length === 0" class="text-xs text-neutral-600 italic">Nothing preserved</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li
|
||||
v-for="item in dryRunResult.would_preserve"
|
||||
:key="item"
|
||||
class="text-xs font-mono text-neutral-400 bg-green-500/5 border border-green-500/10 rounded px-2 py-1"
|
||||
>{{ item }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upcoming Schedules -->
|
||||
<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">Scheduled Wipes</h2>
|
||||
@@ -156,12 +230,28 @@ onMounted(async () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="schedule.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
{{ schedule.is_active ? 'Active' : 'Paused' }}
|
||||
</span>
|
||||
<button
|
||||
@click="toggleSchedule(schedule.id, schedule.is_active)"
|
||||
:disabled="scheduleToggling === schedule.id"
|
||||
class="w-9 h-5 rounded-full transition-colors disabled:opacity-40 cursor-pointer"
|
||||
:class="schedule.is_active ? 'bg-oxide-500' : 'bg-neutral-700'"
|
||||
:title="schedule.is_active ? 'Pause schedule' : 'Activate schedule'"
|
||||
>
|
||||
<Loader2 v-if="scheduleToggling === schedule.id" class="w-3.5 h-3.5 text-white animate-spin mx-auto" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 bg-white rounded-full shadow transition-transform mt-0.5"
|
||||
:class="schedule.is_active ? 'translate-x-4.5' : 'translate-x-0.5'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user