feat: Implement Console and Players views — two core admin screens
Console: Terminal-style RCON interface with timestamped output, color-coded log types, command input, clear button, and connection status indicator. Uses server.sendCommand() from the store. Players: Full management table with search, online/offline/all filter tabs, Steam ID display, session time, ping, playtime, admin badges, and kick/ban action buttons. Sorted online-first. Both views use Oxide Orange brand colors per guidelines. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,147 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// TODO: Implement live server console output with command input
|
import { ref, nextTick, onMounted } from 'vue'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
|
import { Send, Terminal, Trash2 } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const server = useServerStore()
|
||||||
|
|
||||||
|
interface ConsoleLine {
|
||||||
|
timestamp: string
|
||||||
|
text: string
|
||||||
|
type: 'info' | 'warning' | 'error' | 'command' | 'system'
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ref<ConsoleLine[]>([])
|
||||||
|
const commandInput = ref('')
|
||||||
|
const consoleEl = ref<HTMLElement | null>(null)
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
function now(): string {
|
||||||
|
return new Date().toLocaleTimeString('en-US', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLine(text: string, type: ConsoleLine['type'] = 'info') {
|
||||||
|
lines.value.push({ timestamp: now(), text, type })
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToBottom() {
|
||||||
|
await nextTick()
|
||||||
|
if (consoleEl.value) {
|
||||||
|
consoleEl.value.scrollTop = consoleEl.value.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
const cmd = commandInput.value.trim()
|
||||||
|
if (!cmd || sending.value) return
|
||||||
|
|
||||||
|
addLine(`> ${cmd}`, 'command')
|
||||||
|
commandInput.value = ''
|
||||||
|
sending.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await server.sendCommand(cmd) as { output?: string }
|
||||||
|
if (result?.output) {
|
||||||
|
addLine(result.output, 'info')
|
||||||
|
} else {
|
||||||
|
addLine('Command sent.', 'system')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
addLine('Failed to send command. Is the server connected?', 'error')
|
||||||
|
} finally {
|
||||||
|
sending.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearConsole() {
|
||||||
|
lines.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineColor(type: ConsoleLine['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'command': return 'text-oxide-400'
|
||||||
|
case 'warning': return 'text-yellow-400'
|
||||||
|
case 'error': return 'text-red-400'
|
||||||
|
case 'system': return 'text-neutral-500'
|
||||||
|
default: return 'text-neutral-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
addLine('Corrosion Console initialized.', 'system')
|
||||||
|
addLine('Type a command and press Enter to send it to the server.', 'system')
|
||||||
|
if (server.connection?.connection_status !== 'connected') {
|
||||||
|
addLine('WARNING: Server is not connected. Commands will fail.', 'warning')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-6">
|
<div class="p-6 h-full flex flex-col">
|
||||||
<h1 class="text-2xl font-bold text-neutral-100 mb-4">Server Console</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">Live console output and command interface for your Rust server.</p>
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Terminal class="w-5 h-5 text-oxide-500" />
|
||||||
|
<h1 class="text-2xl font-bold text-neutral-100">Server Console</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
class="h-2 w-2 rounded-full"
|
||||||
|
:class="server.connection?.connection_status === 'connected' ? 'bg-green-500' : 'bg-red-500'"
|
||||||
|
/>
|
||||||
|
<span class="text-neutral-400">
|
||||||
|
{{ server.connection?.connection_status === 'connected' ? 'Connected' : 'Disconnected' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="clearConsole"
|
||||||
|
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-3.5 h-3.5" />
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Console output -->
|
||||||
|
<div
|
||||||
|
ref="consoleEl"
|
||||||
|
class="flex-1 bg-black/80 border border-neutral-800 rounded-t-lg p-4 overflow-y-auto font-mono text-sm leading-relaxed min-h-0"
|
||||||
|
>
|
||||||
|
<div v-if="lines.length === 0" class="text-neutral-600 italic">
|
||||||
|
No output yet. Send a command to get started.
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(line, i) in lines"
|
||||||
|
:key="i"
|
||||||
|
class="flex gap-3"
|
||||||
|
>
|
||||||
|
<span class="text-neutral-600 shrink-0 select-none">{{ line.timestamp }}</span>
|
||||||
|
<span :class="lineColor(line.type)" class="whitespace-pre-wrap break-all">{{ line.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Command input -->
|
||||||
|
<div class="flex bg-neutral-900 border border-t-0 border-neutral-800 rounded-b-lg overflow-hidden">
|
||||||
|
<span class="flex items-center px-3 text-oxide-500 font-mono text-sm select-none">$</span>
|
||||||
|
<input
|
||||||
|
v-model="commandInput"
|
||||||
|
@keydown.enter="handleSend"
|
||||||
|
type="text"
|
||||||
|
placeholder="say Hello everyone..."
|
||||||
|
:disabled="sending"
|
||||||
|
class="flex-1 bg-transparent py-3 text-neutral-100 placeholder-neutral-600 font-mono text-sm focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="handleSend"
|
||||||
|
:disabled="!commandInput.trim() || sending"
|
||||||
|
class="flex items-center gap-2 px-4 text-sm font-medium text-oxide-400 hover:text-oxide-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Send class="w-4 h-4" />
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,233 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// TODO: Implement player list with kick, ban, and moderation actions
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useServerStore } from '@/stores/server'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
import { Users, Search, Ban, LogOut, Shield, RefreshCw } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const server = useServerStore()
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
steam_id: string
|
||||||
|
display_name: string
|
||||||
|
is_online: boolean
|
||||||
|
ping_ms: number | null
|
||||||
|
connected_at: string | null
|
||||||
|
playtime_seconds: number
|
||||||
|
is_admin: boolean
|
||||||
|
is_banned: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const players = ref<Player[]>([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const filterStatus = ref<'all' | 'online' | 'offline'>('all')
|
||||||
|
|
||||||
|
const filteredPlayers = computed(() => {
|
||||||
|
let result = players.value
|
||||||
|
|
||||||
|
if (filterStatus.value === 'online') {
|
||||||
|
result = result.filter(p => p.is_online)
|
||||||
|
} else if (filterStatus.value === 'offline') {
|
||||||
|
result = result.filter(p => !p.is_online)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchQuery.value.trim()) {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(p =>
|
||||||
|
p.display_name.toLowerCase().includes(q) ||
|
||||||
|
p.steam_id.includes(q)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
if (a.is_online !== b.is_online) return a.is_online ? -1 : 1
|
||||||
|
return a.display_name.localeCompare(b.display_name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const onlineCount = computed(() => players.value.filter(p => p.is_online).length)
|
||||||
|
|
||||||
|
function formatPlaytime(seconds: number): string {
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConnectedTime(iso: string | null): string {
|
||||||
|
if (!iso) return '\u2014'
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(diff / 60000)
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h > 0) return `${h}h ${m % 60}m`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlayers() {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await api.get<{ players: Player[] }>('/players')
|
||||||
|
players.value = data.players
|
||||||
|
} catch {
|
||||||
|
// API not wired yet — will show empty state
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kickPlayer(steamId: string, name: string) {
|
||||||
|
if (!confirm(`Kick ${name}?`)) return
|
||||||
|
try {
|
||||||
|
await server.sendCommand(`kick ${steamId}`)
|
||||||
|
await fetchPlayers()
|
||||||
|
} catch {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function banPlayer(steamId: string, name: string) {
|
||||||
|
if (!confirm(`Ban ${name}? This will also kick them.`)) return
|
||||||
|
try {
|
||||||
|
await server.sendCommand(`ban ${steamId}`)
|
||||||
|
await fetchPlayers()
|
||||||
|
} catch {
|
||||||
|
// Handle error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchPlayers()
|
||||||
|
})
|
||||||
</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">Player Management</h1>
|
<!-- Header -->
|
||||||
<p class="text-neutral-400">View connected players and manage kick, ban, and moderation actions.</p>
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Users class="w-5 h-5 text-oxide-500" />
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-neutral-100">Player Management</h1>
|
||||||
|
<p class="text-sm text-neutral-500 mt-0.5">
|
||||||
|
{{ onlineCount }} online / {{ players.length }} total
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="fetchPlayers"
|
||||||
|
:disabled="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': isLoading }" />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<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="Search by name or Steam ID..."
|
||||||
|
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 class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
|
||||||
|
<button
|
||||||
|
v-for="opt in (['all', 'online', 'offline'] as const)"
|
||||||
|
:key="opt"
|
||||||
|
@click="filterStatus = opt"
|
||||||
|
class="px-3 py-2 text-sm font-medium transition-colors capitalize"
|
||||||
|
:class="filterStatus === opt
|
||||||
|
? 'bg-oxide-500/15 text-oxide-400'
|
||||||
|
: 'text-neutral-400 hover:text-neutral-200'"
|
||||||
|
>
|
||||||
|
{{ opt }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player 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">Player</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Steam ID</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">Session</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Playtime</th>
|
||||||
|
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Ping</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="filteredPlayers.length === 0">
|
||||||
|
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||||
|
<template v-if="isLoading">Loading players...</template>
|
||||||
|
<template v-else-if="searchQuery">No players matching "{{ searchQuery }}"</template>
|
||||||
|
<template v-else>No players found. Server may be offline or API not connected.</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="player in filteredPlayers"
|
||||||
|
:key="player.steam_id"
|
||||||
|
class="hover:bg-neutral-800/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm font-medium text-neutral-100">{{ player.display_name }}</span>
|
||||||
|
<Shield v-if="player.is_admin" class="w-3.5 h-3.5 text-oxide-400" title="Admin" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ player.steam_id }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full"
|
||||||
|
:class="player.is_online
|
||||||
|
? 'bg-green-500/10 text-green-400'
|
||||||
|
: player.is_banned
|
||||||
|
? 'bg-red-500/10 text-red-400'
|
||||||
|
: 'bg-neutral-700/50 text-neutral-400'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="h-1.5 w-1.5 rounded-full"
|
||||||
|
:class="player.is_online ? 'bg-green-500' : player.is_banned ? 'bg-red-500' : 'bg-neutral-500'"
|
||||||
|
/>
|
||||||
|
{{ player.is_banned ? 'Banned' : player.is_online ? 'Online' : 'Offline' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||||
|
{{ player.is_online ? formatConnectedTime(player.connected_at) : '\u2014' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatPlaytime(player.playtime_seconds) }}</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||||
|
{{ player.is_online && player.ping_ms ? `${player.ping_ms}ms` : '\u2014' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1" v-if="player.is_online">
|
||||||
|
<button
|
||||||
|
@click="kickPlayer(player.steam_id, player.display_name)"
|
||||||
|
class="p-1.5 text-neutral-500 hover:text-yellow-400 rounded transition-colors"
|
||||||
|
title="Kick"
|
||||||
|
>
|
||||||
|
<LogOut class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="banPlayer(player.steam_id, player.display_name)"
|
||||||
|
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||||
|
title="Ban"
|
||||||
|
>
|
||||||
|
<Ban class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user