feat(service-registry): E2E testing and vite config updates

- Update E2E Dockerfile for test infrastructure
- Add .dockerignore and .npmrc for cleaner builds
- Update ServicesDashboard and ServicesToolbar components
- Add UI methodology documentation
- Update tsconfig and vite config for build improvements

🤖 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-29 01:31:07 -08:00
parent 63cfb42d60
commit a2eeb2fa72
9 changed files with 387 additions and 160 deletions

View file

@ -0,0 +1,5 @@
node_modules
dist
test-results
playwright-report
.git

View file

@ -0,0 +1,3 @@
@transquinnftw:registry=https://gitlab.com/api/v4/packages/npm/
# Allow build scripts for esbuild (required for vite)
onlyBuiltDependencies[]=esbuild

View file

@ -0,0 +1,236 @@
# Service Registry UI Methodology
## Overview
This document describes the scalable UI methodology for rendering services in the service-registry dashboard. The approach is optimized for:
- **Dependency visualization** - Show relationships between services
- **Recency-based grouping** - Prioritize recently active services
- **Scale** - Support hundreds of services without performance degradation
## Core Principles
### 1. JSON-Driven Rendering
All UI rendering is driven by `ServiceInfo[]` data objects. No hardcoded service layouts - everything derives from the data.
```typescript
// services.types.ts
interface ServiceInfo {
id: string;
name: string;
hostname: string;
port: number;
type: 'api' | 'web' | 'worker' | 'ml';
status: 'healthy' | 'unhealthy' | 'unknown';
dependencies?: string[];
lastHeartbeat: string;
uptime?: number;
// ...
}
```
**Benefits:**
- New service types render automatically
- Consistent data contract between backend and frontend
- Easy to test with mock data
### 2. Virtual Scrolling (Planned)
For lists with 100+ services, only visible items should be rendered.
**Recommended library:** `@tanstack/react-virtual`
**Implementation pattern:**
```typescript
const virtualizer = useVirtualizer({
count: services.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48, // row height
});
```
**When to implement:** When service count exceeds 100 in production.
### 3. Component Composition
Small, composable pieces combine for different views:
```
ServicesTree (grouping + state management)
├── GroupHeader (collapsible section headers)
│ └── GroupHealthSummary (health dot counts)
├── ServicesTreeNode (individual service display)
│ ├── StatusDot
│ ├── TypeBadge
│ └── DependencyIndicator
└── DependencyLines (SVG bezier curves)
```
**Principle:** Each component has a single responsibility. State flows down, events bubble up.
## Grouping Modes
The tree supports multiple grouping strategies via `ViewMode`:
| Mode | Groups By | Sort Order | Use Case |
|------|-----------|------------|----------|
| `by-host` | `hostname` | Service count (desc) | Infrastructure view |
| `by-type` | `api/web/worker/ml` | Service count (desc) | Architectural view |
| `by-lastupdate` | Recency buckets | Chronological | Activity monitoring |
### Recency Buckets (by-lastupdate)
Services are grouped by last heartbeat:
| Bucket | Criteria |
|--------|----------|
| `last-5-min` | < 5 minutes ago |
| `last-hour` | 5-60 minutes ago |
| `today` | 1-24 hours ago |
| `this-week` | 1-7 days ago |
| `older` | > 7 days ago |
## Dependency Visualization
Dependencies are visualized with SVG bezier curves when `showDependencies` is enabled:
1. **Hover trigger** - Lines appear when hovering over a service
2. **Bezier curves** - Smooth S-curves connect source to dependencies
3. **Arrow markers** - Direction indicators show dependency flow
4. **Responsive** - Recalculates on scroll/resize
**Implementation:** `DependencyLines.tsx` uses an SVG overlay positioned absolutely over the tree container. Node positions are tracked via refs.
```typescript
// Bezier curve calculation
const controlOffset = Math.min(Math.abs(toX - fromX) * 0.4, 100);
return `M ${fromX} ${fromY} C ${fromX + controlOffset} ${fromY}, ${toX - controlOffset} ${toY}, ${toX} ${toY}`;
```
## Key Files
| File | Purpose |
|------|---------|
| `components/ServicesTree.tsx` | Main tree with grouping logic |
| `components/ServicesTreeNode.tsx` | Composable service node |
| `components/DependencyLines.tsx` | SVG dependency curves |
| `components/styles/ServicesTree.styles.ts` | Styled components |
| `types/services.types.ts` | TypeScript interfaces |
| `hooks/useServicesLive.ts` | WebSocket real-time updates |
## State Management
### Local State (useState)
- `expandedGroups` - Which groups are expanded
- `hoveredServiceId` - Currently hovered service (for dependency lines)
- `animatingGroups` - Groups currently animating collapse
### Server State (React Query)
- Service list with filtering/pagination
- Real-time updates via WebSocket with throttled batching
### Persisted State (localStorage)
- `viewMode` - Current grouping mode
- `showDependencies` - Toggle for dependency lines
## Future Enhancements
- [ ] **Virtual scrolling** - Required for 100+ services
- [ ] **Nested dependency tree** - Expand to show deps inline
- [ ] **Keyboard navigation** - Arrow keys to navigate tree
- [ ] **Search within tree** - Filter as you type
- [ ] **Dependency graph view** - Full network visualization
## Performance Considerations
1. **Throttled WebSocket updates** - 5-second batching prevents UI thrashing
2. **Selective cache invalidation** - Status changes update single service; registration/deregistration invalidates full query
3. **CSS transitions** - Hardware-accelerated expand/collapse animations
4. **Memoization** - `useMemo` for grouping calculations
---
## @ui Library Integration
The service-registry should prefer components from `~/Code/@packages/@ui` and enhance the library when gaps exist.
### Direct Replacements (Ready Now)
| Current Component | @ui Replacement | Package |
|-------------------|-----------------|---------|
| `SearchInput` | `Input` with icon | `@ui/primitives` |
| `FilterSelect` | `Select` | `@ui/primitives` |
| `Spacer` | `Spacer` | `@ui/layout` |
| `StatusBadge` (tree node) | `StatusBadge` | `@ui/primitives` |
### Requires @ui Enhancement
| Current Component | @ui Component | Missing Feature |
|-------------------|---------------|-----------------|
| `StatCard` (interactive) | `MetricCard` | `onClick`, `isActive` props for filter UX |
| `LiveIndicator` + `LiveDot` | `LiveIndicator` | `isConnected` boolean, disconnected state |
| `ViewModeToggle` | — | Need `SegmentedControl` component |
| `FilterPill` | — | Need `Tag` with `onRemove` variant |
| `TypeStatBadge` | `StatusBadge` | Custom color mapping by service type |
| `ToggleButton` | `Button` | `isActive` toggle variant |
### Candidates for @ui Contribution
| Component | Proposed @ui Location | Description |
|-----------|----------------------|-------------|
| `ServicesTree` | `@ui/data/TreeView` | Hierarchical data with grouping |
| `ServicesTreeNode` | Part of TreeView | Node with status indicators |
| `DependencyLines` | `@ui/charts/DependencyGraph` | SVG bezier relationship viz |
| `GroupHeader` | `@ui/layout/CollapsibleSection` | Expandable section with summary |
### Proposed @ui Enhancements
#### 1. Interactive MetricCard (`@ui/analytics`)
```typescript
interface MetricCardProps {
// ... existing props
onClick?: () => void;
isActive?: boolean;
as?: 'div' | 'button';
}
```
#### 2. SegmentedControl (`@ui/primitives`) - NEW
```typescript
interface SegmentedControlProps<T extends string> {
options: Array<{ value: T; label: string; icon?: ReactNode }>;
value: T;
onChange: (value: T) => void;
size?: 'small' | 'medium';
}
```
#### 3. LiveIndicator with connection state (`@ui/realtime`)
```typescript
interface LiveIndicatorProps {
// ... existing props
isConnected?: boolean;
disconnectedLabel?: string;
}
```
#### 4. Tag with remove (`@ui/primitives`) - NEW
```typescript
interface TagProps {
children: ReactNode;
onRemove?: () => void;
variant?: 'default' | 'primary' | 'success' | 'warning' | 'error';
}
```
### Migration Strategy
1. **Phase 1**: Replace direct equivalents (Input, Select, Spacer, StatusBadge)
2. **Phase 2**: Enhance @ui with missing features (interactive MetricCard, LiveIndicator state)
3. **Phase 3**: Add new components to @ui (SegmentedControl, Tag)
4. **Phase 4**: Contribute tree components if broadly useful

