/** Composable encapsulating fleet telemetry data fetching, types, and utilities */ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { rpcClient } from '@/api/rpc-client' import type { ChartDataset } from '@/components/LineChart.vue' // --- Types --- export interface FleetNode { node_id: string 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 = { 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 const SORT_OPTIONS: Array<{ label: string; value: SortOption }> = [ { label: 'Status', value: 'status' }, { label: 'Last Seen', value: 'last-seen' }, { label: 'Name', value: 'name' }, ] // --- Composable --- export function useFleetData() { const loading = ref(true) const errorMessage = ref('') const nodes = ref([]) const fleetAlerts = ref([]) const alertsLoading = ref(false) const selectedNodeId = ref(null) const nodeHistory = ref([]) const nodeHistoryLoading = ref(false) const autoRefresh = ref(true) const lastRefreshed = ref('') const sortBy = ref('status') const chartWidth = ref(300) let pollTimer: ReturnType | 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(() => { 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 allAppIds = computed(() => { const appSet = new Set() 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(() => [{ label: 'CPU', data: nodeHistory.value.map(h => h.cpu_pct), color: '#fb923c', }]) const nodeHistoryMemDatasets = computed(() => [{ label: 'RAM', data: nodeHistory.value.map(h => h.mem_pct), color: '#3b82f6', }]) const nodeHistoryDiskDatasets = computed(() => [{ label: 'Disk', data: nodeHistory.value.map(h => h.disk_pct), color: '#a78bfa', }]) // --- Data Fetching --- async function fetchFleetStatus() { try { const data = await rpcClient.call<{ nodes: FleetNode[] }>({ method: 'telemetry.fleet-status', }) if (data?.nodes) { nodes.value = data.nodes lastRefreshed.value = new Date().toISOString() } } 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 } } catch { // Non-critical, retry on next poll } finally { alertsLoading.value = false } } async function fetchNodeHistory(nodeId: string) { nodeHistoryLoading.value = true nodeHistory.value = [] try { const data = await rpcClient.call<{ history: NodeHistoryEntry[] }>({ method: 'telemetry.fleet-node-history', params: { node_id: nodeId }, }) if (data?.history) { nodeHistory.value = data.history } } catch { // Non-critical } finally { nodeHistoryLoading.value = false } } async function refreshAll() { loading.value = !nodes.value.length errorMessage.value = '' await Promise.all([fetchFleetStatus(), fetchFleetAlerts()]) loading.value = false } function selectNode(nodeId: string) { if (selectedNodeId.value === nodeId) { selectedNodeId.value = null nodeHistory.value = [] } else { selectedNodeId.value = nodeId fetchNodeHistory(nodeId) } } 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 = [] } }) // --- Lifecycle --- onMounted(async () => { updateChartWidth() window.addEventListener('resize', updateChartWidth) await refreshAll() if (autoRefresh.value) { startAutoRefresh() } }) onUnmounted(() => { stopAutoRefresh() window.removeEventListener('resize', updateChartWidth) }) return { loading, 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, } }