feat(fleet): Phase B — fleet overview UI + GET /api/fleet read endpoint
Tenant-scoped fleet read: GET /api/fleet returns agent_hosts (host metrics) each with their game_instances, plus a summary (host/instance/online counts). FleetView lists host cards (status, CPU/ mem/disk/uptime/last-heartbeat) with their instances (game, state badge, uptime); honest empty state -> Server page when no hosts. New 'Fleet' sidebar nav item across all four game profiles, /fleet route. Store follows the no-throw-on-fetch pattern (error state, never bricks). The marketing hero made real from the live fleet tables. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,7 @@ import { BetterChatModule } from './modules/betterchat/betterchat.module';
|
|||||||
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
import { TimedExecuteModule } from './modules/timedexecute/timedexecute.module';
|
||||||
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
import { RaidableBasesModule } from './modules/raidablebases/raidablebases.module';
|
||||||
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
import { EarlyAccessModule } from './modules/early-access/early-access.module';
|
||||||
|
import { FleetModule } from './modules/fleet/fleet.module';
|
||||||
|
|
||||||
// Shared Services
|
// Shared Services
|
||||||
import { NatsService } from './services/nats.service';
|
import { NatsService } from './services/nats.service';
|
||||||
@@ -133,6 +134,7 @@ import { NatsBridgeGateway } from './gateways/nats-bridge.gateway';
|
|||||||
TimedExecuteModule,
|
TimedExecuteModule,
|
||||||
RaidableBasesModule,
|
RaidableBasesModule,
|
||||||
EarlyAccessModule,
|
EarlyAccessModule,
|
||||||
|
FleetModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
// Global guards (order matters: auth first, then license, then permissions)
|
// Global guards (order matters: auth first, then license, then permissions)
|
||||||
|
|||||||
19
backend-nest/src/modules/fleet/fleet.controller.ts
Normal file
19
backend-nest/src/modules/fleet/fleet.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
|
||||||
|
import { FleetService } from './fleet.service';
|
||||||
|
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';
|
||||||
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
|
|
||||||
|
@ApiTags('fleet')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('fleet')
|
||||||
|
export class FleetController {
|
||||||
|
constructor(private readonly fleetService: FleetService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('server.view')
|
||||||
|
@ApiOperation({ summary: 'Get fleet overview — hosts and game instances for this license' })
|
||||||
|
async getFleet(@CurrentTenant() licenseId: string) {
|
||||||
|
return this.fleetService.getFleet(licenseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
backend-nest/src/modules/fleet/fleet.module.ts
Normal file
14
backend-nest/src/modules/fleet/fleet.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { FleetController } from './fleet.controller';
|
||||||
|
import { FleetService } from './fleet.service';
|
||||||
|
import { AgentHost } from '../../entities/agent-host.entity';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([AgentHost, GameInstance])],
|
||||||
|
controllers: [FleetController],
|
||||||
|
providers: [FleetService],
|
||||||
|
exports: [FleetService],
|
||||||
|
})
|
||||||
|
export class FleetModule {}
|
||||||
134
backend-nest/src/modules/fleet/fleet.service.ts
Normal file
134
backend-nest/src/modules/fleet/fleet.service.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AgentHost } from '../../entities/agent-host.entity';
|
||||||
|
import { GameInstance } from '../../entities/game-instance.entity';
|
||||||
|
|
||||||
|
export interface FleetInstanceDto {
|
||||||
|
id: string;
|
||||||
|
agent_instance_id: string;
|
||||||
|
game: string;
|
||||||
|
label: string | null;
|
||||||
|
state: string;
|
||||||
|
uptime_seconds: number;
|
||||||
|
last_seen_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetHostDto {
|
||||||
|
id: string;
|
||||||
|
hostname: string;
|
||||||
|
status: string;
|
||||||
|
agent_version: string | null;
|
||||||
|
os: string | null;
|
||||||
|
arch: string | null;
|
||||||
|
cpu_percent: number | null;
|
||||||
|
cpu_cores: number | null;
|
||||||
|
mem_total_mb: number | null;
|
||||||
|
mem_used_mb: number | null;
|
||||||
|
uptime_seconds: number | null;
|
||||||
|
disks: AgentHost['disks'];
|
||||||
|
last_heartbeat_at: string | null;
|
||||||
|
instances: FleetInstanceDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetSummaryDto {
|
||||||
|
host_count: number;
|
||||||
|
instance_count: number;
|
||||||
|
online_host_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetResponseDto {
|
||||||
|
hosts: FleetHostDto[];
|
||||||
|
summary: FleetSummaryDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FleetService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(AgentHost)
|
||||||
|
private readonly hostRepo: Repository<AgentHost>,
|
||||||
|
@InjectRepository(GameInstance)
|
||||||
|
private readonly instanceRepo: Repository<GameInstance>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getFleet(licenseId: string): Promise<FleetResponseDto> {
|
||||||
|
const [hosts, instances] = await Promise.all([
|
||||||
|
this.hostRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { hostname: 'ASC' },
|
||||||
|
}),
|
||||||
|
this.instanceRepo.find({
|
||||||
|
where: { license_id: licenseId },
|
||||||
|
order: { game: 'ASC', label: 'ASC' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Group instances by host_id. Bigint columns come back as strings from pg — coerce.
|
||||||
|
const instancesByHost = new Map<string | null, FleetInstanceDto[]>();
|
||||||
|
for (const inst of instances) {
|
||||||
|
const key = inst.host_id ?? null;
|
||||||
|
if (!instancesByHost.has(key)) {
|
||||||
|
instancesByHost.set(key, []);
|
||||||
|
}
|
||||||
|
instancesByHost.get(key)!.push({
|
||||||
|
id: inst.id,
|
||||||
|
agent_instance_id: inst.agent_instance_id,
|
||||||
|
game: inst.game,
|
||||||
|
label: inst.label,
|
||||||
|
state: inst.state,
|
||||||
|
uptime_seconds: Number(inst.uptime_seconds),
|
||||||
|
last_seen_at: inst.last_seen_at ? inst.last_seen_at.toISOString() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostDtos: FleetHostDto[] = hosts.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
hostname: h.hostname,
|
||||||
|
status: h.status,
|
||||||
|
agent_version: h.agent_version,
|
||||||
|
os: h.os,
|
||||||
|
arch: h.arch,
|
||||||
|
cpu_percent: h.cpu_percent !== null && h.cpu_percent !== undefined ? Number(h.cpu_percent) : null,
|
||||||
|
cpu_cores: h.cpu_cores !== null && h.cpu_cores !== undefined ? Number(h.cpu_cores) : null,
|
||||||
|
mem_total_mb: h.mem_total_mb !== null && h.mem_total_mb !== undefined ? Number(h.mem_total_mb) : null,
|
||||||
|
mem_used_mb: h.mem_used_mb !== null && h.mem_used_mb !== undefined ? Number(h.mem_used_mb) : null,
|
||||||
|
uptime_seconds: h.uptime_seconds !== null && h.uptime_seconds !== undefined ? Number(h.uptime_seconds) : null,
|
||||||
|
disks: h.disks,
|
||||||
|
last_heartbeat_at: h.last_heartbeat_at ? h.last_heartbeat_at.toISOString() : null,
|
||||||
|
instances: instancesByHost.get(h.id) ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Append synthetic "unassigned" bucket only if orphaned instances exist
|
||||||
|
const unassigned = instancesByHost.get(null) ?? [];
|
||||||
|
if (unassigned.length > 0) {
|
||||||
|
hostDtos.push({
|
||||||
|
id: '__unassigned__',
|
||||||
|
hostname: 'Unassigned',
|
||||||
|
status: 'offline',
|
||||||
|
agent_version: null,
|
||||||
|
os: null,
|
||||||
|
arch: null,
|
||||||
|
cpu_percent: null,
|
||||||
|
cpu_cores: null,
|
||||||
|
mem_total_mb: null,
|
||||||
|
mem_used_mb: null,
|
||||||
|
uptime_seconds: null,
|
||||||
|
disks: null,
|
||||||
|
last_heartbeat_at: null,
|
||||||
|
instances: unassigned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const online_host_count = hosts.filter((h) => h.status === 'connected').length;
|
||||||
|
const instance_count = instances.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts: hostDtos,
|
||||||
|
summary: {
|
||||||
|
host_count: hosts.length,
|
||||||
|
instance_count,
|
||||||
|
online_host_count,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,7 @@ export interface GameProfile {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null }
|
const NAV_DASHBOARD: NavItemDef = { label: 'Dashboard', route: '/', icon: 'layout-dashboard', permission: null }
|
||||||
|
const NAV_FLEET: NavItemDef = { label: 'Fleet', route: '/fleet', icon: 'server-cog', permission: 'server.view' }
|
||||||
const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' }
|
const NAV_SERVER: NavItemDef = { label: 'Server', route: '/server', icon: 'server', permission: 'server.view' }
|
||||||
const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' }
|
const NAV_CONSOLE: NavItemDef = { label: 'Console', route: '/console', icon: 'terminal', permission: 'console.view' }
|
||||||
const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' }
|
const NAV_PLAYERS: NavItemDef = { label: 'Players', route: '/players', icon: 'users', permission: 'players.view' }
|
||||||
@@ -147,7 +148,7 @@ const RUST_NAV: NavSection[] = [
|
|||||||
{ label: '', items: [NAV_DASHBOARD] },
|
{ label: '', items: [NAV_DASHBOARD] },
|
||||||
{
|
{
|
||||||
label: 'Server',
|
label: 'Server',
|
||||||
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
|
items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_PLUGINS, NAV_FILES],
|
||||||
},
|
},
|
||||||
{ label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] },
|
{ label: 'Plugin configs', items: [NAV_PLUGIN_CONFIGS] },
|
||||||
{
|
{
|
||||||
@@ -211,7 +212,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
{
|
{
|
||||||
label: 'Server',
|
label: 'Server',
|
||||||
// Conan: no uMod/Oxide; has RCON console, maps, players, files
|
// Conan: no uMod/Oxide; has RCON console, maps, players, files
|
||||||
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Operations',
|
label: 'Operations',
|
||||||
@@ -256,7 +257,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
{
|
{
|
||||||
label: 'Server',
|
label: 'Server',
|
||||||
// Soulmask: no uMod/Oxide; has RCON+GM console, players, files
|
// Soulmask: no uMod/Oxide; has RCON+GM console, players, files
|
||||||
items: [NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
items: [NAV_FLEET, NAV_SERVER, NAV_CONSOLE, NAV_PLAYERS, NAV_FILES],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Operations',
|
label: 'Operations',
|
||||||
@@ -299,6 +300,7 @@ export const GAME_PROFILES: Record<GameId, GameProfile> = {
|
|||||||
label: 'Server',
|
label: 'Server',
|
||||||
// Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins
|
// Dune: no RCON (uses RabbitMQ); label console "Broadcast"; no maps route; no plugins
|
||||||
items: [
|
items: [
|
||||||
|
NAV_FLEET,
|
||||||
NAV_SERVER,
|
NAV_SERVER,
|
||||||
{ label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' },
|
{ label: 'Broadcast', route: '/console', icon: 'radio', permission: 'console.view' },
|
||||||
NAV_PLAYERS,
|
NAV_PLAYERS,
|
||||||
|
|||||||
@@ -343,6 +343,12 @@ const panelRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/admin/AlertsView.vue'),
|
component: () => import('@/views/admin/AlertsView.vue'),
|
||||||
meta: { title: 'Alerts — Corrosion' },
|
meta: { title: 'Alerts — Corrosion' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'fleet',
|
||||||
|
name: 'fleet',
|
||||||
|
component: () => import('@/views/admin/FleetView.vue'),
|
||||||
|
meta: { title: 'Fleet — Corrosion', requiresAuth: true },
|
||||||
|
},
|
||||||
// Platform Admin views (super-admin only)
|
// Platform Admin views (super-admin only)
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
87
frontend/src/stores/fleet.ts
Normal file
87
frontend/src/stores/fleet.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '@/composables/useApi'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types — mirrors the FleetResponseDto from the backend
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface FleetDisk {
|
||||||
|
mount: string
|
||||||
|
total_mb: number
|
||||||
|
free_mb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetInstance {
|
||||||
|
id: string
|
||||||
|
agent_instance_id: string
|
||||||
|
game: string
|
||||||
|
label: string | null
|
||||||
|
state: string
|
||||||
|
uptime_seconds: number
|
||||||
|
last_seen_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetHost {
|
||||||
|
id: string
|
||||||
|
hostname: string
|
||||||
|
status: string
|
||||||
|
agent_version: string | null
|
||||||
|
os: string | null
|
||||||
|
arch: string | null
|
||||||
|
cpu_percent: number | null
|
||||||
|
cpu_cores: number | null
|
||||||
|
mem_total_mb: number | null
|
||||||
|
mem_used_mb: number | null
|
||||||
|
uptime_seconds: number | null
|
||||||
|
disks: FleetDisk[] | null
|
||||||
|
last_heartbeat_at: string | null
|
||||||
|
instances: FleetInstance[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetSummary {
|
||||||
|
host_count: number
|
||||||
|
instance_count: number
|
||||||
|
online_host_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FleetData {
|
||||||
|
hosts: FleetHost[]
|
||||||
|
summary: FleetSummary
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Store
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const useFleetStore = defineStore('fleet', () => {
|
||||||
|
const hosts = ref<FleetHost[]>([])
|
||||||
|
const summary = ref<FleetSummary>({ host_count: 0, instance_count: 0, online_host_count: 0 })
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function fetchFleet() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const data = await api.get<FleetData>('/fleet')
|
||||||
|
hosts.value = data.hosts
|
||||||
|
summary.value = data.summary
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch fleet:', e)
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load fleet data'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hosts,
|
||||||
|
summary,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
fetchFleet,
|
||||||
|
}
|
||||||
|
})
|
||||||
467
frontend/src/views/admin/FleetView.vue
Normal file
467
frontend/src/views/admin/FleetView.vue
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* FleetView — Read-only fleet overview: hosts and game instances for this license.
|
||||||
|
*
|
||||||
|
* Data flow: useFleetStore → GET /api/fleet → tenant-scoped AgentHost + GameInstance rows.
|
||||||
|
*
|
||||||
|
* Render states:
|
||||||
|
* - loading → shows skeleton / loading text
|
||||||
|
* - error → shows error panel (fetch failed / 401 → error state, NOT global error boundary)
|
||||||
|
* - empty → honest empty state with CTA to /server
|
||||||
|
* - populated → summary strip + one card per host + instance list under each
|
||||||
|
*
|
||||||
|
* No fabricated data. All nulls render as '—' via safeFixed/safeDate.
|
||||||
|
*/
|
||||||
|
import { onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useFleetStore } from '@/stores/fleet'
|
||||||
|
import type { FleetHost } from '@/stores/fleet'
|
||||||
|
import { safeFixed, safeDate } from '@/utils/formatters'
|
||||||
|
import Panel from '@/components/ds/data/Panel.vue'
|
||||||
|
import StatCard from '@/components/ds/data/StatCard.vue'
|
||||||
|
import Badge from '@/components/ds/core/Badge.vue'
|
||||||
|
import StatusDot from '@/components/ds/core/StatusDot.vue'
|
||||||
|
import Button from '@/components/ds/core/Button.vue'
|
||||||
|
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||||
|
import Icon from '@/components/ds/core/Icon.vue'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Store / router
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const fleet = useFleetStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fleet.fetchFleet()
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Derived state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const hasHosts = computed(() => fleet.hosts.length > 0)
|
||||||
|
|
||||||
|
/** Map host status → Badge tone */
|
||||||
|
function hostTone(status: string): 'online' | 'offline' | 'warn' {
|
||||||
|
if (status === 'connected') return 'online'
|
||||||
|
if (status === 'degraded') return 'warn'
|
||||||
|
return 'offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostStatusLabel(status: string): string {
|
||||||
|
if (status === 'connected') return 'Online'
|
||||||
|
if (status === 'degraded') return 'Degraded'
|
||||||
|
return 'Offline'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map game instance state → Badge tone */
|
||||||
|
function instanceTone(state: string): 'online' | 'offline' | 'warn' | 'neutral' {
|
||||||
|
if (state === 'running') return 'online'
|
||||||
|
if (state === 'crashed') return 'offline'
|
||||||
|
if (state === 'stopped') return 'warn'
|
||||||
|
return 'neutral'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format uptime seconds → human-readable "Xd Xh Xm" */
|
||||||
|
function formatUptime(seconds: number | null): string {
|
||||||
|
if (seconds == null || seconds < 0) return '—'
|
||||||
|
const d = Math.floor(seconds / 86400)
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (d > 0) return `${d}d ${h}h`
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format memory used/total as "Xm / Xm" or "—" if null. */
|
||||||
|
function formatMem(used: number | null, total: number | null): string {
|
||||||
|
if (used == null && total == null) return '—'
|
||||||
|
const u = used != null ? `${Math.round(used)}MB` : '—'
|
||||||
|
const t = total != null ? `${Math.round(total)}MB` : '—'
|
||||||
|
return `${u} / ${t}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pick primary disk (first entry) for display. */
|
||||||
|
function primaryDisk(host: FleetHost): string {
|
||||||
|
if (!host.disks || host.disks.length === 0) return '—'
|
||||||
|
const d = host.disks[0]
|
||||||
|
if (d == null) return '—'
|
||||||
|
const freePct = d.total_mb > 0 ? Math.round((d.free_mb / d.total_mb) * 100) : 0
|
||||||
|
return `${d.mount} · ${freePct}% free`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Last heartbeat relative time — use safeDate, then strip full timestamp for brevity. */
|
||||||
|
function relativeHeartbeat(iso: string | null): string {
|
||||||
|
if (!iso) return 'Never'
|
||||||
|
return safeDate(iso)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fleet-view">
|
||||||
|
<!-- Page header -->
|
||||||
|
<div class="fleet-view__header">
|
||||||
|
<div>
|
||||||
|
<h1 class="fleet-view__title">Fleet</h1>
|
||||||
|
<p class="fleet-view__sub">Hosts and game instances connected to this license.</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" icon="refresh-cw" :disabled="fleet.loading" @click="fleet.fetchFleet()">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="fleet.loading && !hasHosts" class="fleet-view__loading">
|
||||||
|
<Icon name="loader" :size="18" class="fleet-loading-icon" />
|
||||||
|
<span>Loading fleet data…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state (API failed / 401 / network error) — honest, not global error boundary -->
|
||||||
|
<Panel v-else-if="fleet.error && !hasHosts" title="Could not load fleet data">
|
||||||
|
<EmptyState
|
||||||
|
icon="wifi-off"
|
||||||
|
title="Fleet data unavailable"
|
||||||
|
:description="fleet.error"
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button variant="primary" @click="fleet.fetchFleet()">Try again</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Empty state — no hosts returned -->
|
||||||
|
<Panel v-else-if="!fleet.loading && !fleet.error && !hasHosts">
|
||||||
|
<EmptyState
|
||||||
|
icon="server"
|
||||||
|
title="No hosts connected yet"
|
||||||
|
description="Install the Corrosion host agent on your server machine to see it here."
|
||||||
|
>
|
||||||
|
<template #action>
|
||||||
|
<Button variant="primary" @click="router.push('/server')">Go to Server page</Button>
|
||||||
|
</template>
|
||||||
|
</EmptyState>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<!-- Populated fleet -->
|
||||||
|
<template v-else>
|
||||||
|
<!-- Summary strip -->
|
||||||
|
<div class="fleet-view__summary">
|
||||||
|
<StatCard
|
||||||
|
label="Total hosts"
|
||||||
|
:value="fleet.summary.host_count"
|
||||||
|
icon="server"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Online hosts"
|
||||||
|
:value="fleet.summary.online_host_count"
|
||||||
|
icon="activity"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Game instances"
|
||||||
|
:value="fleet.summary.instance_count"
|
||||||
|
icon="layers"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Host cards -->
|
||||||
|
<div class="fleet-view__hosts">
|
||||||
|
<Panel
|
||||||
|
v-for="host in fleet.hosts"
|
||||||
|
:key="host.id"
|
||||||
|
class="fleet-host"
|
||||||
|
>
|
||||||
|
<!-- Host header -->
|
||||||
|
<template #default>
|
||||||
|
<div class="fleet-host__head">
|
||||||
|
<div class="fleet-host__identity">
|
||||||
|
<StatusDot :tone="hostTone(host.status)" :pulse="host.status === 'connected'" :size="9" />
|
||||||
|
<span class="fleet-host__name">{{ host.hostname }}</span>
|
||||||
|
<Badge :tone="hostTone(host.status)" :dot="false">{{ hostStatusLabel(host.status) }}</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-host__meta">
|
||||||
|
<span class="fleet-host__meta-item" v-if="host.agent_version">
|
||||||
|
<Icon name="zap" :size="12" />v{{ host.agent_version }}
|
||||||
|
</span>
|
||||||
|
<span class="fleet-host__meta-item" v-if="host.os || host.arch">
|
||||||
|
<Icon name="cpu" :size="12" />{{ [host.os, host.arch].filter(Boolean).join(' / ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Host metrics row -->
|
||||||
|
<div class="fleet-host__metrics">
|
||||||
|
<div class="fleet-metric">
|
||||||
|
<span class="fleet-metric__label">CPU</span>
|
||||||
|
<span class="fleet-metric__value">
|
||||||
|
{{ host.cpu_percent != null ? safeFixed(host.cpu_percent, 1) + '%' : '—' }}
|
||||||
|
<span v-if="host.cpu_cores" class="fleet-metric__sub">{{ host.cpu_cores }} cores</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-metric">
|
||||||
|
<span class="fleet-metric__label">Memory</span>
|
||||||
|
<span class="fleet-metric__value">{{ formatMem(host.mem_used_mb, host.mem_total_mb) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-metric">
|
||||||
|
<span class="fleet-metric__label">Disk</span>
|
||||||
|
<span class="fleet-metric__value">{{ primaryDisk(host) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-metric">
|
||||||
|
<span class="fleet-metric__label">Uptime</span>
|
||||||
|
<span class="fleet-metric__value">{{ formatUptime(host.uptime_seconds) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="fleet-metric">
|
||||||
|
<span class="fleet-metric__label">Last heartbeat</span>
|
||||||
|
<span class="fleet-metric__value fleet-metric__value--sm">{{ relativeHeartbeat(host.last_heartbeat_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Instance list -->
|
||||||
|
<div v-if="host.instances.length > 0" class="fleet-host__instances">
|
||||||
|
<div class="fleet-instances__label t-eyebrow">Game instances ({{ host.instances.length }})</div>
|
||||||
|
<div class="fleet-instances__list">
|
||||||
|
<div
|
||||||
|
v-for="inst in host.instances"
|
||||||
|
:key="inst.id"
|
||||||
|
class="fleet-instance"
|
||||||
|
>
|
||||||
|
<StatusDot :tone="instanceTone(inst.state)" :size="7" />
|
||||||
|
<span class="fleet-instance__game">{{ inst.game }}</span>
|
||||||
|
<span v-if="inst.label" class="fleet-instance__label">{{ inst.label }}</span>
|
||||||
|
<Badge :tone="instanceTone(inst.state)" class="fleet-instance__badge">
|
||||||
|
{{ inst.state }}
|
||||||
|
</Badge>
|
||||||
|
<span class="fleet-instance__uptime">{{ formatUptime(inst.uptime_seconds) }}</span>
|
||||||
|
<span class="fleet-instance__seen">{{ safeDate(inst.last_seen_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No instances under this host -->
|
||||||
|
<div v-else class="fleet-host__no-instances">
|
||||||
|
<Icon name="layers" :size="13" />
|
||||||
|
<span>No game instances reported</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ---- Page shell ---- */
|
||||||
|
.fleet-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-view__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-view__title {
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-view__sub {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-view__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.fleet-loading-icon {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Summary strip ---- */
|
||||||
|
.fleet-view__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.fleet-view__summary { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Host list ---- */
|
||||||
|
.fleet-view__hosts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Host card internals ---- */
|
||||||
|
.fleet-host__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 14px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-host__identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-host__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-host__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-host__meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Metrics row ---- */
|
||||||
|
.fleet-host__metrics {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0;
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-right: 1px solid var(--border-subtle);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
.fleet-metric:last-child { border-right: none; }
|
||||||
|
|
||||||
|
.fleet-metric__label {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-metric__value {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-metric__value--sm {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-metric__sub {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Instance list ---- */
|
||||||
|
.fleet-host__instances {
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instances__label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instances__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instance {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 9px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--surface-raised-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instance__game {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instance__label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instance__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instance__uptime {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleet-instance__seen {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- No instances ---- */
|
||||||
|
.fleet-host__no-instances {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 16px 14px;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user