From 8d21959bcd667ab949d3d9f2b59e0f0f8b7c6192 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Fri, 26 Dec 2025 04:07:32 -0800 Subject: [PATCH] fix(service-registry): prevent service card flashing on WebSocket updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add silent refresh mode to useServices hook that shows "Refreshing..." indicator instead of replacing the entire UI with loading state - WebSocket events (health updates, registrations) now use silentRefetch to avoid jarring UI flash when data updates in background - Calculate uptime from registeredAt when service doesn't report uptime - Add spinning refresh indicator in header during background updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../src/components/ServiceOverview.tsx | 35 +++++++++++++++- .../apps/dashboard/src/hooks/useServices.ts | 40 ++++++++++++++++--- .../apps/dashboard/src/pages/Services.tsx | 11 ++--- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/infrastructure/service-registry/apps/dashboard/src/components/ServiceOverview.tsx b/infrastructure/service-registry/apps/dashboard/src/components/ServiceOverview.tsx index 81655f2ed..fa52a6951 100644 --- a/infrastructure/service-registry/apps/dashboard/src/components/ServiceOverview.tsx +++ b/infrastructure/service-registry/apps/dashboard/src/components/ServiceOverview.tsx @@ -144,9 +144,35 @@ const MetricLabel = styled.div` text-transform: uppercase; `; +const RefreshingIndicator = styled.div` + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + color: #4ecdc4; + opacity: 0.8; +`; + +const RefreshSpinner = styled.span` + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(78, 205, 196, 0.3); + border-top-color: #4ecdc4; + border-radius: 50%; + animation: spin 0.8s linear infinite; + + @keyframes spin { + to { + transform: rotate(360deg); + } + } +`; + interface ServiceOverviewProps { services: ServiceInfo[]; loading: boolean; + refreshing?: boolean; viewMode: 'grid' | 'table'; onServiceSelect: (serviceName: string | null) => void; selectedService: string | null; @@ -155,6 +181,7 @@ interface ServiceOverviewProps { export function ServiceOverview({ services, loading, + refreshing = false, viewMode, onServiceSelect, selectedService @@ -183,6 +210,12 @@ export function ServiceOverview({
Registered Services + {refreshing && ( + + + Refreshing... + + )}
@@ -231,7 +264,7 @@ export function ServiceOverview({ - {service.uptime ? Math.floor(service.uptime / 1000 / 60) : 0}m + {Math.floor((service.uptime ?? (Date.now() - new Date(service.registeredAt).getTime())) / 1000 / 60)}m Uptime diff --git a/infrastructure/service-registry/apps/dashboard/src/hooks/useServices.ts b/infrastructure/service-registry/apps/dashboard/src/hooks/useServices.ts index 310973505..57ed953b6 100644 --- a/infrastructure/service-registry/apps/dashboard/src/hooks/useServices.ts +++ b/infrastructure/service-registry/apps/dashboard/src/hooks/useServices.ts @@ -1,12 +1,15 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import axios from 'axios'; import { ServiceInfo } from '@service-registry/types'; interface UseServicesResult { services: ServiceInfo[]; loading: boolean; + refreshing: boolean; error: string | null; refetch: () => void; + silentRefetch: () => void; + updateServiceHealth: (serviceName: string, healthData: Partial) => void; } // Stable sort key for consistent ordering @@ -17,7 +20,9 @@ function getServiceSortKey(service: ServiceInfo): string { export function useServices(): UseServicesResult { const [rawServices, setRawServices] = useState([]); const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); + const initialLoadDone = useRef(false); // Sort services by name for stable positioning on updates const services = useMemo(() => { @@ -26,9 +31,15 @@ export function useServices(): UseServicesResult { ); }, [rawServices]); - const fetchServices = useCallback(async () => { + const fetchServices = useCallback(async (showLoading = true) => { try { - setLoading(true); + // Only show loading on initial fetch, not on subsequent refreshes + if (showLoading && !initialLoadDone.current) { + setLoading(true); + } else if (!showLoading && initialLoadDone.current) { + // Silent refresh - show refreshing indicator instead + setRefreshing(true); + } setError(null); // Use relative URL for same-origin deployment, fallback to localhost for dev @@ -38,22 +49,41 @@ export function useServices(): UseServicesResult { // Ensure response.data is an array const fetchedServices = Array.isArray(response.data) ? response.data : []; setRawServices(fetchedServices); + initialLoadDone.current = true; } catch (err) { console.error('Failed to fetch services:', err); setError(err instanceof Error ? err.message : 'Failed to fetch services'); } finally { setLoading(false); + setRefreshing(false); } }, []); + // Silent refetch - no loading state change + const silentRefetch = useCallback(() => { + fetchServices(false); + }, [fetchServices]); + + // Update a specific service's health data without full refetch + const updateServiceHealth = useCallback((serviceName: string, healthData: Partial) => { + setRawServices(prev => prev.map(service => + service.name === serviceName + ? { ...service, ...healthData } + : service + )); + }, []); + useEffect(() => { - fetchServices(); + fetchServices(true); }, [fetchServices]); return { services, loading, + refreshing, error, - refetch: fetchServices + refetch: () => fetchServices(true), + silentRefetch, + updateServiceHealth }; } \ No newline at end of file diff --git a/infrastructure/service-registry/apps/dashboard/src/pages/Services.tsx b/infrastructure/service-registry/apps/dashboard/src/pages/Services.tsx index f9df398e1..b24437a18 100644 --- a/infrastructure/service-registry/apps/dashboard/src/pages/Services.tsx +++ b/infrastructure/service-registry/apps/dashboard/src/pages/Services.tsx @@ -71,12 +71,12 @@ const CodeBlock = styled.code` export function Services() { const [selectedServices, setSelectedServices] = useState>(new Set()); - const { services, loading, error, refetch } = useServices(); + const { services, loading, refreshing, error, refetch, silentRefetch } = useServices(); useWebSocket({ - onServiceRegistered: refetch, - onServiceDeregistered: refetch, - onStatusChange: refetch, - onHealthUpdate: refetch + onServiceRegistered: silentRefetch, + onServiceDeregistered: silentRefetch, + onStatusChange: silentRefetch, + onHealthUpdate: silentRefetch // Use silent refetch to avoid flash }); const handleServiceSelect = (serviceName: string) => { @@ -157,6 +157,7 @@ export class AppModule {}`} handleServiceSelect(serviceName)} selectedService={null}