fix(service-registry): prevent service card flashing on WebSocket updates

- 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 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-26 04:07:32 -08:00
parent 5766a96dae
commit 8d21959bcd
3 changed files with 75 additions and 11 deletions

View file

@ -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({
<Container>
<Header>
<SectionTitle>Registered Services</SectionTitle>
{refreshing && (
<RefreshingIndicator>
<RefreshSpinner />
Refreshing...
</RefreshingIndicator>
)}
</Header>
<ServicesGrid>
@ -231,7 +264,7 @@ export function ServiceOverview({
<MetricsRow>
<MiniMetric>
<MetricValue>
{service.uptime ? Math.floor(service.uptime / 1000 / 60) : 0}m
{Math.floor((service.uptime ?? (Date.now() - new Date(service.registeredAt).getTime())) / 1000 / 60)}m
</MetricValue>
<MetricLabel>Uptime</MetricLabel>
</MiniMetric>

View file

@ -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<ServiceInfo>) => void;
}
// Stable sort key for consistent ordering
@ -17,7 +20,9 @@ function getServiceSortKey(service: ServiceInfo): string {
export function useServices(): UseServicesResult {
const [rawServices, setRawServices] = useState<ServiceInfo[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(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<ServiceInfo>) => {
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
};
}

View file

@ -71,12 +71,12 @@ const CodeBlock = styled.code`
export function Services() {
const [selectedServices, setSelectedServices] = useState<Set<string>>(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 {}`}
<ServiceOverview
services={services}
loading={loading}
refreshing={refreshing}
viewMode="grid"
onServiceSelect={(serviceName) => handleServiceSelect(serviceName)}
selectedService={null}