frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
+4 -1
View File
@@ -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([])
})
})
+66 -31
View File
@@ -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,