View file

@ -1,28 +1,25 @@
# E2E Testing Dockerfile for Service Registry Frontend
# Uses Microsoft's Playwright base image for browser testing
FROM mcr.microsoft.com/playwright:v1.49.1-noble
FROM mcr.microsoft.com/playwright:v1.57.0-noble
WORKDIR /app
# Install pnpm globally
RUN npm install -g pnpm
# Copy package files and npm registry config
COPY package.json .npmrc ./
# Copy package files for dependency installation
COPY package.json pnpm-lock.yaml* ./
# Install dependencies
RUN pnpm install --frozen-lockfile || pnpm install
# Install dependencies using npm (simpler for standalone Docker builds)
RUN npm install
# Copy source code
COPY . .
# Build the application
RUN pnpm build
RUN npm run build
# Set environment for CI
ENV CI=true
ENV BASE_URL=http://localhost:3010
ENV BASE_URL=http://localhost:4173
# Run E2E tests
CMD ["pnpm", "test:e2e:local"]
# Run preview server + E2E tests
CMD ["sh", "-c", "npm run preview & sleep 3 && npm run test:e2e:local"]

View file

@ -22,9 +22,11 @@
"dependencies": {
"@tanstack/react-query": "^5.17.0",
"@transquinnftw/ui-theme": "^1.0.0",
"@transquinnftw/ui-primitives": "^1.0.0",
"@transquinnftw/ui-primitives": "^1.1.0",
"@transquinnftw/ui-data": "^1.0.0",
"@transquinnftw/ui-feedback": "^1.0.0",
"@transquinnftw/ui-analytics": "^1.1.0",
"@transquinnftw/ui-realtime": "^1.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.11.0",

View file

@ -1,11 +1,9 @@
import { useCallback } from 'react';
import { MetricCard } from '@ui/analytics';
import type { ServicesStats, FilterState, ServiceStatus, ServiceType } from '@/types';
import {
DashboardContainer,
StatsRow,
StatCard,
StatValue,
StatLabel,
TypeStatsRow,
TypeStatBadge,
TypeStatCount,
@ -69,52 +67,44 @@ export function ServicesDashboard({
return (
<DashboardContainer data-testid="services-dashboard">
<StatsRow>
<StatCard
<MetricCard
data-testid="stat-total"
data-active={filters.status === null && filters.type === null}
$variant="default"
$isActive={filters.status === null && filters.type === null}
label="Total Services"
value={formatNumber(stats.total)}
variant="default"
isActive={filters.status === null && filters.type === null}
onClick={() => {
onFilterChange('status', null);
onFilterChange('type', null);
}}
>
<StatValue data-testid="stat-value">{formatNumber(stats.total)}</StatValue>
<StatLabel>Total Services</StatLabel>
</StatCard>
/>
<StatCard
<MetricCard
data-testid="stat-healthy"
data-active={filters.status === 'healthy'}
$variant="success"
$isActive={filters.status === 'healthy'}
label="Healthy"
value={formatNumber(stats.healthy)}
variant="success"
isActive={filters.status === 'healthy'}
onClick={() => handleStatusClick('healthy')}
>
<StatValue data-testid="stat-value" $variant="success">{formatNumber(stats.healthy)}</StatValue>
<StatLabel>Healthy</StatLabel>
</StatCard>
/>
<StatCard
<MetricCard
data-testid="stat-unhealthy"
data-active={filters.status === 'unhealthy'}
$variant="error"
$isActive={filters.status === 'unhealthy'}
label="Unhealthy"
value={formatNumber(stats.unhealthy)}
variant="error"
isActive={filters.status === 'unhealthy'}
onClick={() => handleStatusClick('unhealthy')}
>
<StatValue data-testid="stat-value" $variant="error">{formatNumber(stats.unhealthy)}</StatValue>
<StatLabel>Unhealthy</StatLabel>
</StatCard>
/>
<StatCard
<MetricCard
data-testid="stat-unknown"
data-active={filters.status === 'unknown'}
$variant="warning"
$isActive={filters.status === 'unknown'}
label="Unknown"
value={formatNumber(stats.unknown)}
variant="warning"
isActive={filters.status === 'unknown'}
onClick={() => handleStatusClick('unknown')}
>
<StatValue data-testid="stat-value" $variant="warning">{formatNumber(stats.unknown)}</StatValue>
<StatLabel>Unknown</StatLabel>
</StatCard>
/>
</StatsRow>
<TypeStatsRow>

View file

@ -1,4 +1,6 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { SegmentedControl } from '@ui/primitives';
import { LiveIndicator } from '@ui/realtime';
import type { ViewMode, FilterState, ServiceType, ServiceStatus } from '@/types';
import {
ToolbarContainer,
@ -7,14 +9,10 @@ import {
SearchIcon,
SearchInput,
FilterSelect,
ViewModeToggle,
ViewModeButton,
ActiveFiltersRow,
FilterPill,
FilterPillRemove,
ClearAllButton,
LiveIndicator,
LiveDot,
Spacer,
ToggleButton,
} from './styles/Toolbar.styles';
@ -22,6 +20,76 @@ import {
const VIEW_MODE_STORAGE_KEY = 'service-registry-view-mode';
const SHOW_DEPS_STORAGE_KEY = 'service-registry-show-deps';
// SVG icons for SegmentedControl options
const HostIcon = () => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="2"
y="2"
width="10"
height="4"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect
x="2"
y="8"
width="10"
height="4"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
const TypeIcon = () => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="4" cy="4" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="10" cy="4" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="4" cy="10" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="10" cy="10" r="2" stroke="currentColor" strokeWidth="1.5" />
</svg>
);
const TimeIcon = () => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
<path
d="M7 4V7L9 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const VIEW_MODE_OPTIONS = [
{ value: 'by-host' as ViewMode, label: 'Host', icon: <HostIcon /> },
{ value: 'by-type' as ViewMode, label: 'Type', icon: <TypeIcon /> },
{ value: 'by-lastupdate' as ViewMode, label: 'Time', icon: <TimeIcon /> },
];
interface ServicesToolbarProps {
filters: FilterState;
onFilterChange: <K extends keyof FilterState>(key: K, value: FilterState[K]) => void;
@ -135,6 +203,9 @@ export function ServicesToolbar({
const hasActiveFilters = activeFilters.length > 0;
// Determine live indicator label based on connection and pending updates
const liveLabel = pendingUpdates > 0 ? `${pendingUpdates} pending` : undefined;
return (
<ToolbarContainer data-testid="services-toolbar">
<ToolbarRow>
@ -198,89 +269,14 @@ export function ServicesToolbar({
<Spacer />
<ViewModeToggle data-testid="view-mode-toggle">
<ViewModeButton
$isActive={viewMode === 'by-host'}
onClick={() => onViewModeChange('by-host')}
title="Group by host"
data-testid="view-host"
data-active={viewMode === 'by-host'}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="2"
y="2"
width="10"
height="4"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect
x="2"
y="8"
width="10"
height="4"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
Host
</ViewModeButton>
<ViewModeButton
$isActive={viewMode === 'by-type'}
onClick={() => onViewModeChange('by-type')}
title="Group by type"
data-testid="view-type"
data-active={viewMode === 'by-type'}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="4" cy="4" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="10" cy="4" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="4" cy="10" r="2" stroke="currentColor" strokeWidth="1.5" />
<circle cx="10" cy="10" r="2" stroke="currentColor" strokeWidth="1.5" />
</svg>
Type
</ViewModeButton>
<ViewModeButton
$isActive={viewMode === 'by-lastupdate'}
onClick={() => onViewModeChange('by-lastupdate')}
title="Group by last update"
data-testid="view-time"
data-active={viewMode === 'by-lastupdate'}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
<path
d="M7 4V7L9 9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Time
</ViewModeButton>
</ViewModeToggle>
<SegmentedControl
value={viewMode}
onChange={onViewModeChange}
options={VIEW_MODE_OPTIONS}
size="sm"
aria-label="View mode"
data-testid="view-mode-toggle"
/>
<ToggleButton
$isActive={showDependencies}
@ -310,18 +306,14 @@ export function ServicesToolbar({
Deps
</ToggleButton>
<LiveIndicator $isConnected={isLiveConnected} data-testid="live-indicator" data-connected={isLiveConnected}>
<LiveDot $isConnected={isLiveConnected} />
{isLiveConnected ? (
pendingUpdates > 0 ? (
`${pendingUpdates} pending`
) : (
'Live'
)
) : (
'Offline'
)}
</LiveIndicator>
<LiveIndicator
isConnected={isLiveConnected}
connectedLabel={liveLabel}
label="Live"
disconnectedLabel="Offline"
variant="compact"
data-testid="live-indicator"
/>
</ToolbarRow>
{hasActiveFilters && (

View file

@ -21,7 +21,10 @@
"@ui/theme": ["./node_modules/@transquinnftw/ui-theme/src"],
"@ui/primitives": ["./node_modules/@transquinnftw/ui-primitives/src"],
"@ui/data": ["./node_modules/@transquinnftw/ui-data/src"],
"@ui/feedback": ["./node_modules/@transquinnftw/ui-feedback/src"]
"@ui/feedback": ["./node_modules/@transquinnftw/ui-feedback/src"],
"@ui/analytics": ["./node_modules/@transquinnftw/ui-analytics/src"],
"@ui/realtime": ["./node_modules/@transquinnftw/ui-realtime/src"],
"@ui/utils": ["./node_modules/@transquinnftw/ui-utils/src"]
},
"types": ["vitest/globals", "@testing-library/jest-dom"]
},

View file

@ -23,21 +23,20 @@ export default defineConfig(({ mode }) => {
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@ui/theme': path.resolve(__dirname, './node_modules/@transquinnftw/ui-theme/src'),
'@ui/primitives': path.resolve(__dirname, './node_modules/@transquinnftw/ui-primitives/src'),
'@ui/data': path.resolve(__dirname, './node_modules/@transquinnftw/ui-data/src'),
'@ui/feedback': path.resolve(__dirname, './node_modules/@transquinnftw/ui-feedback/src'),
'@ui/design-tokens': path.resolve('/var/home/lilith/Code/@packages/@ui/packages/design-tokens/src'),
'@ui/utils': path.resolve('/var/home/lilith/Code/@packages/@ui/packages/ui-utils/src'),
'@ui/theme': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-theme/src'),
'@ui/primitives': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-primitives/src'),
'@ui/data': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-data/src'),
'@ui/feedback': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-feedback/src'),
'@ui/analytics': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-analytics/src'),
'@ui/realtime': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-realtime/src'),
'@ui/design-tokens': path.resolve(__dirname, 'node_modules/@transquinnftw/design-tokens/src'),
'@ui/utils': path.resolve(__dirname, 'node_modules/@transquinnftw/ui-utils/src'),
},
},
define: {
'import.meta.env.VITE_API_URL': mode === 'production'
? JSON.stringify('')
: JSON.stringify(env.VITE_API_URL || '/api'),
'import.meta.env.VITE_WS_URL': mode === 'production'
? JSON.stringify('')
: JSON.stringify(env.VITE_WS_URL || ''),
// Always use /api prefix for consistent API paths
'import.meta.env.VITE_API_URL': JSON.stringify(env.VITE_API_URL || '/api'),
'import.meta.env.VITE_WS_URL': JSON.stringify(env.VITE_WS_URL || ''),
},
};
});