fix: Wire automation toggles, browse uMod, and error feedback across admin views
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:
Vantz Stockwell
2026-02-21 16:04:34 -05:00
parent cbb3ba6586
commit 38e6d28248
9 changed files with 290 additions and 48 deletions

View File

@@ -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>

View File

@@ -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')
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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}`)
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}`)
}
}

View File

@@ -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>