feat: Build final 9 admin views + swap final hero graphic

Views: Plugins, Wipes, WipeProfiles, WipeCalendar, WipeHistory,
Maps, Analytics, StoreManage, ModuleStore. All 20/20 admin views
now implemented. Updated hero graphic to final version.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell
2026-02-14 23:20:01 -05:00
parent c45567670e
commit a160ba2df4
10 changed files with 1126 additions and 36 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -1,10 +1,88 @@
<script setup lang="ts">
// TODO: Phase 2 — Implement analytics dashboard
import { ref } from 'vue'
import { BarChart3, TrendingUp, Users, Clock } from 'lucide-vue-next'
const timeRange = ref<'24h' | '7d' | '30d'>('7d')
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Analytics</h1>
<p class="text-neutral-400">Coming soon player trends, performance metrics, and server analytics.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<BarChart3 class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Analytics</h1>
</div>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['24h', '7d', '30d'] as const)"
:key="opt"
@click="timeRange = opt"
class="px-3 py-2 text-sm font-medium transition-colors"
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
</div>
<!-- Stat cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Users class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Peak Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">No data yet</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Avg Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">No data yet</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Clock class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Uptime</p>
</div>
<p class="text-2xl font-bold text-neutral-100">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">No data yet</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<BarChart3 class="w-4 h-4 text-neutral-500" />
<p class="text-sm text-neutral-400">Unique Players</p>
</div>
<p class="text-2xl font-bold text-neutral-100">\u2014</p>
<p class="text-xs text-neutral-600 mt-1">No data yet</p>
</div>
</div>
<!-- Chart placeholders -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<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 Count Over Time</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">Chart will render when data is available</p>
</div>
</div>
<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">Server Performance</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">FPS, entity count, and memory usage</p>
</div>
</div>
</div>
<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">New vs returning players, session duration distribution</p>
</div>
</div>
</div>
</template>

View File

