Archipelago — open-source initial import
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">Fleet Alerts</h3>
|
||||
|
||||
<div v-if="alertsLoading" class="text-white/40 text-sm py-4 text-center">
|
||||
Loading alerts...
|
||||
</div>
|
||||
<div v-else-if="!alerts.length" class="text-white/40 text-sm py-4 text-center">
|
||||
No alerts across the fleet.
|
||||
</div>
|
||||
<div v-else class="space-y-2 max-h-80 overflow-y-auto">
|
||||
<div
|
||||
v-for="(alert, idx) in alerts.slice(0, 50)"
|
||||
:key="idx"
|
||||
class="flex items-start gap-3 p-3 bg-white/5 rounded-lg"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
||||
:class="alertSeverityDot(alert.rule)"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<span class="fleet-node-badge">{{ alert.node_id.slice(0, 8) }}</span>
|
||||
<span class="text-xs text-white/40">{{ alertTypeLabel(alert.rule) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-white/80">{{ alert.message }}</p>
|
||||
<p class="text-xs text-white/30 mt-0.5">{{ formatTimestamp(alert.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type FleetAlert, alertSeverityDot, alertTypeLabel, formatTimestamp } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
alerts: FleetAlert[]
|
||||
alertsLoading: boolean
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<div class="glass-card p-5">
|
||||
<h3 class="text-sm font-medium text-white/80 mb-4">Container Matrix</h3>
|
||||
|
||||
<div v-if="!nodes.length" class="text-white/40 text-sm py-4 text-center">
|
||||
No nodes to display.
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="fleet-matrix-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="fleet-matrix-header-cell">App</th>
|
||||
<th
|
||||
v-for="node in sortedNodes"
|
||||
:key="node.node_id"
|
||||
class="fleet-matrix-header-cell"
|
||||
:title="fleetNodeSubtitle(node)"
|
||||
>
|
||||
{{ fleetNodeDisplayName(node) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="app in allAppIds" :key="app">
|
||||
<td class="fleet-matrix-cell text-white/70">{{ app }}</td>
|
||||
<td
|
||||
v-for="node in sortedNodes"
|
||||
:key="node.node_id"
|
||||
class="fleet-matrix-cell text-center"
|
||||
>
|
||||
<span v-if="getContainerState(node, app) === 'running'" class="text-green-400">✓</span>
|
||||
<span v-else-if="getContainerState(node, app) === 'stopped'" class="text-red-400">✗</span>
|
||||
<span v-else class="text-white/20">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type FleetNode, getContainerState, fleetNodeDisplayName, fleetNodeSubtitle } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
nodes: FleetNode[]
|
||||
sortedNodes: FleetNode[]
|
||||
allAppIds: string[]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div v-if="node" class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">
|
||||
Node Detail — <span>{{ fleetNodeDisplayName(node) }}</span>
|
||||
</h3>
|
||||
<button class="glass-button text-xs px-3 py-1" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
|
||||
<!-- Node Info Summary -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Hostname</p>
|
||||
<p class="text-lg font-bold text-white truncate">{{ node.hostname || fleetNodeDisplayName(node) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Address</p>
|
||||
<p class="text-lg font-bold text-white truncate">{{ node.server_url || nodeId.slice(0, 8) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Version</p>
|
||||
<p class="text-lg font-bold text-white">{{ $ver(node.version) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Uptime</p>
|
||||
<p class="text-lg font-bold text-white">{{ formatUptime(node.uptime_secs) }}</p>
|
||||
</div>
|
||||
<div class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Federation Peers</p>
|
||||
<p class="text-lg font-bold text-white">{{ node.federation_peers }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History Charts -->
|
||||
<div v-if="historyLoading && !historyLabels.length" class="text-white/40 text-sm py-4 text-center mb-4">
|
||||
Loading history...
|
||||
</div>
|
||||
<div v-else-if="historyLoading" class="text-white/40 text-xs text-center mb-4">
|
||||
Refreshing history...
|
||||
</div>
|
||||
<div v-else-if="historyLabels.length" class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="glass-card p-4">
|
||||
<h4 class="text-xs font-medium text-white/60 mb-2">CPU History</h4>
|
||||
<LineChart
|
||||
:datasets="cpuDatasets"
|
||||
:labels="historyLabels"
|
||||
:width="chartWidth"
|
||||
:height="160"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<h4 class="text-xs font-medium text-white/60 mb-2">RAM History</h4>
|
||||
<LineChart
|
||||
:datasets="memDatasets"
|
||||
:labels="historyLabels"
|
||||
:width="chartWidth"
|
||||
:height="160"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
<div class="glass-card p-4">
|
||||
<h4 class="text-xs font-medium text-white/60 mb-2">Disk History</h4>
|
||||
<LineChart
|
||||
:datasets="diskDatasets"
|
||||
:labels="historyLabels"
|
||||
:width="chartWidth"
|
||||
:height="160"
|
||||
:y-max="100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div class="mb-4">
|
||||
<h4 class="text-xs font-medium text-white/60 mb-2 uppercase tracking-wide">Containers</h4>
|
||||
<div v-if="!node.containers?.length" class="text-white/40 text-sm py-2">
|
||||
No containers reported.
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="c in (node.containers || [])"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-3 p-2 bg-white/5 rounded-lg"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
||||
:class="c.state === 'running' ? 'bg-green-400' : 'bg-red-400'"
|
||||
></span>
|
||||
<span class="text-sm text-white flex-1 truncate">{{ c.id }}</span>
|
||||
<span class="text-xs text-white/40">{{ c.state }}</span>
|
||||
<span class="text-xs text-white/30">{{ c.version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Alerts -->
|
||||
<div>
|
||||
<h4 class="text-xs font-medium text-white/60 mb-2 uppercase tracking-wide">Recent Alerts</h4>
|
||||
<div v-if="!node.recent_alerts.length" class="text-white/40 text-sm py-2">
|
||||
No recent alerts for this node.
|
||||
</div>
|
||||
<div v-else class="space-y-1">
|
||||
<div
|
||||
v-for="(alert, idx) in node.recent_alerts"
|
||||
:key="idx"
|
||||
class="flex items-start gap-3 p-2 bg-white/5 rounded-lg"
|
||||
>
|
||||
<span
|
||||
class="inline-block w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
|
||||
:class="alertSeverityDot(alert.rule)"
|
||||
></span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm text-white/80">{{ alert.message }}</p>
|
||||
<p class="text-xs text-white/30">{{ formatTimestamp(alert.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LineChart from '@/components/LineChart.vue'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
import { type FleetNode, formatUptime, alertSeverityDot, formatTimestamp, fleetNodeDisplayName } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
node: FleetNode | null
|
||||
nodeId: string
|
||||
historyLoading: boolean
|
||||
historyLabels: string[]
|
||||
cpuDatasets: ChartDataset[]
|
||||
memDatasets: ChartDataset[]
|
||||
diskDatasets: ChartDataset[]
|
||||
chartWidth: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="glass-card p-5 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-medium text-white/80">Nodes</h3>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="opt in SORT_OPTIONS"
|
||||
:key="opt.value"
|
||||
class="fleet-sort-btn"
|
||||
:class="{ 'fleet-sort-btn-active': sortBy === opt.value }"
|
||||
@click="$emit('update:sortBy', opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!nodes.length" class="text-white/40 text-sm py-8 text-center">
|
||||
No nodes reporting. Ensure telemetry is enabled on beta nodes.
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="node in sortedNodes"
|
||||
:key="node.node_id"
|
||||
class="fleet-node-card"
|
||||
:class="{ 'fleet-node-card-selected': selectedNodeId === node.node_id }"
|
||||
@click="$emit('selectNode', node.node_id)"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="fleet-status-dot"
|
||||
:class="isOnline(node.reported_at) ? 'fleet-dot-online' : 'fleet-dot-offline'"
|
||||
></span>
|
||||
<span class="text-sm font-semibold text-white truncate">{{ fleetNodeDisplayName(node) }}</span>
|
||||
</div>
|
||||
<span class="fleet-version-badge">{{ $ver(node.version) }}</span>
|
||||
</div>
|
||||
<div class="mb-3 truncate text-xs text-white/40">
|
||||
{{ fleetNodeSubtitle(node) }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-3">
|
||||
<div class="fleet-metric-row">
|
||||
<span class="text-xs text-white/50">CPU</span>
|
||||
<div class="fleet-bar-track">
|
||||
<div
|
||||
class="fleet-bar-fill"
|
||||
:class="healthBarClass(node.cpu_pct)"
|
||||
:style="{ width: Math.min(node.cpu_pct, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ node.cpu_pct.toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="fleet-metric-row">
|
||||
<span class="text-xs text-white/50">RAM</span>
|
||||
<div class="fleet-bar-track">
|
||||
<div
|
||||
class="fleet-bar-fill"
|
||||
:class="healthBarClass(node.mem_pct)"
|
||||
:style="{ width: Math.min(node.mem_pct, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ node.mem_pct.toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="fleet-metric-row">
|
||||
<span class="text-xs text-white/50">Disk</span>
|
||||
<div class="fleet-bar-track">
|
||||
<div
|
||||
class="fleet-bar-fill"
|
||||
:class="healthBarClass(node.disk_pct)"
|
||||
:style="{ width: Math.min(node.disk_pct, 100) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-white/60 w-10 text-right">{{ node.disk_pct.toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-xs text-white/40">
|
||||
<span>{{ node.running_count }}/{{ node.container_count }} containers</span>
|
||||
<span>{{ node.federation_peers }} peers</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-white/40 mt-1">
|
||||
<span>Up {{ formatUptime(node.uptime_secs) }}</span>
|
||||
<span>{{ timeAgo(node.reported_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type FleetNode, type SortOption, SORT_OPTIONS,
|
||||
isOnline, healthBarClass, formatUptime, timeAgo, fleetNodeDisplayName, fleetNodeSubtitle,
|
||||
} from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
nodes: FleetNode[]
|
||||
sortedNodes: FleetNode[]
|
||||
sortBy: SortOption
|
||||
selectedNodeId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:sortBy': [value: SortOption]
|
||||
selectNode: [nodeId: string]
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Total Nodes</p>
|
||||
<p class="text-2xl font-bold text-white">{{ nodeCount }}</p>
|
||||
<p class="text-xs text-white/40">
|
||||
<span class="fleet-dot-online"></span> {{ onlineCount }} online
|
||||
<span class="ml-1 fleet-dot-offline"></span> {{ offlineCount }} offline
|
||||
</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Fleet Health</p>
|
||||
<p class="text-2xl font-bold text-white">{{ fleetHealthPct }}%</p>
|
||||
<p class="text-xs text-white/40">{{ healthyCount }}/{{ nodeCount }} no alerts</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg CPU</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgCpu)">{{ avgCpu.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg RAM</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgMem)">{{ avgMem.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
</div>
|
||||
<div data-controller-container tabindex="0" class="monitoring-stat-card">
|
||||
<p class="text-xs text-white/50 uppercase tracking-wide">Avg Disk</p>
|
||||
<p class="text-2xl font-bold text-white" :class="healthTextClass(avgDisk)">{{ avgDisk.toFixed(1) }}%</p>
|
||||
<p class="text-xs text-white/40">across fleet</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { healthTextClass } from './useFleetData'
|
||||
|
||||
defineProps<{
|
||||
nodeCount: number
|
||||
onlineCount: number
|
||||
offlineCount: number
|
||||
fleetHealthPct: number
|
||||
healthyCount: number
|
||||
avgCpu: number
|
||||
avgMem: number
|
||||
avgDisk: number
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
fleetNodeDisplayName,
|
||||
fleetNodeSubtitle,
|
||||
isOnline,
|
||||
normalizeFleetNode,
|
||||
normalizeNodeHistoryResponse,
|
||||
sortFleetNodes,
|
||||
type FleetNode,
|
||||
} from '../useFleetData'
|
||||
|
||||
function node(id: string, reportedAt: string): FleetNode {
|
||||
return {
|
||||
node_id: id,
|
||||
node_name: null,
|
||||
hostname: null,
|
||||
server_url: null,
|
||||
version: '1.8-alpha',
|
||||
uptime_secs: 60,
|
||||
cpu_cores: 4,
|
||||
cpu_pct: 10,
|
||||
mem_pct: 20,
|
||||
disk_pct: 30,
|
||||
container_count: 2,
|
||||
running_count: 2,
|
||||
federation_peers: 1,
|
||||
recent_alerts: [],
|
||||
containers: [],
|
||||
reported_at: reportedAt,
|
||||
}
|
||||
}
|
||||
|
||||
describe('fleet data helpers', () => {
|
||||
it('treats nodes reported within 30 minutes as online', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
|
||||
expect(isOnline('2026-06-10T11:45:00Z')).toBe(true)
|
||||
expect(isOnline('2026-06-10T11:20:00Z')).toBe(false)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sorts status with online nodes first, then latest report', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-06-10T12:00:00Z'))
|
||||
const nodes = [
|
||||
node('offline', '2026-06-10T10:00:00Z'),
|
||||
node('online-old', '2026-06-10T11:45:00Z'),
|
||||
node('online-new', '2026-06-10T11:59:00Z'),
|
||||
]
|
||||
|
||||
expect(sortFleetNodes(nodes, 'status').map(n => n.node_id)).toEqual([
|
||||
'online-new',
|
||||
'online-old',
|
||||
'offline',
|
||||
])
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('sorts by name alphabetically', () => {
|
||||
const zulu = node('zulu', '2026-06-10T11:59:00Z')
|
||||
zulu.node_name = 'Workshop'
|
||||
const alpha = node('alpha', '2026-06-10T11:59:00Z')
|
||||
alpha.node_name = 'Kitchen'
|
||||
|
||||
expect(sortFleetNodes([zulu, alpha], 'name').map(n => n.node_id)).toEqual(['alpha', 'zulu'])
|
||||
})
|
||||
|
||||
it('normalizes older telemetry reports with missing metric and container fields', () => {
|
||||
const normalized = normalizeFleetNode({
|
||||
node_id: 'legacy-node',
|
||||
version: '1.8-alpha',
|
||||
reported_at: '2026-06-10T11:59:00Z',
|
||||
})
|
||||
|
||||
expect(normalized.node_id).toBe('legacy-node')
|
||||
expect(normalized.node_name).toBeNull()
|
||||
expect(normalized.hostname).toBeNull()
|
||||
expect(normalized.server_url).toBeNull()
|
||||
expect(normalized.cpu_pct).toBe(0)
|
||||
expect(normalized.mem_pct).toBe(0)
|
||||
expect(normalized.disk_pct).toBe(0)
|
||||
expect(normalized.containers).toEqual([])
|
||||
expect(normalized.recent_alerts).toEqual([])
|
||||
})
|
||||
|
||||
it('uses node name, hostname, then node id for fleet display labels', () => {
|
||||
const named = normalizeFleetNode({
|
||||
node_id: 'abcdef123456',
|
||||
node_name: 'Kitchen Node',
|
||||
hostname: 'kitchen-node',
|
||||
server_url: 'https://192.0.2.20',
|
||||
})
|
||||
const hostOnly = normalizeFleetNode({
|
||||
node_id: '123456abcdef',
|
||||
hostname: 'workshop-node',
|
||||
server_url: 'https://192.0.2.21',
|
||||
})
|
||||
const idOnly = normalizeFleetNode({ node_id: 'feedfacecafebeef' })
|
||||
|
||||
expect(fleetNodeDisplayName(named)).toBe('Kitchen Node')
|
||||
expect(fleetNodeSubtitle(named)).toBe('kitchen-node')
|
||||
expect(fleetNodeDisplayName(hostOnly)).toBe('workshop-node')
|
||||
expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.0.2.21')
|
||||
expect(fleetNodeDisplayName(idOnly)).toBe('feedface')
|
||||
expect(fleetNodeSubtitle(idOnly)).toBe('feedfacecafebeef')
|
||||
})
|
||||
|
||||
it('normalizes node history responses from backend entries or legacy history fields', () => {
|
||||
const entry = { timestamp: '2026-06-10T11:59:00Z', cpu_pct: 1, mem_pct: 2, disk_pct: 3 }
|
||||
|
||||
expect(normalizeNodeHistoryResponse({ entries: [entry] })).toEqual([entry])
|
||||
expect(normalizeNodeHistoryResponse({ history: [entry] })).toEqual([entry])
|
||||
expect(normalizeNodeHistoryResponse({})).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,533 @@
|
||||
/** Composable encapsulating fleet telemetry data fetching, types, and utilities */
|
||||
|
||||
import { ref, computed, onMounted, onUnmounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import type { ChartDataset } from '@/components/LineChart.vue'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface FleetNode {
|
||||
node_id: string
|
||||
node_name?: string | null
|
||||
hostname?: string | null
|
||||
server_url?: string | null
|
||||
version: string
|
||||
uptime_secs: number
|
||||
cpu_cores: number
|
||||
cpu_pct: number
|
||||
mem_pct: number
|
||||
disk_pct: number
|
||||
container_count: number
|
||||
running_count: number
|
||||
federation_peers: number
|
||||
recent_alerts: Array<{ rule: string; message: string; timestamp: string }>
|
||||
containers: Array<{ id: string; state: string; version: string }>
|
||||
reported_at: string
|
||||
}
|
||||
|
||||
export interface FleetAlert {
|
||||
node_id: string
|
||||
rule: string
|
||||
message: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface NodeHistoryEntry {
|
||||
timestamp: string
|
||||
cpu_pct: number
|
||||
mem_pct: number
|
||||
disk_pct: number
|
||||
}
|
||||
|
||||
export type SortOption = 'status' | 'last-seen' | 'name'
|
||||
|
||||
// --- Utility Functions ---
|
||||
|
||||
export function formatUptime(secs: number): string {
|
||||
if (secs < 60) return `${secs}s`
|
||||
const days = Math.floor(secs / 86400)
|
||||
const hours = Math.floor((secs % 86400) / 3600)
|
||||
const mins = Math.floor((secs % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
export function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
const then = new Date(dateStr).getTime()
|
||||
const diffMs = now - then
|
||||
if (diffMs < 0) return 'just now'
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
if (diffSecs < 60) return `${diffSecs}s ago`
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
export function isOnline(reportedAt: string): boolean {
|
||||
const thirtyMinMs = 30 * 60 * 1000
|
||||
return Date.now() - new Date(reportedAt).getTime() < thirtyMinMs
|
||||
}
|
||||
|
||||
export function healthBarClass(pct: number): string {
|
||||
if (pct >= 85) return 'monitoring-bar-danger'
|
||||
if (pct >= 60) return 'monitoring-bar-warn'
|
||||
return 'monitoring-bar-ok'
|
||||
}
|
||||
|
||||
export function healthTextClass(pct: number): string {
|
||||
if (pct >= 85) return 'fleet-text-danger'
|
||||
if (pct >= 60) return 'fleet-text-warn'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function alertSeverityDot(rule: string): string {
|
||||
const critical = ['container_crash', 'disk_critical', 'node_offline']
|
||||
if (critical.includes(rule)) return 'bg-red-400'
|
||||
return 'bg-orange-400'
|
||||
}
|
||||
|
||||
export function alertTypeLabel(rule: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
container_crash: 'Container Crash',
|
||||
disk_critical: 'Disk Critical',
|
||||
disk_warning: 'Disk Warning',
|
||||
ram_high: 'High RAM',
|
||||
cpu_high: 'High CPU',
|
||||
node_offline: 'Node Offline',
|
||||
version_mismatch: 'Version Mismatch',
|
||||
}
|
||||
return labels[rule] ?? rule
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: string): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleString()
|
||||
}
|
||||
|
||||
export function getContainerState(node: FleetNode, appId: string): string | null {
|
||||
const container = (node.containers || []).find(c => c.id === appId)
|
||||
if (!container) return null
|
||||
return container.state
|
||||
}
|
||||
|
||||
export function fleetNodeDisplayName(node: FleetNode): string {
|
||||
const name = node.node_name?.trim() || node.hostname?.trim()
|
||||
return name || node.node_id.slice(0, 8)
|
||||
}
|
||||
|
||||
export function fleetNodeSubtitle(node: FleetNode): string {
|
||||
const host = node.hostname?.trim()
|
||||
if (host && host !== fleetNodeDisplayName(node)) return host
|
||||
return node.server_url?.trim() || node.node_id
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
||||
{ label: 'Status', value: 'status' },
|
||||
{ label: 'Last Seen', value: 'last-seen' },
|
||||
{ label: 'Name', value: 'name' },
|
||||
]
|
||||
|
||||
export function sortFleetNodes(nodes: FleetNode[], sortBy: SortOption): FleetNode[] {
|
||||
const sorted = [...nodes]
|
||||
switch (sortBy) {
|
||||
case 'status':
|
||||
sorted.sort((a, b) => {
|
||||
const aOnline = isOnline(a.reported_at)
|
||||
const bOnline = isOnline(b.reported_at)
|
||||
if (aOnline !== bOnline) return aOnline ? -1 : 1
|
||||
return new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime()
|
||||
})
|
||||
break
|
||||
case 'last-seen':
|
||||
sorted.sort((a, b) => new Date(b.reported_at).getTime() - new Date(a.reported_at).getTime())
|
||||
break
|
||||
case 'name':
|
||||
sorted.sort((a, b) => fleetNodeDisplayName(a).localeCompare(fleetNodeDisplayName(b)))
|
||||
break
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
function numberOrZero(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
export function normalizeFleetNode(node: Partial<FleetNode>): FleetNode {
|
||||
return {
|
||||
node_id: typeof node.node_id === 'string' ? node.node_id : 'unknown',
|
||||
node_name: typeof node.node_name === 'string' ? node.node_name : null,
|
||||
hostname: typeof node.hostname === 'string' ? node.hostname : null,
|
||||
server_url: typeof node.server_url === 'string' ? node.server_url : null,
|
||||
version: typeof node.version === 'string' ? node.version : 'unknown',
|
||||
uptime_secs: numberOrZero(node.uptime_secs),
|
||||
cpu_cores: numberOrZero(node.cpu_cores),
|
||||
cpu_pct: numberOrZero(node.cpu_pct),
|
||||
mem_pct: numberOrZero(node.mem_pct),
|
||||
disk_pct: numberOrZero(node.disk_pct),
|
||||
container_count: numberOrZero(node.container_count),
|
||||
running_count: numberOrZero(node.running_count),
|
||||
federation_peers: numberOrZero(node.federation_peers),
|
||||
recent_alerts: Array.isArray(node.recent_alerts) ? node.recent_alerts : [],
|
||||
containers: Array.isArray(node.containers) ? node.containers : [],
|
||||
reported_at: typeof node.reported_at === 'string' ? node.reported_at : new Date(0).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeNodeHistoryResponse(data: {
|
||||
history?: NodeHistoryEntry[]
|
||||
entries?: NodeHistoryEntry[]
|
||||
} | null | undefined): NodeHistoryEntry[] {
|
||||
if (Array.isArray(data?.history)) return data.history
|
||||
if (Array.isArray(data?.entries)) return data.entries
|
||||
return []
|
||||
}
|
||||
|
||||
type FleetCache = {
|
||||
nodes: FleetNode[]
|
||||
fleetAlerts: FleetAlert[]
|
||||
lastRefreshed: string
|
||||
selectedNodeId: string | null
|
||||
sortBy: SortOption
|
||||
}
|
||||
|
||||
const FLEET_CACHE_KEY = 'archipelago.fleet.cache.v1'
|
||||
|
||||
function readFleetCache(): Partial<FleetCache> {
|
||||
if (typeof window === 'undefined') return {}
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(FLEET_CACHE_KEY)
|
||||
if (!raw) return {}
|
||||
const parsed = JSON.parse(raw) as Partial<FleetCache>
|
||||
return {
|
||||
nodes: Array.isArray(parsed.nodes) ? parsed.nodes.map(normalizeFleetNode) : [],
|
||||
fleetAlerts: Array.isArray(parsed.fleetAlerts) ? parsed.fleetAlerts : [],
|
||||
lastRefreshed: typeof parsed.lastRefreshed === 'string' ? parsed.lastRefreshed : '',
|
||||
selectedNodeId: typeof parsed.selectedNodeId === 'string' ? parsed.selectedNodeId : null,
|
||||
sortBy: parsed.sortBy === 'last-seen' || parsed.sortBy === 'name' ? parsed.sortBy : 'status',
|
||||
}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeFleetCache(state: FleetCache) {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
window.sessionStorage.setItem(FLEET_CACHE_KEY, JSON.stringify(state))
|
||||
} catch {
|
||||
// Cache is opportunistic only.
|
||||
}
|
||||
}
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function useFleetData() {
|
||||
const cached = readFleetCache()
|
||||
const loading = ref(!(cached.nodes?.length ?? 0))
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<FleetNode[]>(cached.nodes ?? [])
|
||||
const fleetAlerts = ref<FleetAlert[]>(cached.fleetAlerts ?? [])
|
||||
const refreshing = ref(false)
|
||||
const alertsLoading = ref(false)
|
||||
const selectedNodeId = ref<string | null>(cached.selectedNodeId ?? null)
|
||||
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
||||
const nodeHistoryLoading = ref(false)
|
||||
const autoRefresh = ref(true)
|
||||
const lastRefreshed = ref(cached.lastRefreshed ?? '')
|
||||
const sortBy = ref<SortOption>(cached.sortBy ?? 'status')
|
||||
const chartWidth = ref(300)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
// --- Computed ---
|
||||
|
||||
const onlineCount = computed(() => nodes.value.filter(n => isOnline(n.reported_at)).length)
|
||||
const offlineCount = computed(() => nodes.value.length - onlineCount.value)
|
||||
const healthyCount = computed(() => nodes.value.filter(n => n.recent_alerts.length === 0).length)
|
||||
|
||||
const fleetHealthPct = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return Math.round((healthyCount.value / nodes.value.length) * 100)
|
||||
})
|
||||
|
||||
const avgCpu = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return nodes.value.reduce((sum, n) => sum + n.cpu_pct, 0) / nodes.value.length
|
||||
})
|
||||
|
||||
const avgMem = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return nodes.value.reduce((sum, n) => sum + n.mem_pct, 0) / nodes.value.length
|
||||
})
|
||||
|
||||
const avgDisk = computed(() => {
|
||||
if (!nodes.value.length) return 0
|
||||
return nodes.value.reduce((sum, n) => sum + n.disk_pct, 0) / nodes.value.length
|
||||
})
|
||||
|
||||
const selectedNode = computed(() => {
|
||||
if (!selectedNodeId.value) return null
|
||||
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
|
||||
})
|
||||
|
||||
const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
|
||||
|
||||
const allAppIds = computed(() => {
|
||||
const appSet = new Set<string>()
|
||||
for (const node of nodes.value) {
|
||||
for (const c of (node.containers || [])) {
|
||||
appSet.add(c.id)
|
||||
}
|
||||
}
|
||||
return Array.from(appSet).sort()
|
||||
})
|
||||
|
||||
// Node history chart datasets
|
||||
const nodeHistoryLabels = computed(() => {
|
||||
return nodeHistory.value.map(h => {
|
||||
const d = new Date(h.timestamp)
|
||||
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||
})
|
||||
})
|
||||
|
||||
const nodeHistoryCpuDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'CPU',
|
||||
data: nodeHistory.value.map(h => h.cpu_pct),
|
||||
color: '#fb923c',
|
||||
}])
|
||||
|
||||
const nodeHistoryMemDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'RAM',
|
||||
data: nodeHistory.value.map(h => h.mem_pct),
|
||||
color: '#3b82f6',
|
||||
}])
|
||||
|
||||
const nodeHistoryDiskDatasets = computed<ChartDataset[]>(() => [{
|
||||
label: 'Disk',
|
||||
data: nodeHistory.value.map(h => h.disk_pct),
|
||||
color: '#a78bfa',
|
||||
}])
|
||||
|
||||
// --- Data Fetching ---
|
||||
|
||||
async function fetchFleetStatus() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||
method: 'telemetry.fleet-status',
|
||||
})
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (loading.value) {
|
||||
errorMessage.value = err instanceof Error ? err.message : 'Failed to load fleet data'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFleetAlerts() {
|
||||
alertsLoading.value = true
|
||||
try {
|
||||
const data = await rpcClient.call<{ alerts: FleetAlert[] }>({
|
||||
method: 'telemetry.fleet-alerts',
|
||||
})
|
||||
if (data?.alerts) {
|
||||
fleetAlerts.value = data.alerts
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Non-critical, retry on next poll
|
||||
} finally {
|
||||
alertsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNodeHistory(nodeId: string) {
|
||||
nodeHistoryLoading.value = true
|
||||
try {
|
||||
const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
|
||||
method: 'telemetry.fleet-node-history',
|
||||
params: { node_id: nodeId },
|
||||
})
|
||||
nodeHistory.value = normalizeNodeHistoryResponse(data)
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
nodeHistoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
loading.value = !nodes.value.length
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(nodeId: string) {
|
||||
if (selectedNodeId.value === nodeId) {
|
||||
selectedNodeId.value = null
|
||||
nodeHistory.value = []
|
||||
} else {
|
||||
selectedNodeId.value = nodeId
|
||||
}
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
pollTimer = setInterval(async () => {
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
if (selectedNodeId.value) {
|
||||
await fetchNodeHistory(selectedNodeId.value)
|
||||
}
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function exportFleetData() {
|
||||
const exportData = {
|
||||
exported_at: new Date().toISOString(),
|
||||
nodes: nodes.value,
|
||||
alerts: fleetAlerts.value,
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `fleet-telemetry-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function updateChartWidth() {
|
||||
const container = document.querySelector('.glass-card')
|
||||
if (container) {
|
||||
const cardWidth = container.clientWidth
|
||||
chartWidth.value = Math.max(Math.floor((cardWidth - 80) / 3), 200)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch node history when selection changes
|
||||
watch(selectedNodeId, (newId) => {
|
||||
if (newId) {
|
||||
fetchNodeHistory(newId)
|
||||
} else {
|
||||
nodeHistory.value = []
|
||||
}
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
})
|
||||
|
||||
watch(sortBy, () => {
|
||||
writeFleetCache({
|
||||
nodes: nodes.value,
|
||||
fleetAlerts: fleetAlerts.value,
|
||||
lastRefreshed: lastRefreshed.value,
|
||||
selectedNodeId: selectedNodeId.value,
|
||||
sortBy: sortBy.value,
|
||||
})
|
||||
})
|
||||
|
||||
// --- Lifecycle ---
|
||||
|
||||
// 02-11 gap closure: `startAutoRefresh()`'s 60s poll used to be armed only
|
||||
// in onMounted and disarmed only in onUnmounted — harmless before Fleet
|
||||
// joined KEEP_ALIVE_PATHS (02-04), since leaving the tab fully unmounted
|
||||
// this composable's owning component and onUnmounted fired every time.
|
||||
// Once Fleet is KeepAlive'd, onUnmounted never fires again after the first
|
||||
// visit, so the poll ran forever in the background regardless of tab
|
||||
// visibility — the exact off-screen-drain class 02-04's own audit
|
||||
// convention exists to prevent, missed here because that audit grepped
|
||||
// Fleet.vue itself (which has no lifecycle side effects of its own) and
|
||||
// never extended to this composable it delegates to. Arm/disarm now
|
||||
// follows activate/deactivate, mirroring Server.vue's vpnPollInterval
|
||||
// fix from 02-04; `startAutoRefresh()` already clears any existing timer
|
||||
// first, so calling it from both onMounted and onActivated on a fresh
|
||||
// KeepAlive-wrapped mount is safe and idempotent.
|
||||
function armFleetPoll() {
|
||||
if (autoRefresh.value) startAutoRefresh()
|
||||
}
|
||||
function disarmFleetPoll() {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateChartWidth()
|
||||
window.addEventListener('resize', updateChartWidth)
|
||||
await refreshAll()
|
||||
armFleetPoll()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
armFleetPoll()
|
||||
})
|
||||
|
||||
onDeactivated(() => {
|
||||
disarmFleetPoll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
disarmFleetPoll()
|
||||
window.removeEventListener('resize', updateChartWidth)
|
||||
})
|
||||
|
||||
return {
|
||||
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
||||
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
||||
onlineCount, offlineCount, healthyCount, fleetHealthPct,
|
||||
avgCpu, avgMem, avgDisk, sortedNodes, allAppIds,
|
||||
nodeHistoryLabels, nodeHistoryCpuDatasets, nodeHistoryMemDatasets, nodeHistoryDiskDatasets,
|
||||
refreshAll, selectNode, toggleAutoRefresh, exportFleetData,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user