diff --git a/features/service-registry/frontend/.dockerignore b/features/service-registry/frontend/.dockerignore new file mode 100644 index 000000000..39a3112fe --- /dev/null +++ b/features/service-registry/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +test-results +playwright-report +.git diff --git a/features/service-registry/frontend/.npmrc b/features/service-registry/frontend/.npmrc new file mode 100644 index 000000000..cd951ced9 --- /dev/null +++ b/features/service-registry/frontend/.npmrc @@ -0,0 +1,3 @@ +@transquinnftw:registry=https://gitlab.com/api/v4/packages/npm/ +# Allow build scripts for esbuild (required for vite) +onlyBuiltDependencies[]=esbuild diff --git a/features/service-registry/frontend/docs/UI_METHODOLOGY.md b/features/service-registry/frontend/docs/UI_METHODOLOGY.md new file mode 100644 index 000000000..213527d70 --- /dev/null +++ b/features/service-registry/frontend/docs/UI_METHODOLOGY.md @@ -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 { + 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 diff --git a/features/service-registry/frontend/e2e/Dockerfile b/features/service-registry/frontend/e2e/Dockerfile index 7d6b8122d..825c033ff 100644 --- a/features/service-registry/frontend/e2e/Dockerfile +++ b/features/service-registry/frontend/e2e/Dockerfile @@ -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"] diff --git a/features/service-registry/frontend/package.json b/features/service-registry/frontend/package.json index 283e7b46b..e36134d2f 100644 --- a/features/service-registry/frontend/package.json +++ b/features/service-registry/frontend/package.json @@ -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", diff --git a/features/service-registry/frontend/src/components/ServicesDashboard.tsx b/features/service-registry/frontend/src/components/ServicesDashboard.tsx index fe8127833..5ed78df99 100644 --- a/features/service-registry/frontend/src/components/ServicesDashboard.tsx +++ b/features/service-registry/frontend/src/components/ServicesDashboard.tsx @@ -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 ( - { onFilterChange('status', null); onFilterChange('type', null); }} - > - {formatNumber(stats.total)} - Total Services - + /> - handleStatusClick('healthy')} - > - {formatNumber(stats.healthy)} - Healthy - + /> - handleStatusClick('unhealthy')} - > - {formatNumber(stats.unhealthy)} - Unhealthy - + /> - handleStatusClick('unknown')} - > - {formatNumber(stats.unknown)} - Unknown - + /> diff --git a/features/service-registry/frontend/src/components/ServicesToolbar.tsx b/features/service-registry/frontend/src/components/ServicesToolbar.tsx index ae3789f54..27a69ad43 100644 --- a/features/service-registry/frontend/src/components/ServicesToolbar.tsx +++ b/features/service-registry/frontend/src/components/ServicesToolbar.tsx @@ -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 = () => ( + + + + +); + +const TypeIcon = () => ( + + + + + + +); + +const TimeIcon = () => ( + + + + +); + +const VIEW_MODE_OPTIONS = [ + { value: 'by-host' as ViewMode, label: 'Host', icon: }, + { value: 'by-type' as ViewMode, label: 'Type', icon: }, + { value: 'by-lastupdate' as ViewMode, label: 'Time', icon: }, +]; + interface ServicesToolbarProps { filters: FilterState; onFilterChange: (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 ( @@ -198,89 +269,14 @@ export function ServicesToolbar({ - - onViewModeChange('by-host')} - title="Group by host" - data-testid="view-host" - data-active={viewMode === 'by-host'} - > - - - - - Host - - onViewModeChange('by-type')} - title="Group by type" - data-testid="view-type" - data-active={viewMode === 'by-type'} - > - - - - - - - Type - - onViewModeChange('by-lastupdate')} - title="Group by last update" - data-testid="view-time" - data-active={viewMode === 'by-lastupdate'} - > - - - - - Time - - + - - - {isLiveConnected ? ( - pendingUpdates > 0 ? ( - `${pendingUpdates} pending` - ) : ( - 'Live' - ) - ) : ( - 'Offline' - )} - + {hasActiveFilters && ( diff --git a/features/service-registry/frontend/tsconfig.json b/features/service-registry/frontend/tsconfig.json index f16e783c1..5e20a530d 100644 --- a/features/service-registry/frontend/tsconfig.json +++ b/features/service-registry/frontend/tsconfig.json @@ -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"] }, diff --git a/features/service-registry/frontend/vite.config.ts b/features/service-registry/frontend/vite.config.ts index 24993f6e5..e0dc15e7a 100644 --- a/features/service-registry/frontend/vite.config.ts +++ b/features/service-registry/frontend/vite.config.ts @@ -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 || ''), }, }; });