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:
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.4 MiB |
@@ -1,10 +1,88 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Analytics</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Coming soon — player trends, performance metrics, and server analytics.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,117 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Map Library</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Upload custom maps and manage map rotation for your server.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,126 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Module Store</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Browse and purchase add-on modules to extend your panel capabilities.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,170 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Plugin Management</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Manage installed plugins and browse the uMod plugin library.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,140 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Webstore Management</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Manage store items, categories, and pricing for your server webstore.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,138 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Wipe Calendar</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Calendar view of upcoming and completed server wipes.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,99 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Wipe History</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Execution logs for all past wipes with status and details.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,122 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Wipe Profiles</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Create and manage reusable wipe profiles for different reset configurations.</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,180 @@
|
|||||||
<script setup lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Auto-Wiper</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Configure wipe schedules and trigger manual wipes.</p>
|
<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 · {{ 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 }} · {{ 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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user