@@ -1,10 +1,117 @@
<script setup lang="ts">
// TODO: Implement map library with upload and rotation management
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import type { MapEntry } from '@/types'
import { Map, Upload, Trash2, RefreshCw } from 'lucide-vue-next'
const api = useApi()
const maps = ref<MapEntry[]>([])
const isLoading = ref(false)
function formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function typeBadgeClass(type: string): string {
return type === 'custom' ? 'bg-oxide-500/15 text-oxide-400' : 'bg-blue-500/15 text-blue-400'
}
async function fetchMaps() {
isLoading.value = true
try {
const data = await api.get<{ maps: MapEntry[] }>('/maps')
maps.value = data.maps
} catch {
// API not wired yet
} finally {
isLoading.value = false
}
}
async function deleteMap(map: MapEntry) {
if (!confirm(`Delete ${map.display_name}?`)) return
try {
await api.del(`/maps/${map.id}`)
await fetchMaps()
} catch {
// Handle error
}
}
onMounted(() => {
fetchMaps()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Map Library</h1>
<p class="text-neutral-400">Upload custom maps and manage map rotation for your server.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Map class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Map Library</h1>
<p class="text-sm text-neutral-500 mt-0.5">{{ maps.length }} maps</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
@click="fetchMaps"
:disabled="isLoading"
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
<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
</button>
</div>
</div>
<!-- Maps grid -->
<div v-if="maps.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Map class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Maps</h3>
<p class="text-sm text-neutral-500">Upload custom maps or they'll appear here when procedural maps are generated.</p>
</div>
<div v-else class="grid grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="map in maps"
:key="map.id"
class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden hover:border-neutral-700 transition-colors"
>
<!-- Thumbnail or placeholder -->
<div class="h-32 bg-neutral-800 flex items-center justify-center">
<img v-if="map.thumbnail_path" :src="map.thumbnail_path" :alt="map.display_name" class="w-full h-full object-cover" />
<Map v-else class="w-8 h-8 text-neutral-600" />
</div>
<div class="p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-neutral-100 truncate">{{ map.display_name }}</h3>
<span class="text-xs font-medium px-2 py-0.5 rounded-full shrink-0 ml-2" :class="typeBadgeClass(map.map_type)">
{{ map.map_type }}
</span>
</div>
<div class="flex items-center justify-between text-xs text-neutral-500">
<span>{{ formatSize(map.file_size_bytes) }}</span>
<span v-if="map.world_size">{{ map.world_size }}m</span>
<span v-if="map.seed">Seed: {{ map.seed }}</span>
</div>
<div class="flex justify-end mt-3">
<button
@click="deleteMap(map)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete map"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,10 +1,126 @@
<script setup lang="ts">
// TODO: Implement module store browser for purchasing add-on modules
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { Package, Check, Lock } from 'lucide-vue-next'
const auth = useAuthStore()
interface Module {
slug: string
name: string
description: string
price: string
features: string[]
}
const modules: Module[] = [
{
slug: 'analytics',
name: 'Analytics & Insights',
description: 'Player trends, retention metrics, and server performance dashboards.',
price: '$4.99/mo',
features: ['Player count history', 'Performance graphs', 'Retention reports', 'Export to CSV'],
},
{
slug: 'webstore',
name: 'Webstore',
description: 'Sell kits, ranks, and custom items with Stripe/PayPal checkout.',
price: '$9.99/mo',
features: ['Item catalog', 'Stripe + PayPal', 'Auto-delivery via RCON', 'Transaction history'],
},
{
slug: 'discord_bot',
name: 'Discord Bot',
description: 'Two-way Discord integration with chat relay and command bridge.',
price: '$2.99/mo',
features: ['Chat relay', 'Server status embed', 'Player lookup', 'Admin commands'],
},
{
slug: 'backups',
name: 'Cloud Backups',
description: 'Automated server backups with point-in-time restore.',
price: '$5.99/mo',
features: ['Scheduled backups', 'Manual snapshots', 'One-click restore', '30-day retention'],
},
]
const tab = ref<'available' | 'installed'>('available')
function isEnabled(slug: string): boolean {
return auth.license?.modules_enabled?.includes(slug) ?? false
}
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Module Store</h1>
<p class="text-neutral-400">Browse and purchase add-on modules to extend your panel capabilities.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Package class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Module Store</h1>
</div>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
@click="tab = 'available'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'available' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Available
</button>
<button
@click="tab = 'installed'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'installed' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Installed
</button>
</div>
</div>
<!-- Module cards -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div
v-for="mod in (tab === 'installed' ? modules.filter(m => isEnabled(m.slug)) : modules)"
:key="mod.slug"
class="bg-neutral-900 border rounded-lg p-5 transition-colors"
:class="isEnabled(mod.slug) ? 'border-oxide-500/30' : 'border-neutral-800'"
>
<div class="flex items-start justify-between mb-3">
<div>
<h3 class="text-base font-semibold text-neutral-100">{{ mod.name }}</h3>
<p class="text-sm text-neutral-500 mt-0.5">{{ mod.description }}</p>
</div>
<span class="text-sm font-medium text-oxide-400 shrink-0 ml-4">{{ mod.price }}</span>
</div>
<ul class="space-y-1.5 mb-4">
<li v-for="feat in mod.features" :key="feat" class="flex items-center gap-2 text-sm text-neutral-400">
<Check class="w-3.5 h-3.5 text-oxide-500 shrink-0" />
{{ feat }}
</li>
</ul>
<button
v-if="isEnabled(mod.slug)"
disabled
class="w-full py-2 text-sm font-medium text-green-400 bg-green-500/10 border border-green-500/20 rounded-lg cursor-default"
>
Installed
</button>
<button
v-else
class="w-full flex items-center justify-center gap-2 py-2 text-sm font-medium text-oxide-400 bg-oxide-500/10 hover:bg-oxide-500/20 border border-oxide-500/20 rounded-lg transition-colors"
>
<Lock class="w-3.5 h-3.5" />
Subscribe
</button>
</div>
</div>
<div v-if="tab === 'installed' && modules.filter(m => isEnabled(m.slug)).length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<Package class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Modules Installed</h3>
<p class="text-sm text-neutral-500">Browse available modules to extend your panel.</p>
</div>
</div>
</template>

View File

@@ -1,10 +1,170 @@
<script setup lang="ts">
// TODO: Implement installed plugin list and uMod plugin browser
import { ref, computed, onMounted } from 'vue'
import { usePluginStore } from '@/stores/plugins'
import type { PluginEntry } from '@/types'
import { Puzzle, Search, Download, RefreshCw, Power, PowerOff, Trash2 } from 'lucide-vue-next'
const pluginStore = usePluginStore()
const searchQuery = ref('')
const tab = ref<'installed' | 'browse'>('installed')
const filteredPlugins = computed(() => {
let result = pluginStore.plugins
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
result = result.filter((p: PluginEntry) => p.plugin_name.toLowerCase().includes(q))
}
return result
})
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
function sourceLabel(source: string): string {
switch (source) {
case 'umod': return 'uMod'
case 'corrosion_module': return 'Corrosion'
case 'manual': return 'Manual'
default: return source
}
}
function sourceBadgeClass(source: string): string {
switch (source) {
case 'umod': return 'bg-green-500/10 text-green-400'
case 'corrosion_module': return 'bg-oxide-500/15 text-oxide-400'
default: return 'bg-neutral-700/50 text-neutral-400'
}
}
onMounted(() => {
pluginStore.fetchPlugins()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Plugin Management</h1>
<p class="text-neutral-400">Manage installed plugins and browse the uMod plugin library.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Puzzle class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Plugins</h1>
<p class="text-sm text-neutral-500 mt-0.5">
{{ loadedCount }} loaded / {{ pluginStore.plugins.length }} installed
</p>
</div>
</div>
<button
@click="pluginStore.fetchPlugins()"
:disabled="pluginStore.isLoading"
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 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': pluginStore.isLoading }" />
Refresh
</button>
</div>
<!-- Tabs + Search -->
<div class="flex items-center gap-4">
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
@click="tab = 'installed'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'installed' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Installed
</button>
<button
@click="tab = 'browse'"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tab === 'browse' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
Browse uMod
</button>
</div>
<div 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...'"
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>
<!-- Installed Plugins -->
<div v-if="tab === 'installed'" 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">Version</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Source</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Wipe Behavior</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-if="filteredPlugins.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="pluginStore.isLoading">Loading plugins...</template>
<template v-else>No plugins installed yet.</template>
</td>
</tr>
<tr
v-for="plugin in filteredPlugins"
:key="plugin.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3 text-sm font-medium text-neutral-100">{{ plugin.plugin_name }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ plugin.plugin_version || '\u2014' }}</td>
<td class="px-4 py-3">
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="sourceBadgeClass(plugin.source)">
{{ sourceLabel(plugin.source) }}
</span>
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center gap-1.5 text-xs font-medium"
:class="plugin.is_loaded ? 'text-green-400' : 'text-neutral-500'"
>
<span class="h-1.5 w-1.5 rounded-full" :class="plugin.is_loaded ? 'bg-green-500' : 'bg-neutral-600'" />
{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}
</span>
</td>
<td class="px-4 py-3 text-xs text-neutral-500">
<template v-if="plugin.never_wipe">Never wipe</template>
<template v-else>
{{ [plugin.wipe_on_map && 'Map', plugin.wipe_on_bp && 'BP', plugin.wipe_on_full && 'Full'].filter(Boolean).join(', ') || 'None' }}
</template>
</td>
<td class="px-4 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<button
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">
<Trash2 class="w-4 h-4" />
</button>
</div>
</td>
</tr>
</tbody>
</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>
</div>
</div>
</template>

View File

@@ -1,10 +1,140 @@
<script setup lang="ts">
// TODO: Implement webstore item and category management
import { ref, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import type { WebstoreItem } from '@/types'
import { ShoppingBag, Plus, Trash2, RefreshCw, DollarSign } from 'lucide-vue-next'
const api = useApi()
const items = ref<WebstoreItem[]>([])
const isLoading = ref(false)
function typeBadgeClass(type: string): string {
switch (type) {
case 'kit': return 'bg-blue-500/15 text-blue-400'
case 'rank': return 'bg-purple-500/15 text-purple-400'
case 'currency': return 'bg-yellow-500/15 text-yellow-400'
case 'custom_command': return 'bg-oxide-500/15 text-oxide-400'
default: return 'bg-neutral-700/50 text-neutral-400'
}
}
async function fetchItems() {
isLoading.value = true
try {
const data = await api.get<{ items: WebstoreItem[] }>('/store/items')
items.value = data.items
} catch {
// API not wired yet
} finally {
isLoading.value = false
}
}
async function deleteItem(item: WebstoreItem) {
if (!confirm(`Delete ${item.item_name}?`)) return
try {
await api.del(`/store/items/${item.id}`)
await fetchItems()
} catch {
// Handle error
}
}
onMounted(() => {
fetchItems()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Webstore Management</h1>
<p class="text-neutral-400">Manage store items, categories, and pricing for your server webstore.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<ShoppingBag class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Webstore</h1>
<p class="text-sm text-neutral-500 mt-0.5">{{ items.length }} items</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
@click="fetchItems"
:disabled="isLoading"
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
<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">
<Plus class="w-4 h-4" />
Add Item
</button>
</div>
</div>
<!-- Items table -->
<div 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">Item</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Price</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-if="items.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="isLoading">Loading store items...</template>
<template v-else>No store items yet. Add items to start selling.</template>
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3">
<p class="text-sm font-medium text-neutral-100">{{ item.item_name }}</p>
<p v-if="item.description" class="text-xs text-neutral-500 truncate max-w-xs">{{ item.description }}</p>
</td>
<td class="px-4 py-3">
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)">
{{ item.item_type.replace('_', ' ') }}
</span>
</td>
<td class="px-4 py-3">
<div class="flex items-center gap-1 text-sm text-neutral-200">
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
{{ item.price.toFixed(2) }}
</div>
</td>
<td class="px-4 py-3">
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="item.is_active ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
>
{{ item.is_active ? 'Active' : 'Inactive' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
{{ item.delivery_config.commands.length }} cmd{{ item.delivery_config.commands.length !== 1 ? 's' : '' }}
</td>
<td class="px-4 py-3 text-right">
<button
@click="deleteItem(item)"
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
title="Delete item"
>
<Trash2 class="w-4 h-4" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -1,10 +1,138 @@
<script setup lang="ts">
// TODO: Implement calendar view of scheduled and past wipes
import { ref, computed, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { Calendar, ChevronLeft, ChevronRight } from 'lucide-vue-next'
const wipeStore = useWipeStore()
const currentMonth = ref(new Date())
const monthLabel = computed(() => {
return currentMonth.value.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
})
const calendarDays = computed(() => {
const year = currentMonth.value.getFullYear()
const month = currentMonth.value.getMonth()
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const days: { date: number; inMonth: boolean; hasWipe: boolean; wipeType?: string }[] = []
// Padding before
for (let i = 0; i < firstDay; i++) {
days.push({ date: 0, inMonth: false, hasWipe: false })
}
// Days of month
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const wipe = wipeStore.history.find(w =>
w.started_at?.startsWith(dateStr) || w.completed_at?.startsWith(dateStr)
)
days.push({
date: d,
inMonth: true,
hasWipe: !!wipe,
wipeType: wipe?.wipe_type,
})
}
return days
})
function prevMonth() {
const d = new Date(currentMonth.value)
d.setMonth(d.getMonth() - 1)
currentMonth.value = d
}
function nextMonth() {
const d = new Date(currentMonth.value)
d.setMonth(d.getMonth() + 1)
currentMonth.value = d
}
onMounted(() => {
wipeStore.fetchHistory()
wipeStore.fetchSchedules()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Wipe Calendar</h1>
<p class="text-neutral-400">Calendar view of upcoming and completed server wipes.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center gap-3">
<Calendar class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Wipe Calendar</h1>
</div>
<!-- Month navigation -->
<div class="flex items-center justify-between">
<button @click="prevMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
<ChevronLeft class="w-5 h-5" />
</button>
<h2 class="text-lg font-semibold text-neutral-200">{{ monthLabel }}</h2>
<button @click="nextMonth" class="p-2 text-neutral-400 hover:text-neutral-200 transition-colors">
<ChevronRight class="w-5 h-5" />
</button>
</div>
<!-- Calendar grid -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
<!-- Day headers -->
<div class="grid grid-cols-7 border-b border-neutral-800">
<div
v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="day"
class="py-2 text-center text-xs font-medium text-neutral-500 uppercase"
>
{{ day }}
</div>
</div>
<!-- Day cells -->
<div class="grid grid-cols-7">
<div
v-for="(day, i) in calendarDays"
:key="i"
class="min-h-20 p-2 border-b border-r border-neutral-800 last:border-r-0"
:class="{ 'bg-neutral-800/30': !day.inMonth }"
>
<template v-if="day.inMonth">
<p class="text-sm" :class="day.hasWipe ? 'text-oxide-400 font-bold' : 'text-neutral-400'">
{{ day.date }}
</p>
<div v-if="day.hasWipe" class="mt-1">
<span class="text-xs bg-oxide-500/15 text-oxide-400 px-1.5 py-0.5 rounded">
{{ day.wipeType }}
</span>
</div>
</template>
</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-3">Active Schedules</h2>
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 text-center py-4">
No active schedules.
</div>
<div v-else class="space-y-2">
<div
v-for="schedule in wipeStore.schedules.filter(s => s.is_active)"
:key="schedule.id"
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
>
<div>
<p class="text-sm text-neutral-200">{{ schedule.schedule_name }}</p>
<p class="text-xs text-neutral-500 font-mono">{{ schedule.cron_expression }}</p>
</div>
<p class="text-xs text-neutral-400">
Next: {{ schedule.next_scheduled_run ? new Date(schedule.next_scheduled_run).toLocaleDateString() : 'TBD' }}
</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,10 +1,99 @@
<script setup lang="ts">
// TODO: Implement wipe execution log with status and details
import { onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { History, RefreshCw } from 'lucide-vue-next'
const wipeStore = useWipeStore()
function statusBadgeClass(status: string): string {
switch (status) {
case 'success': return 'bg-green-500/10 text-green-400'
case 'failed':
case 'rolled_back': return 'bg-red-500/10 text-red-400'
case 'wiping':
case 'pre_wipe':
case 'post_wipe': return 'bg-yellow-500/10 text-yellow-400'
default: return 'bg-neutral-700/50 text-neutral-400'
}
}
function duration(start: string | null, end: string | null): string {
if (!start || !end) return '\u2014'
const ms = new Date(end).getTime() - new Date(start).getTime()
const s = Math.floor(ms / 1000)
const m = Math.floor(s / 60)
if (m > 0) return `${m}m ${s % 60}s`
return `${s}s`
}
onMounted(() => {
wipeStore.fetchHistory()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Wipe History</h1>
<p class="text-neutral-400">Execution logs for all past wipes with status and details.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<History class="w-5 h-5 text-oxide-500" />
<div>
<h1 class="text-2xl font-bold text-neutral-100">Wipe History</h1>
<p class="text-sm text-neutral-500 mt-0.5">{{ wipeStore.history.length }} wipes recorded</p>
</div>
</div>
<button
@click="wipeStore.fetchHistory()"
:disabled="wipeStore.isLoading"
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 rounded-lg transition-colors"
>
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': wipeStore.isLoading }" />
Refresh
</button>
</div>
<!-- History table -->
<div 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">Type</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Trigger</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Started</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Duration</th>
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Map</th>
</tr>
</thead>
<tbody class="divide-y divide-neutral-800">
<tr v-if="wipeStore.history.length === 0">
<td colspan="6" class="px-4 py-12 text-center text-neutral-500 text-sm">
<template v-if="wipeStore.isLoading">Loading history...</template>
<template v-else>No wipe history yet.</template>
</td>
</tr>
<tr
v-for="wipe in wipeStore.history"
:key="wipe.id"
class="hover:bg-neutral-800/50 transition-colors"
>
<td class="px-4 py-3 text-sm font-medium text-neutral-100 capitalize">{{ wipe.wipe_type }}</td>
<td class="px-4 py-3 text-sm text-neutral-400 capitalize">{{ wipe.trigger_type.replace('_', ' ') }}</td>
<td class="px-4 py-3">
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="statusBadgeClass(wipe.status)">
{{ wipe.status.replace('_', ' ') }}
</span>
</td>
<td class="px-4 py-3 text-sm text-neutral-400">
{{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : '\u2014' }}
</td>
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
{{ duration(wipe.started_at, wipe.completed_at) }}
</td>
<td class="px-4 py-3 text-sm text-neutral-400">{{ wipe.map_used || '\u2014' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -1,10 +1,122 @@
<script setup lang="ts">
// TODO: Implement wipe profile creation and management
import { ref, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { FileText, Plus, ChevronDown, ChevronRight } from 'lucide-vue-next'
const wipeStore = useWipeStore()
const expandedId = ref<string | null>(null)
function toggle(id: string) {
expandedId.value = expandedId.value === id ? null : id
}
onMounted(() => {
wipeStore.fetchProfiles()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Wipe Profiles</h1>
<p class="text-neutral-400">Create and manage reusable wipe profiles for different reset configurations.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<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">
<Plus class="w-4 h-4" />
New Profile
</button>
</div>
<!-- Profiles -->
<div v-if="wipeStore.profiles.length === 0" class="bg-neutral-900 border border-neutral-800 rounded-lg p-12 text-center">
<FileText class="w-10 h-10 text-neutral-600 mx-auto mb-3" />
<h3 class="text-lg font-medium text-neutral-300 mb-1">No Wipe Profiles</h3>
<p class="text-sm text-neutral-500">Create a profile to define pre-wipe and post-wipe behavior.</p>
</div>
<div v-else class="space-y-3">
<div
v-for="profile in wipeStore.profiles"
: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>
<component :is="expandedId === profile.id ? ChevronDown : ChevronRight" class="w-4 h-4 text-neutral-500" />
</button>
<div v-if="expandedId === profile.id" class="border-t border-neutral-800 p-4">
<div class="grid grid-cols-2 gap-6">
<!-- Pre-wipe -->
<div>
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Pre-Wipe</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">Backup before wipe</span>
<span :class="profile.pre_wipe_config.backup_before_wipe ? 'text-green-400' : 'text-neutral-600'">
{{ profile.pre_wipe_config.backup_before_wipe ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Kick players</span>
<span :class="profile.pre_wipe_config.kick_players_before_wipe ? 'text-green-400' : 'text-neutral-600'">
{{ profile.pre_wipe_config.kick_players_before_wipe ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Final save</span>
<span :class="profile.pre_wipe_config.run_final_save ? 'text-green-400' : 'text-neutral-600'">
{{ profile.pre_wipe_config.run_final_save ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Discord announce</span>
<span :class="profile.pre_wipe_config.discord_pre_announce ? 'text-green-400' : 'text-neutral-600'">
{{ profile.pre_wipe_config.discord_pre_announce ? 'Yes' : 'No' }}
</span>
</div>
</div>
</div>
<!-- Post-wipe -->
<div>
<h4 class="text-xs font-medium text-neutral-400 uppercase tracking-wider mb-3">Post-Wipe</h4>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-neutral-500">Verify server started</span>
<span :class="profile.post_wipe_config.verify_server_started ? 'text-green-400' : 'text-neutral-600'">
{{ profile.post_wipe_config.verify_server_started ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Verify plugins loaded</span>
<span :class="profile.post_wipe_config.verify_plugins_loaded ? 'text-green-400' : 'text-neutral-600'">
{{ profile.post_wipe_config.verify_plugins_loaded ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Rollback on failure</span>
<span :class="profile.post_wipe_config.rollback_on_failure ? 'text-oxide-400' : 'text-neutral-600'">
{{ profile.post_wipe_config.rollback_on_failure ? 'Yes' : 'No' }}
</span>
</div>
<div class="flex justify-between">
<span class="text-neutral-500">Max restart attempts</span>
<span class="text-neutral-300">{{ profile.post_wipe_config.max_restart_attempts }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,10 +1,180 @@
<script setup lang="ts">
// TODO: Implement auto-wiper with schedules and manual trigger
import { ref, onMounted } from 'vue'
import { useWipeStore } from '@/stores/wipe'
import { useServerStore } from '@/stores/server'
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
import { RouterLink } from 'vue-router'
const wipeStore = useWipeStore()
const server = useServerStore()
const triggerType = ref<'map' | 'blueprint' | 'full'>('map')
const triggerLoading = ref(false)
const dryRunLoading = ref(false)
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
} finally {
triggerLoading.value = false
}
}
async function triggerDryRun() {
dryRunLoading.value = true
try {
await wipeStore.triggerDryRun(triggerType.value, '')
} catch {
// Handle error
} finally {
dryRunLoading.value = false
}
}
onMounted(() => {
wipeStore.fetchSchedules()
wipeStore.fetchHistory()
})
</script>
<template>
<div class="p-6">
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Auto-Wiper</h1>
<p class="text-neutral-400">Configure wipe schedules and trigger manual wipes.</p>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<RefreshCw class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Auto-Wiper</h1>
</div>
<div class="flex gap-2">
<RouterLink
to="/wipes/profiles"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Profiles
</RouterLink>
<RouterLink
to="/wipes/calendar"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
Calendar
</RouterLink>
<RouterLink
to="/wipes/history"
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
>
History
</RouterLink>
</div>
</div>
<!-- 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 class="flex items-end gap-4">
<div>
<label class="block text-xs text-neutral-500 mb-2">Wipe Type</label>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['map', 'blueprint', 'full'] as const)"
:key="opt"
@click="triggerType = opt"
class="px-4 py-2 text-sm font-medium transition-colors capitalize"
:class="triggerType === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
</div>
<button
@click="triggerDryRun"
:disabled="dryRunLoading"
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" />
<AlertTriangle v-else class="w-4 h-4" />
Dry Run
</button>
<button
@click="triggerWipe"
:disabled="triggerLoading || 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" />
<Zap v-else class="w-4 h-4" />
Trigger Wipe
</button>
</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>
<div v-if="wipeStore.schedules.length === 0" class="text-sm text-neutral-500 py-4 text-center">
No wipe schedules configured. Create a profile and schedule to automate wipes.
</div>
<div v-else class="space-y-3">
<div
v-for="schedule in wipeStore.schedules"
:key="schedule.id"
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
>
<div class="flex items-center gap-3">
<Clock class="w-4 h-4 text-neutral-500" />
<div>
<p class="text-sm font-medium text-neutral-200">{{ schedule.schedule_name }}</p>
<p class="text-xs text-neutral-500">
{{ schedule.wipe_type }} wipe &middot; {{ schedule.cron_expression }} ({{ schedule.timezone }})
</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>
</div>
</div>
<!-- Recent History -->
<div 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">Recent Wipes</h2>
<RouterLink to="/wipes/history" class="text-sm text-oxide-400 hover:text-oxide-300 transition-colors">
View All
</RouterLink>
</div>
<div v-if="wipeStore.history.length === 0" class="text-sm text-neutral-500 py-4 text-center">
No wipe history yet.
</div>
<div v-else class="space-y-2">
<div
v-for="wipe in wipeStore.history.slice(0, 5)"
:key="wipe.id"
class="flex items-center justify-between p-3 bg-neutral-800/50 rounded-lg"
>
<div>
<p class="text-sm text-neutral-200">{{ wipe.wipe_type }} wipe</p>
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} &middot; {{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : 'Pending' }}</p>
</div>
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"
:class="{
'bg-green-500/10 text-green-400': wipe.status === 'success',
'bg-red-500/10 text-red-400': wipe.status === 'failed' || wipe.status === 'rolled_back',
'bg-yellow-500/10 text-yellow-400': wipe.status === 'wiping' || wipe.status === 'pre_wipe' || wipe.status === 'post_wipe',
'bg-neutral-700/50 text-neutral-400': wipe.status === 'pending',
}"
>
{{ wipe.status }}
</span>
</div>
</div>
</div>
</div>
</template>