Files
corrosion-admin-panel/frontend/src/views/admin/PluginsView.vue
Vantz Stockwell 180631989a
All checks were successful
Build Host Agent / build (push) Successful in 28s
Test Asgard Runner / test (push) Successful in 3s
fix(panel): real auto-updating version + remove fake agent footer; rename companion -> Corrosion host agent
Version badge: was hardcoded '1.0.8' — now single-sourced from frontend/package.json (1.0.0) via Vite define __APP_VERSION__, so it auto-updates on release. Sidebar agent footer: removed the FABRICATED 'asgard-01' host name and the fake 'Agent v1.0.8' line — now shows real server.connection data, or an honest 'No host agent connected' empty state when nothing is deployed (the operator's actual state). Renamed 'Companion agent' -> 'Corrosion host agent' across the UI (ServerView/SetupWizard/Dashboard/Plugins), the binary names (corrosion-host-agent-<os>-<arch>) + CDN path (/host-agent/), the Go Makefile build output, and the Gitea CI workflow — frontend download links and CI output now match. Marketing hero mock host names neutralized (asgard-01 -> rust-host/dune-host/conan-host). DB column names (companion_last_seen) left intact. Build green; zero 'asgard'/'1.0.8' remain in frontend/src.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 09:03:37 -04:00

608 lines
21 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { usePluginStore } from '@/stores/plugins'
import type { UmodPlugin } from '@/stores/plugins'
import { useToastStore } from '@/stores/toast'
import type { PluginEntry } from '@/types'
import Panel from '@/components/ds/data/Panel.vue'
import Button from '@/components/ds/core/Button.vue'
import Badge from '@/components/ds/core/Badge.vue'
import Icon from '@/components/ds/core/Icon.vue'
import IconButton from '@/components/ds/core/IconButton.vue'
import Input from '@/components/ds/forms/Input.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Alert from '@/components/ds/feedback/Alert.vue'
import Tabs from '@/components/ds/navigation/Tabs.vue'
const pluginStore = usePluginStore()
const toast = useToastStore()
const searchQuery = ref('')
const tab = ref<'installed' | 'browse' | 'upload'>('installed')
const browseQuery = ref('')
const browsePage = ref(1)
const browseDebounce = ref<ReturnType<typeof setTimeout> | null>(null)
const installing = ref<string | null>(null)
// Upload state
const uploadFile = ref<File | null>(null)
const isDragOver = ref(false)
const isUploading = ref(false)
const uploadInput = ref<HTMLInputElement | null>(null)
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 browsePlugins = computed(() => pluginStore.browseResults?.data ?? [])
const loadedCount = computed(() => pluginStore.plugins.filter((p: PluginEntry) => p.is_loaded).length)
const tabItems = [
{ value: 'installed', label: 'Installed' },
{ value: 'browse', label: 'Browse uMod' },
{ value: 'upload', label: 'Upload custom' },
]
function sourceLabel(source: string): string {
switch (source) {
case 'umod': return 'uMod'
case 'corrosion_module': return 'Corrosion'
case 'manual': return 'Manual'
default: return source
}
}
function sourceTone(source: string): 'online' | 'accent' | 'neutral' {
switch (source) {
case 'umod': return 'online'
case 'corrosion_module': return 'accent'
default: return 'neutral'
}
}
async function handleToggleLoad(plugin: PluginEntry) {
try {
await pluginStore.reloadPlugin(plugin.id)
toast.success(`${plugin.plugin_name} ${plugin.is_loaded ? 'unloaded' : 'loaded'} successfully`)
await pluginStore.fetchPlugins()
} catch {
toast.error(`Failed to toggle ${plugin.plugin_name}`)
}
}
async function handleUninstall(plugin: PluginEntry) {
if (!confirm(`Uninstall ${plugin.plugin_name}? This cannot be undone.`)) return
try {
await pluginStore.uninstallPlugin(plugin.id)
toast.success(`${plugin.plugin_name} uninstalled`)
} catch {
toast.error(`Failed to uninstall ${plugin.plugin_name}`)
}
}
async function handleBrowseSearch(page = 1) {
if (!browseQuery.value.trim()) return
browsePage.value = page
try {
await pluginStore.browseUmod(browseQuery.value.trim(), page)
} catch {
toast.error('Failed to search uMod plugins')
}
}
function scheduleBrowseSearch() {
if (browseDebounce.value) clearTimeout(browseDebounce.value)
browseDebounce.value = setTimeout(() => handleBrowseSearch(1), 400)
}
function browsePrev() {
if (browsePage.value > 1) handleBrowseSearch(browsePage.value - 1)
}
function browseNext() {
if (pluginStore.browseResults && browsePage.value < pluginStore.browseResults.last_page) {
handleBrowseSearch(browsePage.value + 1)
}
}
async function installFromBrowse(result: UmodPlugin) {
installing.value = result.name
try {
await pluginStore.installPlugin({ plugin_name: result.name, umod_slug: result.slug, source: 'umod' })
toast.success(`${result.name} installed`)
} catch {
toast.error(`Failed to install ${result.name}`)
} finally {
installing.value = null
}
}
// Upload helpers
function isAlreadyInstalled(name: string): boolean {
return pluginStore.plugins.some((p: PluginEntry) => p.plugin_name === name)
}
function validateCsFile(file: File): string | null {
if (!file.name.toLowerCase().endsWith('.cs')) {
return 'Only .cs plugin files are accepted'
}
if (file.size > 5 * 1024 * 1024) {
return 'File must be under 5 MB'
}
return null
}
function handleFilePick(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const err = validateCsFile(file)
if (err) { toast.error(err); return }
uploadFile.value = file
}
function handleDrop(event: DragEvent) {
isDragOver.value = false
const file = event.dataTransfer?.files[0]
if (!file) return
const err = validateCsFile(file)
if (err) { toast.error(err); return }
uploadFile.value = file
}
function clearUpload() {
uploadFile.value = null
if (uploadInput.value) uploadInput.value.value = ''
}
async function handleUpload() {
if (!uploadFile.value) return
isUploading.value = true
try {
await pluginStore.uploadPlugin(uploadFile.value)
toast.success(`${uploadFile.value.name} uploaded successfully`)
clearUpload()
tab.value = 'installed'
} catch (err) {
toast.error((err as Error).message || 'Upload failed')
} finally {
isUploading.value = false
}
}
onMounted(() => {
pluginStore.fetchPlugins()
})
</script>
<template>
<div class="plv">
<!-- Page head -->
<div class="plv__head">
<div class="plv__head-id">
<div class="plv__head-chip">
<Icon name="puzzle" :size="20" :stroke-width="2" />
</div>
<div>
<div class="t-eyebrow">Plugin management</div>
<h1 class="plv__title">Plugins</h1>
</div>
</div>
<div class="plv__head-actions">
<div class="plv__stat-pill">
<span class="plv__stat-num">{{ loadedCount }}</span>
<span class="plv__stat-sep">/</span>
<span class="plv__stat-total">{{ pluginStore.plugins.length }}</span>
<span class="plv__stat-label">loaded</span>
</div>
<Button
variant="secondary"
size="sm"
icon="refresh-cw"
:loading="pluginStore.isLoading"
:disabled="pluginStore.isLoading"
@click="pluginStore.fetchPlugins()"
>Refresh</Button>
</div>
</div>
<!-- Tab bar + search -->
<div class="plv__toolbar">
<Tabs v-model="tab" :items="tabItems" />
<Input
v-if="tab === 'installed'"
v-model="searchQuery"
icon="search"
placeholder="Search installed plugins…"
size="sm"
style="max-width: 280px;"
/>
<Input
v-if="tab === 'browse'"
v-model="browseQuery"
icon="search"
placeholder="Search uMod plugins…"
size="sm"
style="max-width: 280px;"
@input="scheduleBrowseSearch"
@keydown.enter="handleBrowseSearch(1)"
/>
</div>
<!-- ===== INSTALLED TAB ===== -->
<Panel v-if="tab === 'installed'" :flush-body="true">
<EmptyState
v-if="filteredPlugins.length === 0 && !pluginStore.isLoading"
icon="puzzle"
title="No plugins installed"
:description="searchQuery ? `No plugins matching &quot;${searchQuery}&quot;.` : 'Install plugins from the Browse uMod tab or upload a custom .cs file.'"
/>
<div v-else-if="pluginStore.isLoading && filteredPlugins.length === 0" class="plv__loading">
<Icon name="loader" :size="20" class="plv__spin" />
<span>Loading plugins</span>
</div>
<table v-else class="plv__table">
<thead>
<tr>
<th>Plugin</th>
<th>Version</th>
<th>Source</th>
<th>Status</th>
<th>Wipe behavior</th>
<th class="plv__th-right">Actions</th>
</tr>
</thead>
<tbody>
<tr
v-for="plugin in filteredPlugins"
:key="plugin.id"
>
<td class="plv__plugin-name">{{ plugin.plugin_name }}</td>
<td class="plv__mono">{{ plugin.plugin_version ?? '—' }}</td>
<td>
<Badge :tone="sourceTone(plugin.source)" size="md">{{ sourceLabel(plugin.source) }}</Badge>
</td>
<td>
<Badge
:tone="plugin.is_loaded ? 'online' : 'offline'"
:dot="true"
:pulse="plugin.is_loaded"
>{{ plugin.is_loaded ? 'Loaded' : 'Unloaded' }}</Badge>
</td>
<td class="plv__secondary plv__wipe-cell">
<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="plv__td-right">
<div class="plv__row-actions">
<IconButton
:icon="plugin.is_loaded ? 'power' : 'play'"
variant="ghost"
size="sm"
:label="plugin.is_loaded ? 'Unload' : 'Load'"
@click="handleToggleLoad(plugin)"
/>
<IconButton
icon="trash-2"
variant="danger"
size="sm"
label="Uninstall"
@click="handleUninstall(plugin)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</Panel>
<!-- ===== BROWSE UMOD TAB ===== -->
<div v-if="tab === 'browse'">
<!-- No search yet -->
<Panel v-if="!browseQuery.trim() && browsePlugins.length === 0">
<EmptyState
icon="search"
title="Search uMod"
description="Type a plugin name above to search the uMod plugin directory."
/>
</Panel>
<!-- Loading -->
<Panel v-else-if="pluginStore.isBrowseLoading">
<div class="plv__loading">
<Icon name="loader" :size="20" class="plv__spin" />
<span>Searching uMod</span>
</div>
</Panel>
<!-- No results -->
<Panel v-else-if="browseQuery.trim() && browsePlugins.length === 0">
<EmptyState
icon="download"
title="No plugins found"
:description="`No uMod plugins matched &quot;${browseQuery}&quot;. Try a different search term.`"
/>
</Panel>
<!-- Results -->
<Panel v-else :flush-body="true">
<template #actions>
<span v-if="pluginStore.browseResults" class="plv__browse-meta">
{{ pluginStore.browseResults.total.toLocaleString() }} plugins
&middot; page {{ pluginStore.browseResults.current_page }}/{{ pluginStore.browseResults.last_page }}
</span>
<Button
variant="ghost"
size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
@click="browsePrev"
>Prev</Button>
<Button
variant="ghost"
size="sm"
icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
@click="browseNext"
>Next</Button>
</template>
<table class="plv__table">
<thead>
<tr>
<th>Plugin</th>
<th>Author</th>
<th>Version</th>
<th>Downloads</th>
<th class="plv__th-right">Action</th>
</tr>
</thead>
<tbody>
<tr
v-for="result in browsePlugins"
:key="result.name"
>
<td>
<div class="plv__browse-name">{{ result.title }}</div>
<div v-if="result.description" class="plv__browse-desc">{{ result.description }}</div>
</td>
<td class="plv__secondary">{{ result.author ?? '—' }}</td>
<td class="plv__mono">{{ result.latest_release_version_formatted ?? '—' }}</td>
<td class="plv__secondary plv__mono">{{ result.downloads_shortened ?? '—' }}</td>
<td class="plv__td-right">
<Button
size="sm"
:variant="isAlreadyInstalled(result.name) ? 'secondary' : 'primary'"
:icon="installing === result.name ? 'loader' : 'download'"
:loading="installing === result.name"
:disabled="installing === result.name || isAlreadyInstalled(result.name)"
@click="installFromBrowse(result)"
>{{ isAlreadyInstalled(result.name) ? 'Installed' : 'Install' }}</Button>
</td>
</tr>
</tbody>
</table>
<!-- Bottom pagination -->
<div v-if="pluginStore.browseResults && pluginStore.browseResults.last_page > 1" class="plv__browse-foot">
<span class="plv__browse-meta">
Page {{ pluginStore.browseResults.current_page }} of {{ pluginStore.browseResults.last_page }}
</span>
<div class="plv__browse-pag">
<Button
variant="secondary"
size="sm"
icon="chevron-left"
:disabled="browsePage <= 1 || pluginStore.isBrowseLoading"
@click="browsePrev"
>Previous</Button>
<Button
variant="secondary"
size="sm"
icon-right="chevron-right"
:disabled="!pluginStore.browseResults || browsePage >= pluginStore.browseResults.last_page || pluginStore.isBrowseLoading"
@click="browseNext"
>Next</Button>
</div>
</div>
</Panel>
</div>
<!-- ===== UPLOAD CUSTOM TAB ===== -->
<div v-if="tab === 'upload'" class="plv__upload-wrap">
<Panel title="Upload custom plugin" subtitle="Upload .cs plugin files from Lone Wolf, Codefling, or your own code. Max 5 MB.">
<!-- Drop zone -->
<div
class="plv__dropzone"
:class="{
'plv__dropzone--active': isDragOver,
'plv__dropzone--ready': !!uploadFile && !isDragOver,
}"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="handleDrop"
@click="uploadInput?.click()"
>
<input
ref="uploadInput"
type="file"
accept=".cs"
class="plv__file-hidden"
@change="handleFilePick"
/>
<!-- No file -->
<template v-if="!uploadFile">
<div class="plv__drop-icon">
<Icon name="upload" :size="22" :stroke-width="1.75" />
</div>
<p class="plv__drop-label">Drop your .cs file here</p>
<p class="plv__drop-hint">or click to browse</p>
</template>
<!-- File selected -->
<template v-else>
<div class="plv__file-row">
<div class="plv__file-icon">
<Icon name="puzzle" :size="20" :stroke-width="1.75" />
</div>
<div class="plv__file-info">
<div class="plv__file-name">{{ uploadFile.name }}</div>
<div class="plv__file-size">{{ (uploadFile.size / 1024).toFixed(1) }} KB</div>
</div>
<IconButton
icon="x"
variant="ghost"
size="sm"
label="Remove"
@click.stop="clearUpload"
/>
</div>
</template>
</div>
<!-- Actions -->
<div class="plv__upload-actions">
<Button
icon="upload"
:loading="isUploading"
:disabled="!uploadFile || isUploading"
@click="handleUpload"
>{{ isUploading ? 'Uploading…' : 'Upload plugin' }}</Button>
<Button
v-if="uploadFile"
variant="ghost"
@click="clearUpload"
>Cancel</Button>
</div>
</Panel>
<Alert tone="info">
The plugin will be registered in your plugin list immediately. Your host agent must be connected
for the file to be delivered to the game server. If the agent is offline, re-upload once it reconnects.
</Alert>
</div>
</div>
</template>
<style scoped>
.plv { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
/* Page head */
.plv__head {
display: flex; align-items: flex-end; justify-content: space-between;
flex-wrap: wrap; gap: 12px;
}
.plv__head-id { display: flex; align-items: center; gap: 12px; }
.plv__head-chip {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
color: var(--accent); background: var(--accent-soft);
box-shadow: inset 0 0 0 1px var(--accent-border);
}
.plv__title {
font-size: var(--text-2xl); font-weight: 700; letter-spacing: -0.02em;
color: var(--text-primary); margin-top: 3px;
}
.plv__head-actions { display: flex; align-items: center; gap: 12px; }
/* Stat pill */
.plv__stat-pill {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
}
.plv__stat-num { font-weight: 700; color: var(--accent-text); }
.plv__stat-sep { color: var(--text-muted); }
.plv__stat-total { color: var(--text-tertiary); }
.plv__stat-label { font-family: var(--font-sans); font-size: var(--text-xs); color: var(--text-tertiary); }
/* Toolbar */
.plv__toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* Loading */
.plv__loading {
display: flex; align-items: center; justify-content: center; gap: 10px;
padding: 48px; color: var(--text-tertiary); font-size: var(--text-sm);
}
@keyframes plv-spin { to { transform: rotate(360deg); } }
.plv__spin { animation: plv-spin 0.7s linear infinite; }
/* Table */
.plv__table { width: 100%; border-collapse: collapse; }
.plv__table th {
padding: 10px 14px; text-align: left;
font-size: var(--text-xs); font-weight: 600; color: var(--text-tertiary);
text-transform: uppercase; letter-spacing: var(--tracking-wider);
border-bottom: 1px solid var(--border-subtle);
}
.plv__table td {
padding: 10px 14px; font-size: var(--text-sm);
color: var(--text-primary); border-bottom: 1px solid var(--border-subtle);
}
.plv__table tbody tr:last-child td { border-bottom: 0; }
.plv__table tbody tr:hover td { background: var(--surface-hover); }
.plv__th-right { text-align: right; }
.plv__td-right { text-align: right; }
.plv__plugin-name { font-weight: 500; }
.plv__mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-xs) !important; color: var(--text-secondary) !important; }
.plv__secondary { color: var(--text-secondary) !important; }
.plv__wipe-cell { font-size: var(--text-xs) !important; }
.plv__row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; }
/* Browse */
.plv__browse-meta { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.plv__browse-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plv__browse-desc {
font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px;
max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.plv__browse-foot {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-top: 1px solid var(--border-subtle);
}
.plv__browse-pag { display: flex; gap: 8px; }
/* Upload */
.plv__upload-wrap { display: flex; flex-direction: column; gap: 12px; }
.plv__dropzone {
border: 2px dashed var(--border-default); border-radius: var(--radius-md);
padding: 40px 24px; text-align: center;
cursor: pointer; transition: var(--transition-colors);
display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.plv__dropzone:hover { border-color: var(--accent-border); background: var(--accent-soft); }
.plv__dropzone--active { border-color: var(--accent); background: var(--accent-soft); }
.plv__dropzone--ready { border-color: var(--status-online-border); background: var(--status-online-soft); }
.plv__file-hidden { position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none; }
.plv__drop-icon {
width: 46px; height: 46px; border-radius: var(--radius-lg);
display: flex; align-items: center; justify-content: center;
background: var(--surface-raised-2); color: var(--text-tertiary);
box-shadow: var(--ring-default); margin-bottom: 4px;
}
.plv__drop-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
.plv__drop-hint { font-size: var(--text-xs); color: var(--text-tertiary); }
.plv__file-row { display: flex; align-items: center; gap: 12px; }
.plv__file-icon {
width: 40px; height: 40px; flex: none; border-radius: var(--radius-md);
display: flex; align-items: center; justify-content: center;
background: var(--status-online-soft); color: var(--status-online);
box-shadow: inset 0 0 0 1px var(--status-online-border);
}
.plv__file-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
.plv__file-size { font-size: var(--text-xs); color: var(--text-tertiary); font-family: var(--font-mono); }
.plv__upload-actions { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
</style>