From 5bbcd4c95bc1b6bdadad0aab01821de4044471ee Mon Sep 17 00:00:00 2001 From: ComasSky Date: Tue, 30 Dec 2025 18:35:53 +0100 Subject: [PATCH 01/56] Add average --- src/main/web/src/components/PeerTable.vue | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/main/web/src/components/PeerTable.vue b/src/main/web/src/components/PeerTable.vue index eeefdd7..03eb0a9 100644 --- a/src/main/web/src/components/PeerTable.vue +++ b/src/main/web/src/components/PeerTable.vue @@ -9,6 +9,41 @@ formatTimestampToLocale } from '@utils/formatting'; {{ type === 'inbound' ? 'Inbound Peers' : 'Outbound Peers' }} ({{ peers.length }}) + +
+
+ Average Ping + {{ + avgMinPing !== null ? formatPingSmart(avgMinPing) : 'N/A' + }} +
+
+ Average Received + {{ + avgBytesRecv !== null ? formatBytesIEC(avgBytesRecv) : 'N/A' + }} +
+
+ Average Sent + {{ + avgBytesSent !== null ? formatBytesIEC(avgBytesSent) : 'N/A' + }} +
+
+ Average Time Offset + {{ + avgTimeOffset !== null ? formatSecondsWithSuffix(avgTimeOffset) : 'N/A' + }} +
+
+ Average Connection Time + {{ + avgConnTime !== null ? formatRelativeTimeSince(avgConnTime) : 'N/A' + }} +
+
@@ -93,6 +128,7 @@ formatTimestampToLocale } from '@utils/formatting'; + { : String(bVal).localeCompare(String(aVal)); }); }); + +// Averages for relevant numeric columns +const average = (arr: (number | null | undefined)[]) => { + const nums = arr.filter((v): v is number => typeof v === 'number' && !isNaN(v)); + if (!nums.length) return null; + return nums.reduce((a, b) => a + b, 0) / nums.length; +}; + +const avgMinPing = computed(() => average(props.peers.map((p) => p.minping))); +const avgBytesRecv = computed(() => average(props.peers.map((p) => p.bytesrecv))); +const avgBytesSent = computed(() => average(props.peers.map((p) => p.bytessent))); +const avgTimeOffset = computed(() => average(props.peers.map((p) => p.timeoffset))); +const avgConnTime = computed(() => average(props.peers.map((p) => p.conntime))); From 0354c593d60fc7dbb3baf9b4867660055aa03db5 Mon Sep 17 00:00:00 2001 From: ComasSky Date: Tue, 30 Dec 2025 19:27:48 +0100 Subject: [PATCH 02/56] Add average --- src/main/web/src/App.vue | 35 ++++++++--- src/main/web/src/components/Footer.vue | 10 ++- .../src/components/PeerDistributionChart.vue | 61 +++++++++---------- src/main/web/src/components/PeerTable.vue | 33 +++++----- src/main/web/src/components/PeerTableRow.vue | 8 +-- src/main/web/src/components/Spinner.vue | 37 +++++++++-- src/main/web/src/components/Status.vue | 38 ++++++------ src/main/web/src/components/Tooltip.vue | 40 ++++++------ .../web/src/components/cards/BlockCard.vue | 11 +--- src/main/web/src/composables/useWebSocket.ts | 5 -- src/main/web/src/utils/deepMerge.ts | 25 ++++---- src/main/web/src/utils/formatting.ts | 11 +++- src/main/web/src/utils/nodeHealth.ts | 14 +++-- 13 files changed, 179 insertions(+), 149 deletions(-) diff --git a/src/main/web/src/App.vue b/src/main/web/src/App.vue index 9c85ada..1caa70c 100644 --- a/src/main/web/src/App.vue +++ b/src/main/web/src/App.vue @@ -6,7 +6,6 @@ import { useMockData } from '@composables/useMockData'; import { storeToRefs } from 'pinia'; // Async Components -const Status = defineAsyncComponent(() => import('@components/Status.vue')); const PeersCard = defineAsyncComponent(() => import('@components/cards/PeersCard.vue')); const BlockCard = defineAsyncComponent(() => import('@components/cards/BlockCard.vue')); const NodeCard = defineAsyncComponent(() => import('@components/cards/NodeCard.vue')); @@ -15,10 +14,10 @@ const PeerDistributionChart = defineAsyncComponent( ); const MempoolInfoCard = defineAsyncComponent(() => import('@components/cards/MempoolInfoCard.vue')); const PeerTable = defineAsyncComponent(() => import('@components/PeerTable.vue')); -const Footer = defineAsyncComponent(() => import('@components/Footer.vue')); -const BaseCardSkeleton = defineAsyncComponent( - () => import('@components/cards/BaseCardSkeleton.vue') -); + +import Status from '@components/Status.vue'; +import Footer from '@components/Footer.vue'; +import BaseCardSkeleton from '@components/cards/BaseCardSkeleton.vue'; // Store const dashboardStore = useDashboardStore(); @@ -49,6 +48,24 @@ const { // Computed properties for cleaner template logic const shouldShowContent = computed(() => MOCK_MODE.value || rpcConnected.value); +const connectionState = computed(() => { + if (MOCK_MODE.value) { + const mockState = getMockConnectionState(); + return { + isConnected: mockState.isConnected, + rpcConnected: mockState.rpcConnected, + errorMessage: mockState.errorMessage, + isRetrying: false, + }; + } + return { + isConnected: isConnected.value, + rpcConnected: rpcConnected.value, + errorMessage: errorMessage.value, + isRetrying: isRetrying.value, + }; +}); + const themeIcons: { [key: string]: string[] } = { light: ['fas', 'sun'], dark: ['fas', 'moon'], @@ -112,13 +129,13 @@ onBeforeUnmount(() => { diff --git a/src/main/web/src/components/Footer.vue b/src/main/web/src/components/Footer.vue index 39ef6c6..9ff428f 100644 --- a/src/main/web/src/components/Footer.vue +++ b/src/main/web/src/components/Footer.vue @@ -17,7 +17,7 @@
- v{{ version }} + v{{ props.version }} diff --git a/src/main/web/src/components/PeerDistributionChart.vue b/src/main/web/src/components/PeerDistributionChart.vue index 014f1ad..154a11f 100644 --- a/src/main/web/src/components/PeerDistributionChart.vue +++ b/src/main/web/src/components/PeerDistributionChart.vue @@ -17,7 +17,6 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; library.add(faArrowDown, faArrowUp); Chart.register(ArcElement, Tooltip, Legend, PieController); -Chart.defaults.animation = false; // --- Color utilities --- @@ -50,19 +49,6 @@ const generateColors = (num: number) => { }; // --- Chart.js config --- -const updateChartDefaults = () => { - const textPrimary = getCssVar('--text-primary'); - const borderStrong = getCssVar('--border-strong'); - const bgCard = getCssVar('--bg-card'); - const textSecondary = getCssVar('--text-secondary'); - Chart.defaults.color = textPrimary; - Chart.defaults.borderColor = borderStrong; - Chart.defaults.backgroundColor = bgCard; - Chart.defaults.plugins.legend.labels.color = textPrimary; - Chart.defaults.plugins.tooltip.backgroundColor = bgCard; - Chart.defaults.plugins.tooltip.bodyColor = textPrimary; - Chart.defaults.plugins.tooltip.titleColor = textSecondary; -}; const getChartOptions = (): ChartOptions<'doughnut'> => { const bgCard = getCssVar('--bg-card'); const textPrimary = getCssVar('--text-primary'); @@ -72,9 +58,14 @@ const getChartOptions = (): ChartOptions<'doughnut'> => { responsive: true, maintainAspectRatio: false, animation: false, + color: textPrimary, + borderColor: borderStrong, layout: { padding: 0 }, plugins: { - legend: { display: false }, + legend: { + display: false, + labels: { color: textPrimary }, + }, tooltip: { mode: 'point', intersect: true, @@ -107,24 +98,26 @@ const props = defineProps<{ const canvasRef = ref(null); let chartInstance: Chart | null = null; const hoveredIndex = ref(null); +const styleVersion = ref(0); const chartLabels = computed(() => props.peers.map((p) => p.server || '[Unknown]')); -const chartColors = computed(() => generateColors(props.peers.length)); +const chartColors = computed(() => { + styleVersion.value; // Dependency to force update on theme change + return generateColors(props.peers.length); +}); // --- Chart.js helpers --- -const extractChartData = (data: SubverDistribution[]) => { +const extractChartData = (data: SubverDistribution[], colors: string[]) => { const labels = data.map((d) => d.server || '[Unknown]'); const percentages = data.map((d) => d.percentage); - const backgroundColors = generateColors(data.length); - return { labels, percentages, backgroundColors }; + return { labels, percentages, backgroundColors: colors }; }; const destroyChart = () => { chartInstance?.destroy(); chartInstance = null; }; -const initChart = (data: SubverDistribution[]): Chart | null => { +const initChart = (data: SubverDistribution[], colors: string[]): Chart | null => { if (!canvasRef.value) return null; - updateChartDefaults(); - const { labels, percentages, backgroundColors } = extractChartData(data); + const { labels, percentages, backgroundColors } = extractChartData(data, colors); const ctx = canvasRef.value.getContext('2d'); if (!ctx) return null; return new Chart(ctx, { @@ -144,9 +137,8 @@ const initChart = (data: SubverDistribution[]): Chart | null => { options: getChartOptions(), }); }; -const updateChartData = (chart: Chart, data: SubverDistribution[]) => { - updateChartDefaults(); - const { labels, percentages, backgroundColors } = extractChartData(data); +const updateChartData = (chart: Chart, data: SubverDistribution[], colors: string[]) => { + const { labels, percentages, backgroundColors } = extractChartData(data, colors); chart.data.labels = labels; chart.data.datasets[0].data = percentages; chart.data.datasets[0].backgroundColor = backgroundColors; @@ -177,22 +169,27 @@ const handleLegendLeave = () => { watch( () => props.peers, (val) => { - if (chartInstance) updateChartData(chartInstance, val); + if (chartInstance) updateChartData(chartInstance, val, chartColors.value); else nextTick(() => { - if (!chartInstance) chartInstance = initChart(val); + if (!chartInstance) chartInstance = initChart(val, chartColors.value); }); }, { deep: true, immediate: true } ); watch( () => props.isDarkMode, - () => { + async () => { + await nextTick(); // Wait for DOM/CSS updates invalidateCssCache(); - destroyChart(); - nextTick(() => { - chartInstance = initChart(props.peers); - }); + styleVersion.value++; + if (chartInstance) { + chartInstance.options = getChartOptions(); + if (chartInstance.data.datasets[0]) { + chartInstance.data.datasets[0].backgroundColor = chartColors.value; + } + chartInstance.update('none'); + } } ); onBeforeUnmount(destroyChart); diff --git a/src/main/web/src/components/PeerTable.vue b/src/main/web/src/components/PeerTable.vue index 03eb0a9..eb75d77 100644 --- a/src/main/web/src/components/PeerTable.vue +++ b/src/main/web/src/components/PeerTable.vue @@ -171,29 +171,28 @@ function setSort(key: keyof Peer) { } } +function compare(a: unknown, b: unknown, order: 'asc' | 'desc') { + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + if (typeof a === 'number' && typeof b === 'number') { + return order === 'asc' ? a - b : b - a; + } + return order === 'asc' ? String(a).localeCompare(String(b)) : String(b).localeCompare(String(a)); +} + const sortedPeers = computed(() => { const key = sortKey.value; - return [...props.peers].sort((a, b) => { - const aVal = a[key]; - const bVal = b[key]; - if (aVal == null && bVal == null) return 0; - if (aVal == null) return 1; - if (bVal == null) return -1; - if (typeof aVal === 'number' && typeof bVal === 'number') { - return sortOrder.value === 'asc' ? aVal - bVal : bVal - aVal; - } - return sortOrder.value === 'asc' - ? String(aVal).localeCompare(String(bVal)) - : String(bVal).localeCompare(String(aVal)); - }); + const order = sortOrder.value; + return [...props.peers].sort((a, b) => compare(a[key], b[key], order)); }); // Averages for relevant numeric columns -const average = (arr: (number | null | undefined)[]) => { + +function average(arr: Array): number | null { const nums = arr.filter((v): v is number => typeof v === 'number' && !isNaN(v)); - if (!nums.length) return null; - return nums.reduce((a, b) => a + b, 0) / nums.length; -}; + return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null; +} const avgMinPing = computed(() => average(props.peers.map((p) => p.minping))); const avgBytesRecv = computed(() => average(props.peers.map((p) => p.bytesrecv))); diff --git a/src/main/web/src/components/PeerTableRow.vue b/src/main/web/src/components/PeerTableRow.vue index 3aad046..a6afe26 100644 --- a/src/main/web/src/components/PeerTableRow.vue +++ b/src/main/web/src/components/PeerTableRow.vue @@ -92,12 +92,12 @@ const props = defineProps<{ peer: Peer; type: 'inbound' | 'outbound' }>(); +const props = withDefaults(defineProps<{ + size?: number | string; // px or tailwind class + accentColor?: string; + bgColor?: string; + colorClass?: string; +}>(), { + size: 48, + accentColor: '#f7931a', + bgColor: '#fff', + colorClass: 'text-accent', +}); + +const sizePx = computed(() => typeof props.size === 'number' ? props.size : 48); +const sizeClass = computed(() => typeof props.size === 'string' ? props.size : `h-12 w-12`); + + + diff --git a/src/main/web/src/components/Status.vue b/src/main/web/src/components/Status.vue index 2745edb..88a7fb2 100644 --- a/src/main/web/src/components/Status.vue +++ b/src/main/web/src/components/Status.vue @@ -30,21 +30,20 @@ const props = defineProps<{ const hasLowOutbound = computed(() => hasLowOutboundPeers(props.outboundPeers)); const isOutOfSync = computed(() => isNodeOutOfSync(props.blockchain, props.block)); const hasWarnings = computed(() => hasLowOutbound.value || isOutOfSync.value); -const isHealthy = computed(() => props.isConnected && props.rpcConnected && !hasWarnings.value); -const statusClass = computed(() => - isHealthy.value - ? 'bg-status-success/10 border border-status-success text-status-success' - : props.isConnected && props.rpcConnected && hasWarnings.value - ? 'bg-status-warning/10 border border-status-warning text-status-warning' - : 'bg-status-error/10 border border-status-error text-status-error pulse-error' -); -const badgeTextClass = computed(() => - isHealthy.value - ? 'text-status-success' - : props.isConnected && props.rpcConnected && hasWarnings.value - ? 'text-status-warning' - : 'text-status-error' -); +const isConnectedAndRpc = computed(() => props.isConnected && props.rpcConnected); +const isHealthy = computed(() => isConnectedAndRpc.value && !hasWarnings.value); + +const statusClass = computed(() => { + if (isHealthy.value) return 'bg-status-success/10 border border-status-success text-status-success'; + if (isConnectedAndRpc.value && hasWarnings.value) return 'bg-status-warning/10 border border-status-warning text-status-warning'; + return 'bg-status-error/10 border border-status-error text-status-error pulse-error'; +}); + +const badgeTextClass = computed(() => { + if (isHealthy.value) return 'text-status-success'; + if (isConnectedAndRpc.value && hasWarnings.value) return 'text-status-warning'; + return 'text-status-error'; +}); diff --git a/src/main/web/src/components/PeerTableRow.vue b/src/main/web/src/components/PeerTableRow.vue index a6afe26..bbbfd6f 100644 --- a/src/main/web/src/components/PeerTableRow.vue +++ b/src/main/web/src/components/PeerTableRow.vue @@ -5,7 +5,7 @@
@@ -32,13 +32,13 @@ {{ formatRelativeTimeSince(peer.conntime) }} @@ -50,7 +50,7 @@ {{ peer.connection_type }} @@ -58,7 +58,7 @@ {{ formatBytesIEC(peer.bytesrecv) }} @@ -67,7 +67,7 @@ {{ formatBytesIEC(peer.bytessent) }} diff --git a/src/main/web/src/components/Status.vue b/src/main/web/src/components/Status.vue index b92453d..4239e2d 100644 --- a/src/main/web/src/components/Status.vue +++ b/src/main/web/src/components/Status.vue @@ -1,6 +1,6 @@ diff --git a/src/main/web/src/components/__tests__/Tooltip.test.ts b/src/main/web/src/components/__tests__/Tooltip.test.ts index 3a437ab..d40eb1c 100644 --- a/src/main/web/src/components/__tests__/Tooltip.test.ts +++ b/src/main/web/src/components/__tests__/Tooltip.test.ts @@ -8,27 +8,23 @@ describe('Tooltip.vue', () => { props: { text: 'info' }, slots: { default: '' }, }); + expect(wrapper.find('button').exists()).toBe(true); expect(wrapper.text()).toContain('Hover me'); }); - it('shows tooltip on mouseenter and hides on mouseleave', async () => { + it('renders with top position by default', () => { const wrapper = mount(Tooltip, { props: { text: 'info' }, - slots: { default: '' }, + slots: { default: 'Hover' }, }); - // Trigger mouseenter and check if tooltip is rendered - await wrapper.trigger('mouseenter'); - expect(document.body.innerHTML).toContain('info'); - // Trigger mouseleave and check if tooltip is removed - await wrapper.trigger('mouseleave'); - expect(document.body.innerHTML).not.toContain('info'); + expect(wrapper.find('span').exists()).toBe(true); }); - it('positions tooltip according to prop', async () => { + it('renders with custom position', () => { const wrapper = mount(Tooltip, { props: { text: 'info', position: 'bottom' }, slots: { default: 'Hover' }, }); - expect(wrapper.props('position')).toBe('bottom'); + expect(wrapper.find('span').exists()).toBe(true); }); }); diff --git a/src/main/web/src/components/cards/BlockCard.vue b/src/main/web/src/components/cards/BlockCard.vue index 977f8e5..945cbdd 100644 --- a/src/main/web/src/components/cards/BlockCard.vue +++ b/src/main/web/src/components/cards/BlockCard.vue @@ -21,7 +21,7 @@ : 'Current height of the blockchain. This is the number of blocks in the chain. Click to view on mempool.org.' " position="bottom" - horizontal="center" + > @@ -80,7 +80,7 @@ @@ -93,7 +93,7 @@ diff --git a/src/main/web/src/components/cards/NodeCard.vue b/src/main/web/src/components/cards/NodeCard.vue index a93288f..89eeba1 100644 --- a/src/main/web/src/components/cards/NodeCard.vue +++ b/src/main/web/src/components/cards/NodeCard.vue @@ -13,7 +13,7 @@
{{ cleanedSubversion }} @@ -27,7 +27,7 @@
{{ formatUptime(upTime) }} @@ -44,7 +44,7 @@

@@ -58,7 +58,7 @@

@@ -74,7 +74,7 @@

- + {{ peer.subver || '[Empty]' }} - + {{ peer.version }} - + {{ peer.network || 'N/A' }} - + {{ formatPingSmart(peer.minping) }}