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">
|
||||
// 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · {{ 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>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user