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:
parent
5766a96dae
commit
8d21959bcd
3 changed files with 75 additions and 11 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue