All checks were successful
Test Asgard Runner / test (push) Successful in 4s
Tokens ported 1:1 from the Claude Design bundle (colors/game-themes/type/spacing/elevation/motion/fonts) with the data-theme/data-game theming contract via useThemeGame (+ cc-skin-swap repaint guard). 23 design-system components reimplemented as Vue SFCs (core/forms/data/navigation/feedback/brand). DashboardLayout rebuilt as the game-aware shell (GameSwitcher, grouped nav with permission gating preserved, agent-health footer, topbar). DashboardView: Fleet + Solo with per-game GAME_FIELDS rows and the themed ECharts PlayersChart; Solo wired to the real server store, Fleet on representative data pending the multi-instance backend. All four game skins (Rust/Dune/Conan/Soulmask). vue-tsc + vite build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
167 lines
6.4 KiB
Vue
167 lines
6.4 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* ServerCard — server instance summary card.
|
|
* Sets :data-game so per-game accent token re-skins apply via the global [data-game] selector.
|
|
* Status drives the dot + left rail color.
|
|
* `offline` dims the card and swaps the power IconButton to a Start action.
|
|
* Pending state shows when status==='online' && cpu==null && ram==null.
|
|
*/
|
|
import { computed } from '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 ResourceMeter from './ResourceMeter.vue'
|
|
|
|
export interface StatItem {
|
|
label: string
|
|
value: string | number
|
|
}
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
game?: string
|
|
gameIcon?: string
|
|
name: string
|
|
region?: string
|
|
map?: string
|
|
version?: string
|
|
status?: 'online' | 'offline' | 'starting' | 'wiping' | 'updating'
|
|
players?: { cur: number; max: number }
|
|
cpu?: number
|
|
ram?: number
|
|
ramSub?: string
|
|
ip?: string
|
|
stats?: StatItem[]
|
|
}>(),
|
|
{
|
|
game: 'rust',
|
|
gameIcon: 'box',
|
|
status: 'online',
|
|
players: () => ({ cur: 0, max: 0 }),
|
|
},
|
|
)
|
|
|
|
const emit = defineEmits<{
|
|
console: []
|
|
settings: []
|
|
power: []
|
|
}>()
|
|
|
|
interface StatusEntry {
|
|
tone: 'online' | 'offline' | 'warn' | 'info' | 'starting' | 'wiping' | 'neutral' | 'accent'
|
|
label: string
|
|
pulse: boolean
|
|
}
|
|
|
|
const DEFAULT_STATUS: StatusEntry = { tone: 'online', label: 'Online', pulse: true }
|
|
|
|
const STATUS_MAP: Record<string, StatusEntry> = {
|
|
online: { tone: 'online', label: 'Online', pulse: true },
|
|
offline: { tone: 'offline', label: 'Offline', pulse: false },
|
|
starting: { tone: 'starting', label: 'Booting', pulse: true },
|
|
wiping: { tone: 'wiping', label: 'Wiping', pulse: true },
|
|
updating: { tone: 'starting', label: 'Updating', pulse: true },
|
|
}
|
|
|
|
const st = computed<StatusEntry>(() => STATUS_MAP[props.status ?? 'online'] ?? DEFAULT_STATUS)
|
|
const offline = computed(() => props.status === 'offline')
|
|
|
|
const statList = computed<StatItem[]>(() => {
|
|
if (props.stats) return props.stats
|
|
const items: StatItem[] = [
|
|
{ label: 'Players', value: `${props.players?.cur ?? 0} / ${props.players?.max ?? 0}` },
|
|
]
|
|
if (props.version) {
|
|
items.push({ label: 'Build', value: props.version })
|
|
}
|
|
return items
|
|
})
|
|
|
|
const pending = computed(
|
|
() => props.status === 'online' && props.cpu == null && props.ram == null,
|
|
)
|
|
|
|
const showMeters = computed(
|
|
() => !offline.value && (props.cpu != null || props.ram != null),
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
:data-game="game"
|
|
:class="['cc-server', offline && 'cc-server--offline']"
|
|
>
|
|
<!-- Head -->
|
|
<div class="cc-server__head">
|
|
<div class="cc-server__game">
|
|
<Icon :name="gameIcon" :size="18" :stroke-width="2" />
|
|
</div>
|
|
<div class="cc-server__id">
|
|
<div class="cc-server__name">
|
|
{{ name }}
|
|
<Badge :tone="st.tone" dot :pulse="st.pulse">{{ st.label }}</Badge>
|
|
</div>
|
|
<div class="cc-server__meta">
|
|
<span v-if="region">{{ region }}</span>
|
|
<span v-if="map">{{ map }}</span>
|
|
<span v-if="ip">{{ ip }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="cc-server__actions">
|
|
<IconButton icon="terminal" variant="ghost" size="sm" label="Console" @click="emit('console')" />
|
|
<IconButton icon="settings" variant="ghost" size="sm" label="Settings" @click="emit('settings')" />
|
|
<IconButton
|
|
:icon="offline ? 'play' : 'power'"
|
|
:variant="offline ? 'accent' : 'ghost'"
|
|
size="sm"
|
|
:label="offline ? 'Start' : 'Power'"
|
|
@click="emit('power')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div class="cc-server__body">
|
|
<div class="cc-server__stats">
|
|
<div v-for="(s, i) in statList" :key="i" class="cc-server__stat">
|
|
<b>{{ s.value }}</b>
|
|
<span>{{ s.label }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="showMeters" class="cc-server__meters">
|
|
<ResourceMeter v-if="cpu != null" label="CPU" :value="cpu" />
|
|
<ResourceMeter v-if="ram != null" label="RAM" :value="ram" :sub="ramSub" />
|
|
</div>
|
|
|
|
<div v-if="pending" class="cc-server__pending">
|
|
<Icon name="loader" :size="13" :stroke-width="2.5" />
|
|
Telemetry pending · agent monitoring
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
.cc-server { position: relative; background: var(--surface-base); border-radius: var(--radius-lg); box-shadow: var(--ring-default); overflow: hidden; transition: var(--transition-colors); }
|
|
.cc-server::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--accent); opacity: .9; }
|
|
.cc-server:hover { box-shadow: inset 0 0 0 1px var(--border-strong); }
|
|
.cc-server--offline::before { background: var(--status-offline); }
|
|
.cc-server--offline { opacity: .82; }
|
|
.cc-server__head { display: flex; align-items: center; gap: 12px; padding: 14px 14px 12px 17px; }
|
|
.cc-server__game { width: 34px; height: 34px; border-radius: var(--radius-md); flex: none; 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); }
|
|
.cc-server__id { flex: 1; min-width: 0; }
|
|
.cc-server__name { font-size: var(--text-base); font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 8px; }
|
|
.cc-server__meta { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 2px; display: flex; gap: 10px; }
|
|
.cc-server__body { padding: 0 14px 13px 17px; display: flex; flex-direction: column; gap: 11px; }
|
|
.cc-server__stats { display: flex; gap: 18px; }
|
|
.cc-server__stat { display: flex; flex-direction: column; gap: 1px; }
|
|
.cc-server__stat b { font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm); color: var(--text-primary); font-variant-numeric: tabular-nums; }
|
|
.cc-server__stat span { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; }
|
|
.cc-server__meters { display: flex; gap: 14px; }
|
|
.cc-server__meters > * { flex: 1; }
|
|
.cc-server__pending { display: flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); }
|
|
.cc-server__pending .cc-icon { color: var(--status-starting); }
|
|
.cc-server__actions { display: flex; gap: 5px; }
|
|
</style>
|