frontend: polish app launch and release experience
This commit is contained in:
@@ -28,9 +28,12 @@
|
||||
</div>
|
||||
|
||||
<!-- History Charts -->
|
||||
<div v-if="historyLoading" class="text-white/40 text-sm py-4 text-center mb-4">
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { isOnline, normalizeFleetNode, normalizeNodeHistoryResponse, sortFleetNodes, type FleetNode } from '../useFleetData'
|
||||
|
||||
function node(id: string, reportedAt: string): FleetNode {
|
||||
return {
|
||||
node_id: id,
|
||||
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', () => {
|
||||
expect(sortFleetNodes([
|
||||
node('zulu', '2026-06-10T11:59:00Z'),
|
||||
node('alpha', '2026-06-10T11:59:00Z'),
|
||||
], '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.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('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([])
|
||||
})
|
||||
})
|
||||
@@ -118,6 +118,58 @@ export const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [
|
||||
{ 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) => a.node_id.localeCompare(b.node_id))
|
||||
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',
|
||||
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 []
|
||||
}
|
||||
|
||||
// --- Composable ---
|
||||
|
||||
export function useFleetData() {
|
||||
@@ -125,6 +177,7 @@ export function useFleetData() {
|
||||
const errorMessage = ref('')
|
||||
const nodes = ref<FleetNode[]>([])
|
||||
const fleetAlerts = ref<FleetAlert[]>([])
|
||||
const refreshing = ref(false)
|
||||
const alertsLoading = ref(false)
|
||||
const selectedNodeId = ref<string | null>(null)
|
||||
const nodeHistory = ref<NodeHistoryEntry[]>([])
|
||||
@@ -166,26 +219,7 @@ export function useFleetData() {
|
||||
return nodes.value.find(n => n.node_id === selectedNodeId.value) ?? null
|
||||
})
|
||||
|
||||
const sortedNodes = computed(() => {
|
||||
const sorted = [...nodes.value]
|
||||
switch (sortBy.value) {
|
||||
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) => a.node_id.localeCompare(b.node_id))
|
||||
break
|
||||
}
|
||||
return sorted
|
||||
})
|
||||
const sortedNodes = computed(() => sortFleetNodes(nodes.value, sortBy.value))
|
||||
|
||||
const allAppIds = computed(() => {
|
||||
const appSet = new Set<string>()
|
||||
@@ -227,11 +261,11 @@ export function useFleetData() {
|
||||
|
||||
async function fetchFleetStatus() {
|
||||
try {
|
||||
const data = await rpcClient.call<{ nodes: FleetNode[] }>({
|
||||
const data = await rpcClient.call<{ nodes: Partial<FleetNode>[] }>({
|
||||
method: 'telemetry.fleet-status',
|
||||
})
|
||||
if (data?.nodes) {
|
||||
nodes.value = data.nodes
|
||||
nodes.value = data.nodes.map(normalizeFleetNode)
|
||||
lastRefreshed.value = new Date().toISOString()
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -259,15 +293,12 @@ export function useFleetData() {
|
||||
|
||||
async function fetchNodeHistory(nodeId: string) {
|
||||
nodeHistoryLoading.value = true
|
||||
nodeHistory.value = []
|
||||
try {
|
||||
const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({
|
||||
const data = await rpcClient.call<{ history?: NodeHistoryEntry[]; entries?: NodeHistoryEntry[] }>({
|
||||
method: 'telemetry.fleet-node-history',
|
||||
params: { node_id: nodeId },
|
||||
})
|
||||
if (data?.history) {
|
||||
nodeHistory.value = data.history
|
||||
}
|
||||
nodeHistory.value = normalizeNodeHistoryResponse(data)
|
||||
} catch {
|
||||
// Non-critical
|
||||
} finally {
|
||||
@@ -277,9 +308,14 @@ export function useFleetData() {
|
||||
|
||||
async function refreshAll() {
|
||||
loading.value = !nodes.value.length
|
||||
refreshing.value = true
|
||||
errorMessage.value = ''
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
loading.value = false
|
||||
try {
|
||||
await Promise.all([fetchFleetStatus(), fetchFleetAlerts()])
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectNode(nodeId: string) {
|
||||
@@ -288,7 +324,6 @@ export function useFleetData() {
|
||||
nodeHistory.value = []
|
||||
} else {
|
||||
selectedNodeId.value = nodeId
|
||||
fetchNodeHistory(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +402,7 @@ export function useFleetData() {
|
||||
})
|
||||
|
||||
return {
|
||||
loading, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
loading, refreshing, errorMessage, nodes, fleetAlerts, alertsLoading,
|
||||
selectedNodeId, selectedNode, nodeHistory, nodeHistoryLoading,
|
||||
autoRefresh, lastRefreshed, sortBy, chartWidth,
|
||||
onlineCount, offlineCount, healthyCount, fleetHealthPct,
|
||||
|
||||
Reference in New Issue
Block a user