Container Session Components Reference
Task ID: F.5.2 ADR References: ADR-055, ADR-056 Status: Planning Phase (3/28 components complete) Version: 1.0.0 Last Updated: January 5, 2026
Overview
This document provides comprehensive reference documentation for the 28-component Container Session Dashboard that enables administrators to monitor and manage container-level license sessions across:
- Docker containers (local development)
- Google Cloud Workstations (1-100 users per container)
- Kubernetes pods (orchestrated deployments)
Purpose
The Container Session UI provides:
- Real-time monitoring of container heartbeat status
- Multi-tenant administration with role-based access control
- User session management within multi-user containers
- License utilization analytics and alerting
- Lifecycle control (terminate sessions, kick users)
Architecture Overview
┌──────────────────────────────────────────────────────────────┐
│ Container Session Dashboard │
├──────────────────────────────────────────────────────────────┤
│ Layer 1: Dashboard Shell (3 components) │
│ Layer 2: Filtering & Navigation (5 components) │
│ Layer 3: Session List & Cards (7 components) │
│ Layer 4: Detail Views (4 components) │
│ Layer 5: Admin & System Views (4 components) │
│ Supporting: Hooks & State (5 components) │
├──────────────────────────────────────────────────────────────┤
│ Total: 28 Components | 3,500 LOC Est. | 3 Weeks Dev │
└──────────────────────────────────────────────────────────────┘
Table of Contents
- Layer 1: Dashboard Shell
- Layer 2: Filtering & Navigation
- Layer 3: Session List & Cards
- Layer 4: Detail Views
- Layer 5: Admin & System Views
- Supporting Infrastructure
- Component Hierarchy
- Data Flow
- Role-Based Rendering
- Storybook Examples
- Performance Optimization
- Testing Considerations
Layer 1: Dashboard Shell
The top-level orchestration layer that provides the overall dashboard structure, navigation, and summary metrics.
1.1 ContainerSessionDashboard
Purpose: Root component that orchestrates the entire Container Session dashboard, manages global state, and coordinates polling/WebSocket updates.
Props Interface
interface ContainerSessionDashboardProps {
/**
* Initial filters to apply (e.g., from URL params)
* @default {}
*/
initialFilters?: ContainerSessionFilters;
/**
* Enable real-time updates via polling or WebSocket
* @default true
*/
enableRealtime?: boolean;
/**
* Polling interval in milliseconds (if WebSocket disabled)
* @default 5000
*/
pollingInterval?: number;
/**
* Enable System Admin dashboard view
* @default false - automatically determined from user role
*/
showSystemAdminView?: boolean;
}
Usage Example
import { ContainerSessionDashboard } from '@/components/container-sessions';
export function ContainerSessionsPage() {
return (
<ContainerSessionDashboard
initialFilters={{ status: 'active' }}
enableRealtime={true}
pollingInterval={5000}
/>
);
}
State Management
React Query Keys:
containerSessionKeys.lists()- Session list datacontainerSessionKeys.stats()- Dashboard statistics
Zustand Selectors:
useDashboardStore(state => state.filters)- Active filtersuseDashboardStore(state => state.view)- Grid/list view modeuseDashboardStore(state => state.selectedSessionId)- Selected session for detail view
Accessibility
<div
role="region"
aria-label="Container Session Dashboard"
aria-live="polite"
aria-atomic="false"
>
{/* Dashboard content */}
</div>
Keyboard Navigation:
Tab- Navigate through filters, cards, and actionsEnter/Space- Activate selected card or actionEscape- Close detail drawer
Styling Notes
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
{/* Content uses max-width container for large screens */}
</div>
</div>
Error Handling
<SessionErrorBoundary
fallback={({ error, resetErrorBoundary }) => (
<DashboardErrorView error={error} onRetry={resetErrorBoundary} />
)}
>
<ContainerSessionDashboard />
</SessionErrorBoundary>
Testing Considerations
describe('ContainerSessionDashboard', () => {
it('should render with initial filters', () => {
render(<ContainerSessionDashboard initialFilters={{ status: 'active' }} />);
expect(screen.getByRole('region')).toBeInTheDocument();
});
it('should poll for updates every 5 seconds', async () => {
const { container } = render(<ContainerSessionDashboard />);
await waitFor(() => {
expect(mockApi.listSessions).toHaveBeenCalledTimes(2);
}, { timeout: 6000 });
});
it('should handle network errors gracefully', async () => {
mockApi.listSessions.mockRejectedValue(new Error('Network error'));
render(<ContainerSessionDashboard />);
expect(await screen.findByRole('alert')).toHaveTextContent('Network error');
});
});
1.2 DashboardHeader
Purpose: Header component with breadcrumbs, page title, role selector (System Admin only), and manual refresh button.
Props Interface
interface DashboardHeaderProps {
/**
* Current user role for role-based rendering
*/
userRole: 'system_admin' | 'tenant_admin' | 'team_manager' | 'user';
/**
* Current tenant context
*/
tenant?: Tenant;
/**
* Callback when refresh button is clicked
*/
onRefresh: () => void;
/**
* Show loading spinner during refresh
* @default false
*/
isRefreshing?: boolean;
/**
* Enable tenant selector (System Admin only)
* @default false
*/
showTenantSelector?: boolean;
/**
* Callback when tenant is changed (System Admin only)
*/
onTenantChange?: (tenantId: string) => void;
}
Usage Example
import { DashboardHeader } from '@/components/container-sessions';
import { useAuth } from '@/hooks/useAuth';
export function ContainerDashboard() {
const { user, role, tenant } = useAuth();
const queryClient = useQueryClient();
const handleRefresh = () => {
queryClient.invalidateQueries({ queryKey: containerSessionKeys.all });
};
return (
<DashboardHeader
userRole={role}
tenant={tenant}
onRefresh={handleRefresh}
showTenantSelector={role === 'system_admin'}
/>
);
}
Component Structure
<header className="bg-white dark:bg-gray-800 shadow">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6">
{/* Breadcrumbs */}
<nav aria-label="Breadcrumb" className="mb-4">
<ol className="flex items-center space-x-2 text-sm">
<li><a href="/dashboard">Dashboard</a></li>
<li aria-current="page">Container Sessions</li>
</ol>
</nav>
{/* Title and Actions */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Container Sessions</h1>
{tenant && <p className="text-gray-500">{tenant.name}</p>}
</div>
<div className="flex items-center gap-4">
{/* Tenant Selector (System Admin only) */}
{showTenantSelector && <TenantSelector />}
{/* Refresh Button */}
<button
onClick={onRefresh}
disabled={isRefreshing}
aria-label="Refresh dashboard"
>
<RefreshIcon className={isRefreshing ? 'animate-spin' : ''} />
</button>
</div>
</div>
</div>
</header>
Accessibility
- Semantic
<header>element <nav>witharia-label="Breadcrumb"- Current page indicated with
aria-current="page" - Refresh button has
aria-labelfor screen readers
Testing Considerations
it('should show tenant selector for system admin', () => {
render(<DashboardHeader userRole="system_admin" showTenantSelector />);
expect(screen.getByRole('combobox', { name: /tenant/i })).toBeInTheDocument();
});
it('should hide tenant selector for tenant admin', () => {
render(<DashboardHeader userRole="tenant_admin" />);
expect(screen.queryByRole('combobox', { name: /tenant/i })).not.toBeInTheDocument();
});
it('should call onRefresh when refresh button clicked', async () => {
const onRefresh = vi.fn();
render(<DashboardHeader onRefresh={onRefresh} />);
await userEvent.click(screen.getByRole('button', { name: /refresh/i }));
expect(onRefresh).toHaveBeenCalledTimes(1);
});
1.3 SessionMetrics
Purpose: Displays 3 KPI cards showing session statistics: Active Sessions, License Utilization, and Sessions by Type.
Props Interface
interface SessionMetricsProps {
/**
* Session statistics data
*/
stats: ContainerSessionStats;
/**
* Loading state
* @default false
*/
isLoading?: boolean;
/**
* Error state
*/
error?: Error | null;
/**
* Show trend indicators (up/down arrows)
* @default true
*/
showTrends?: boolean;
/**
* Time range for comparison (for trends)
* @default 'previous_day'
*/
trendComparison?: 'previous_hour' | 'previous_day' | 'previous_week';
}
Usage Example
import { SessionMetrics } from '@/components/container-sessions';
import { useContainerSessionStats } from '@/hooks/useContainerSessions';
export function Dashboard() {
const { data: stats, isLoading, error } = useContainerSessionStats();
return (
<SessionMetrics
stats={stats}
isLoading={isLoading}
error={error}
showTrends={true}
/>
);
}
Component Structure
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{/* Active Sessions Card */}
<MetricCard
title="Active Sessions"
value={stats.active_sessions}
total={stats.total_sessions}
trend={calculateTrend(stats.active_sessions, previousStats.active_sessions)}
icon={<ServerIcon className="h-6 w-6 text-blue-600" />}
color="blue"
/>
{/* License Utilization Card */}
<MetricCard
title="License Utilization"
value={stats.active_users}
total={stats.total_license_seats}
percentage={true}
trend={calculateTrend(stats.active_users, previousStats.active_users)}
icon={<UsersIcon className="h-6 w-6 text-green-600" />}
color="green"
/>
{/* Sessions by Type Card */}
<MetricCard
title="Sessions by Type"
breakdown={stats.by_container_type}
icon={<ChartPieIcon className="h-6 w-6 text-purple-600" />}
color="purple"
/>
</div>
MetricCard Subcomponent
interface MetricCardProps {
title: string;
value: number;
total?: number;
percentage?: boolean;
trend?: { direction: 'up' | 'down'; value: number };
icon: React.ReactNode;
color: 'blue' | 'green' | 'purple' | 'red';
breakdown?: Record<string, number>;
}
function MetricCard({ title, value, total, trend, icon, color }: MetricCardProps) {
const percentage = total ? (value / total) * 100 : 0;
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<div className={`p-3 rounded-full bg-${color}-100 dark:bg-${color}-900`}>
{icon}
</div>
{trend && <TrendIndicator {...trend} />}
</div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400">
{title}
</h3>
<div className="mt-2 flex items-baseline">
<p className="text-3xl font-bold text-gray-900 dark:text-white">
{value.toLocaleString()}
</p>
{total && (
<p className="ml-2 text-sm text-gray-500">
/ {total.toLocaleString()} ({percentage.toFixed(1)}%)
</p>
)}
</div>
</div>
);
}
Accessibility
<div role="region" aria-label="Session metrics">
<MetricCard aria-label="Active sessions count" />
<MetricCard aria-label="License utilization percentage" />
<MetricCard aria-label="Sessions by container type" />
</div>
Testing Considerations
describe('SessionMetrics', () => {
const mockStats: ContainerSessionStats = {
total_sessions: 150,
active_sessions: 120,
expired_sessions: 20,
released_sessions: 10,
total_users: 450,
active_users: 380,
by_container_type: { docker: 80, cloud_workstation: 35, kubernetes: 5 },
by_status: { active: 120, expired: 20, released: 10 },
avg_users_per_session: 3.17,
avg_session_duration_minutes: 145,
};
it('should display active sessions count', () => {
render(<SessionMetrics stats={mockStats} />);
expect(screen.getByText('120')).toBeInTheDocument();
expect(screen.getByText('/ 150')).toBeInTheDocument();
});
it('should show skeleton loading state', () => {
render(<SessionMetrics stats={mockStats} isLoading={true} />);
expect(screen.getAllByRole('status', { name: /loading/i })).toHaveLength(3);
});
it('should display breakdown for sessions by type', () => {
render(<SessionMetrics stats={mockStats} />);
expect(screen.getByText(/docker.*80/i)).toBeInTheDocument();
expect(screen.getByText(/cloud workstation.*35/i)).toBeInTheDocument();
});
});
Layer 2: Filtering & Navigation
Components for filtering, searching, and navigating the session list.
2.1 SessionFilters
Purpose: Combined filter bar that orchestrates all individual filter components (Tenant, Container Type, Status, Search).
Props Interface
interface SessionFiltersProps {
/**
* Current filter values
*/
filters: ContainerSessionFilters;
/**
* Callback when filters change
*/
onFiltersChange: (filters: ContainerSessionFilters) => void;
/**
* Current user role for role-based filter visibility
*/
userRole: UserRole;
/**
* Show tenant filter (System Admin only)
* @default false
*/
showTenantFilter?: boolean;
/**
* Enable search functionality
* @default true
*/
enableSearch?: boolean;
/**
* Callback when filters are cleared
*/
onClearFilters?: () => void;
}
Usage Example
import { SessionFilters } from '@/components/container-sessions';
import { useDashboardStore } from '@/stores/dashboardStore';
export function FilterBar() {
const filters = useDashboardStore(state => state.filters);
const setFilters = useDashboardStore(state => state.setFilters);
const { role } = useAuth();
return (
<SessionFilters
filters={filters}
onFiltersChange={setFilters}
userRole={role}
showTenantFilter={role === 'system_admin'}
/>
);
}
Component Structure
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* Tenant Filter (System Admin only) */}
{showTenantFilter && (
<TenantFilter
value={filters.tenant_id}
onChange={(tenantId) => onFiltersChange({ ...filters, tenant_id: tenantId })}
/>
)}
{/* Container Type Filter */}
<ContainerTypeFilter
value={filters.container_type}
onChange={(type) => onFiltersChange({ ...filters, container_type: type })}
/>
{/* Status Filter */}
<StatusFilter
value={filters.status}
onChange={(status) => onFiltersChange({ ...filters, status })}
/>
{/* Search Bar */}
{enableSearch && (
<SearchBar
value={filters.search}
onChange={(search) => onFiltersChange({ ...filters, search })}
placeholder="Search by container ID, name, or hostname..."
/>
)}
</div>
{/* Active Filters & Clear Button */}
{hasActiveFilters && (
<div className="mt-4 flex items-center justify-between">
<ActiveFilterBadges filters={filters} />
<button onClick={onClearFilters} className="text-sm text-blue-600">
Clear all filters
</button>
</div>
)}
</div>
State Management
Zustand Store:
interface DashboardState {
filters: ContainerSessionFilters;
setFilters: (filters: ContainerSessionFilters) => void;
clearFilters: () => void;
}
export const useDashboardStore = create<DashboardState>((set) => ({
filters: {},
setFilters: (filters) => set({ filters }),
clearFilters: () => set({ filters: {} }),
}));
Accessibility
- Each filter has a
<label>withhtmlForattribute - Clear button has descriptive text (not just an icon)
- Active filters announced to screen readers via
aria-live="polite"
Testing Considerations
it('should update filters when container type changes', async () => {
const onFiltersChange = vi.fn();
render(<SessionFilters filters={{}} onFiltersChange={onFiltersChange} />);
const dockerCheckbox = screen.getByRole('checkbox', { name: /docker/i });
await userEvent.click(dockerCheckbox);
expect(onFiltersChange).toHaveBeenCalledWith({ container_type: 'docker' });
});
it('should clear all filters when clear button clicked', async () => {
const onClearFilters = vi.fn();
render(
<SessionFilters
filters={{ status: 'active', container_type: 'docker' }}
onClearFilters={onClearFilters}
/>
);
await userEvent.click(screen.getByRole('button', { name: /clear all/i }));
expect(onClearFilters).toHaveBeenCalled();
});
2.2 TenantFilter
Purpose: Dropdown filter for selecting tenant (System Admin only). Allows admins to view sessions across different customer organizations.
Props Interface
interface TenantFilterProps {
/**
* Selected tenant ID
*/
value?: string;
/**
* Callback when tenant selection changes
*/
onChange: (tenantId: string | undefined) => void;
/**
* List of available tenants (fetched from API)
*/
tenants?: Tenant[];
/**
* Loading state while fetching tenants
* @default false
*/
isLoading?: boolean;
/**
* Show "All Tenants" option
* @default true
*/
showAllOption?: boolean;
}
Usage Example
import { TenantFilter } from '@/components/container-sessions/filters';
import { useTenants } from '@/hooks/useTenants';
export function SystemAdminFilters() {
const { data: tenants, isLoading } = useTenants();
const [selectedTenant, setSelectedTenant] = useState<string | undefined>();
return (
<TenantFilter
value={selectedTenant}
onChange={setSelectedTenant}
tenants={tenants}
isLoading={isLoading}
showAllOption={true}
/>
);
}
Component Structure
<div className="relative">
<label htmlFor="tenant-filter" className="block text-sm font-medium text-gray-700 mb-1">
Tenant
</label>
<select
id="tenant-filter"
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={isLoading}
className="block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
{showAllOption && <option value="">All Tenants</option>}
{tenants?.map((tenant) => (
<option key={tenant.id} value={tenant.id}>
{tenant.name} ({tenant.active_sessions} sessions)
</option>
))}
</select>
{isLoading && <Spinner className="absolute right-8 top-8 h-4 w-4" />}
</div>
Role-Based Visibility
// Only render for System Admin
{user.role === 'system_admin' && <TenantFilter />}
Accessibility
<label>associated with<select>viahtmlFor- Disabled state when loading
- Keyboard navigable (native
<select>)
Testing Considerations
it('should show all tenants option by default', () => {
render(<TenantFilter tenants={mockTenants} showAllOption={true} />);
expect(screen.getByRole('option', { name: /all tenants/i })).toBeInTheDocument();
});
it('should call onChange with tenant ID when selected', async () => {
const onChange = vi.fn();
render(<TenantFilter tenants={mockTenants} onChange={onChange} />);
await userEvent.selectOptions(
screen.getByRole('combobox'),
screen.getByRole('option', { name: /acme corp/i })
);
expect(onChange).toHaveBeenCalledWith('tenant-123');
});
2.3 ContainerTypeFilter
Purpose: Checkbox group for filtering by container type (Docker, Cloud Workstation, Kubernetes).
Props Interface
interface ContainerTypeFilterProps {
/**
* Selected container type(s)
*/
value?: ContainerType | ContainerType[];
/**
* Callback when selection changes
*/
onChange: (type: ContainerType | undefined) => void;
/**
* Allow multiple selection
* @default false
*/
multiple?: boolean;
/**
* Show session count per type
* @default true
*/
showCounts?: boolean;
/**
* Session counts by type (from stats API)
*/
counts?: Record<ContainerType, number>;
}
Usage Example
import { ContainerTypeFilter } from '@/components/container-sessions/filters';
export function Filters() {
const [type, setType] = useState<ContainerType | undefined>();
const { data: stats } = useContainerSessionStats();
return (
<ContainerTypeFilter
value={type}
onChange={setType}
showCounts={true}
counts={stats?.by_container_type}
/>
);
}
Component Structure
<fieldset className="space-y-2">
<legend className="block text-sm font-medium text-gray-700 mb-2">
Container Type
</legend>
<div className="space-y-2">
{/* Docker */}
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={value === 'docker'}
onChange={() => onChange(value === 'docker' ? undefined : 'docker')}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<ContainerTypeIcon type="docker" className="h-4 w-4" />
<span>Docker</span>
{showCounts && counts?.docker && (
<span className="text-sm text-gray-500">({counts.docker})</span>
)}
</label>
{/* Cloud Workstation */}
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={value === 'cloud_workstation'}
onChange={() => onChange(value === 'cloud_workstation' ? undefined : 'cloud_workstation')}
className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<ContainerTypeIcon type="cloud_workstation" className="h-4 w-4" />
<span>Cloud Workstation</span>
{showCounts && counts?.cloud_workstation && (
<span className="text-sm text-gray-500">({counts.cloud_workstation})</span>
)}
</label>
{/* Kubernetes */}
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={value === 'kubernetes'}
onChange={() => onChange(value === 'kubernetes' ? undefined : 'kubernetes')}
className="rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
<ContainerTypeIcon type="kubernetes" className="h-4 w-4" />
<span>Kubernetes</span>
{showCounts && counts?.kubernetes && (
<span className="text-sm text-gray-500">({counts.kubernetes})</span>
)}
</label>
</div>
</fieldset>
Accessibility
<fieldset>with<legend>for semantic grouping- Each checkbox has a visible
<label> - Keyboard accessible (native checkboxes)
Testing Considerations
it('should toggle docker filter when checkbox clicked', async () => {
const onChange = vi.fn();
render(<ContainerTypeFilter onChange={onChange} />);
await userEvent.click(screen.getByRole('checkbox', { name: /docker/i }));
expect(onChange).toHaveBeenCalledWith('docker');
await userEvent.click(screen.getByRole('checkbox', { name: /docker/i }));
expect(onChange).toHaveBeenCalledWith(undefined);
});
it('should display session counts when provided', () => {
const counts = { docker: 45, cloud_workstation: 12, kubernetes: 3 };
render(<ContainerTypeFilter showCounts={true} counts={counts} />);
expect(screen.getByText('(45)')).toBeInTheDocument();
expect(screen.getByText('(12)')).toBeInTheDocument();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
2.4 StatusFilter
Purpose: Radio button group for filtering by session status (Active, Released, Expired).
Props Interface
interface StatusFilterProps {
/**
* Selected status
*/
value?: ContainerSessionStatus;
/**
* Callback when status selection changes
*/
onChange: (status: ContainerSessionStatus | undefined) => void;
/**
* Show "All Statuses" option
* @default true
*/
showAllOption?: boolean;
/**
* Show session count per status
* @default true
*/
showCounts?: boolean;
/**
* Session counts by status (from stats API)
*/
counts?: Record<ContainerSessionStatus, number>;
}
Usage Example
import { StatusFilter } from '@/components/container-sessions/filters';
export function Filters() {
const [status, setStatus] = useState<ContainerSessionStatus | undefined>('active');
const { data: stats } = useContainerSessionStats();
return (
<StatusFilter
value={status}
onChange={setStatus}
showCounts={true}
counts={stats?.by_status}
/>
);
}
Component Structure
<fieldset className="space-y-2">
<legend className="block text-sm font-medium text-gray-700 mb-2">
Status
</legend>
<div className="space-y-2">
{showAllOption && (
<label className="flex items-center space-x-2">
<input
type="radio"
name="status"
checked={!value}
onChange={() => onChange(undefined)}
className="border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span>All Statuses</span>
</label>
)}
{/* Active */}
<label className="flex items-center space-x-2">
<input
type="radio"
name="status"
value="active"
checked={value === 'active'}
onChange={() => onChange('active')}
className="border-gray-300 text-green-600 focus:ring-green-500"
/>
<SessionStatusBadge status="active" />
{showCounts && counts?.active && (
<span className="text-sm text-gray-500">({counts.active})</span>
)}
</label>
{/* Released */}
<label className="flex items-center space-x-2">
<input
type="radio"
name="status"
value="released"
checked={value === 'released'}
onChange={() => onChange('released')}
className="border-gray-300 text-gray-600 focus:ring-gray-500"
/>
<SessionStatusBadge status="released" />
{showCounts && counts?.released && (
<span className="text-sm text-gray-500">({counts.released})</span>
)}
</label>
{/* Expired */}
<label className="flex items-center space-x-2">
<input
type="radio"
name="status"
value="expired"
checked={value === 'expired'}
onChange={() => onChange('expired')}
className="border-gray-300 text-red-600 focus:ring-red-500"
/>
<SessionStatusBadge status="expired" />
{showCounts && counts?.expired && (
<span className="text-sm text-gray-500">({counts.expired})</span>
)}
</label>
</div>
</fieldset>
Accessibility
<fieldset>with<legend>for grouping- Radio inputs share same
nameattribute - Each radio has a visible
<label>
Testing Considerations
it('should select active status by default', () => {
render(<StatusFilter value="active" onChange={vi.fn()} />);
expect(screen.getByRole('radio', { name: /active/i })).toBeChecked();
});
it('should call onChange when status changed', async () => {
const onChange = vi.fn();
render(<StatusFilter value="active" onChange={onChange} />);
await userEvent.click(screen.getByRole('radio', { name: /expired/i }));
expect(onChange).toHaveBeenCalledWith('expired');
});
2.5 SearchBar
Purpose: Text input for searching sessions by container ID, name, or hostname.
Props Interface
interface SearchBarProps {
/**
* Current search value
*/
value?: string;
/**
* Callback when search value changes
*/
onChange: (search: string) => void;
/**
* Placeholder text
* @default "Search sessions..."
*/
placeholder?: string;
/**
* Debounce delay in milliseconds
* @default 300
*/
debounceMs?: number;
/**
* Show search icon
* @default true
*/
showIcon?: boolean;
/**
* Show clear button when value is not empty
* @default true
*/
showClearButton?: boolean;
}
Usage Example
import { SearchBar } from '@/components/container-sessions';
export function Filters() {
const [search, setSearch] = useState('');
return (
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search by container ID, name, or hostname..."
debounceMs={300}
/>
);
}
Component Structure
<div className="relative">
<label htmlFor="search" className="sr-only">
Search sessions
</label>
{showIcon && (
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" aria-hidden="true" />
</div>
)}
<input
type="search"
id="search"
value={value}
onChange={(e) => debouncedOnChange(e.target.value)}
placeholder={placeholder}
className="block w-full rounded-md border-gray-300 pl-10 pr-10 focus:border-blue-500 focus:ring-blue-500"
/>
{showClearButton && value && (
<button
type="button"
onClick={() => onChange('')}
className="absolute inset-y-0 right-0 flex items-center pr-3"
aria-label="Clear search"
>
<XMarkIcon className="h-5 w-5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
Debouncing
import { useDebouncedCallback } from 'use-debounce';
const debouncedOnChange = useDebouncedCallback(
(value: string) => onChange(value),
debounceMs
);
Accessibility
- Hidden
<label>viasr-onlyclass type="search"for semantic meaning- Clear button has
aria-label
Testing Considerations
it('should debounce search input', async () => {
const onChange = vi.fn();
render(<SearchBar onChange={onChange} debounceMs={300} />);
const input = screen.getByRole('searchbox');
await userEvent.type(input, 'test');
// Should not call onChange immediately
expect(onChange).not.toHaveBeenCalled();
// Should call onChange after debounce delay
await waitFor(() => expect(onChange).toHaveBeenCalledWith('test'), { timeout: 400 });
});
it('should clear search when clear button clicked', async () => {
const onChange = vi.fn();
render(<SearchBar value="test" onChange={onChange} />);
await userEvent.click(screen.getByRole('button', { name: /clear search/i }));
expect(onChange).toHaveBeenCalledWith('');
});
Layer 3: Session List & Cards
Components for displaying and interacting with the list of container sessions.
3.1 ContainerSessionList
Purpose: Container component that renders session cards in grid or list view with pagination and virtualization for performance.
Props Interface
interface ContainerSessionListProps {
/**
* Session data to display
*/
sessions: ContainerSession[];
/**
* Loading state
* @default false
*/
isLoading?: boolean;
/**
* Error state
*/
error?: Error | null;
/**
* View mode
* @default 'grid'
*/
view?: 'grid' | 'list';
/**
* Callback when session card is clicked
*/
onSessionClick?: (session: ContainerSession) => void;
/**
* Enable virtualization for large lists
* @default true for >50 items
*/
enableVirtualization?: boolean;
/**
* Show empty state when no sessions
* @default true
*/
showEmptyState?: boolean;
}
Usage Example
import { ContainerSessionList } from '@/components/container-sessions';
import { useContainerSessions } from '@/hooks/useContainerSessions';
export function SessionGrid() {
const { data, isLoading, error } = useContainerSessions({ status: 'active' });
const view = useDashboardStore(state => state.view);
const setSelectedSession = useDashboardStore(state => state.setSelectedSessionId);
return (
<ContainerSessionList
sessions={data?.results || []}
isLoading={isLoading}
error={error}
view={view}
onSessionClick={(session) => setSelectedSession(session.id)}
/>
);
}
Component Structure
{/* Loading State */}
{isLoading && (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{[...Array(6)].map((_, i) => (
<SessionCardSkeleton key={i} />
))}
</div>
)}
{/* Empty State */}
{!isLoading && sessions.length === 0 && showEmptyState && (
<EmptyState
title="No sessions found"
description="Try adjusting your filters or create a new session."
/>
)}
{/* Session Cards - Grid View */}
{!isLoading && view === 'grid' && (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{sessions.map((session) => (
<ContainerSessionCard
key={session.id}
session={session}
onClick={() => onSessionClick?.(session)}
/>
))}
</div>
)}
{/* Session Cards - List View */}
{!isLoading && view === 'list' && (
<div className="space-y-4">
{sessions.map((session) => (
<ContainerSessionCard
key={session.id}
session={session}
variant="list"
onClick={() => onSessionClick?.(session)}
/>
))}
</div>
)}
Virtualization (for large lists)
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedSessionList({ sessions }: { sessions: ContainerSession[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: sessions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 280, // Estimated card height
overscan: 5,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
{virtualizer.getVirtualItems().map((virtualItem) => (
<div
key={virtualItem.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ContainerSessionCard session={sessions[virtualItem.index]} />
</div>
))}
</div>
</div>
);
}
Performance Optimization
const MemoizedSessionCard = React.memo(ContainerSessionCard);
Accessibility
- Grid uses proper landmark (
<main>or<section>) - Empty state has descriptive text
- Loading state announced via
aria-live="polite"
Testing Considerations
it('should render session cards in grid view', () => {
render(<ContainerSessionList sessions={mockSessions} view="grid" />);
expect(screen.getAllByRole('article')).toHaveLength(mockSessions.length);
});
it('should show empty state when no sessions', () => {
render(<ContainerSessionList sessions={[]} />);
expect(screen.getByText(/no sessions found/i)).toBeInTheDocument();
});
it('should call onSessionClick when card is clicked', async () => {
const onSessionClick = vi.fn();
render(<ContainerSessionList sessions={mockSessions} onSessionClick={onSessionClick} />);
await userEvent.click(screen.getAllByRole('article')[0]);
expect(onSessionClick).toHaveBeenCalledWith(mockSessions[0]);
});
3.2 ContainerSessionCard
Purpose: Individual session card displaying session metadata, status, users, and actions.
Props Interface
interface ContainerSessionCardProps {
/**
* Container session data
*/
session: ContainerSession;
/**
* Card variant (grid or list view)
* @default 'grid'
*/
variant?: 'grid' | 'list';
/**
* Callback when card is clicked
*/
onClick?: () => void;
/**
* Show action menu
* @default true
*/
showActions?: boolean;
/**
* Current user role for action permissions
*/
userRole?: UserRole;
/**
* Highlight card (e.g., when selected)
* @default false
*/
highlighted?: boolean;
}
Usage Example
import { ContainerSessionCard } from '@/components/container-sessions';
export function SessionGrid({ sessions }: { sessions: ContainerSession[] }) {
const { role } = useAuth();
const selectedId = useDashboardStore(state => state.selectedSessionId);
return (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{sessions.map((session) => (
<ContainerSessionCard
key={session.id}
session={session}
userRole={role}
highlighted={session.id === selectedId}
/>
))}
</div>
);
}
Component Structure
<article
className={cn(
'bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-lg transition-shadow cursor-pointer',
highlighted && 'ring-2 ring-blue-500',
variant === 'list' && 'flex items-center p-4'
)}
onClick={onClick}
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
>
{/* Header */}
<div className="p-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<ContainerTypeIcon type={session.container_type} className="h-6 w-6" />
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{session.container_name || session.container_id.substring(0, 12)}
</h3>
<p className="text-sm text-gray-500">{session.hostname}</p>
</div>
</div>
<SessionStatusBadge status={session.status} />
</div>
</div>
{/* Metadata */}
<div className="px-6 pb-4 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-500">Users</span>
<UserCountIndicator
current={session.current_user_count}
max={session.max_users}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">Heartbeat</span>
<HeartbeatTimer expiresAt={session.expires_at} />
</div>
<div className="flex items-center justify-between">
<span className="text-gray-500">License</span>
<code className="text-xs">{session.license_key.substring(0, 8)}...</code>
</div>
</div>
{/* Actions */}
{showActions && (
<div className="px-6 pb-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<ActionMenu session={session} userRole={userRole} />
</div>
)}
</article>
Accessibility
- Semantic
<article>element tabIndex={0}for keyboard focus- Keyboard handler for Enter key
- Status badge has accessible color contrast
Styling Notes
- Hover effect increases shadow
- Highlighted state uses ring utility
- Dark mode support via
dark:variants
Testing Considerations
it('should display container name and hostname', () => {
render(<ContainerSessionCard session={mockSession} />);
expect(screen.getByText(mockSession.container_name)).toBeInTheDocument();
expect(screen.getByText(mockSession.hostname)).toBeInTheDocument();
});
it('should show user count indicator', () => {
const session = { ...mockSession, current_user_count: 5, max_users: 10 };
render(<ContainerSessionCard session={session} />);
expect(screen.getByText('5 / 10')).toBeInTheDocument();
});
it('should call onClick when Enter key pressed', async () => {
const onClick = vi.fn();
render(<ContainerSessionCard session={mockSession} onClick={onClick} />);
const card = screen.getByRole('article');
card.focus();
await userEvent.keyboard('{Enter}');
expect(onClick).toHaveBeenCalled();
});
3.3 SessionStatusBadge
Purpose: Colored badge displaying session status (Active, Expired, Released).
Props Interface
interface SessionStatusBadgeProps {
/**
* Session status
*/
status: ContainerSessionStatus;
/**
* Size variant
* @default 'md'
*/
size?: 'sm' | 'md' | 'lg';
/**
* Show icon alongside text
* @default true
*/
showIcon?: boolean;
}
Usage Example
import { SessionStatusBadge } from '@/components/container-sessions';
export function SessionCard({ session }: { session: ContainerSession }) {
return (
<div>
<SessionStatusBadge status={session.status} size="md" />
</div>
);
}
Component Structure
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
SESSION_STATUS_COLORS[status],
size === 'sm' && 'px-2 py-0.5 text-[10px]',
size === 'lg' && 'px-3 py-1 text-sm'
)}
>
{showIcon && <StatusIcon status={status} className="h-3 w-3" />}
{SESSION_STATUS_LABELS[status]}
</span>
Color Mapping
const SESSION_STATUS_COLORS: Record<ContainerSessionStatus, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
expired: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
released: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
};
Accessibility
- Sufficient color contrast (WCAG 2.1 AA)
- Text label (not icon-only)
Testing Considerations
it.each([
['active', 'Active', 'green'],
['expired', 'Expired', 'red'],
['released', 'Released', 'gray'],
] as const)('should render %s status with %s label and %s color', (status, label, color) => {
const { container } = render(<SessionStatusBadge status={status} />);
expect(screen.getByText(label)).toBeInTheDocument();
expect(container.firstChild).toHaveClass(`bg-${color}-100`);
});
3.4 ContainerTypeIcon
Purpose: Icon component displaying container type (Docker, Cloud Workstation, Kubernetes).
Props Interface
interface ContainerTypeIconProps {
/**
* Container type
*/
type: ContainerType;
/**
* Icon size (Tailwind class)
* @default 'h-5 w-5'
*/
className?: string;
/**
* Show tooltip with type label
* @default true
*/
showTooltip?: boolean;
}
Usage Example
import { ContainerTypeIcon } from '@/components/container-sessions';
export function SessionCard({ session }: { session: ContainerSession }) {
return (
<div className="flex items-center gap-2">
<ContainerTypeIcon type={session.container_type} className="h-6 w-6" />
<span>{CONTAINER_TYPE_LABELS[session.container_type]}</span>
</div>
);
}
Component Structure
<div className="relative inline-block">
<div
className={cn(
'flex items-center justify-center rounded-md p-1.5',
CONTAINER_TYPE_COLORS[type],
className
)}
aria-label={CONTAINER_TYPE_LABELS[type]}
title={showTooltip ? CONTAINER_TYPE_LABELS[type] : undefined}
>
{type === 'docker' && <DockerIcon />}
{type === 'cloud_workstation' && <CloudIcon />}
{type === 'kubernetes' && <KubernetesIcon />}
</div>
</div>
Icon SVGs
// Custom SVG icons or use library like @heroicons/react or react-icons
function DockerIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" {...props}>
{/* Docker logo path */}
</svg>
);
}
Accessibility
aria-labelfor screen readerstitleattribute for tooltip- Icon uses
currentColorfor theme consistency
Testing Considerations
it('should render docker icon', () => {
render(<ContainerTypeIcon type="docker" />);
const icon = screen.getByLabelText('Docker');
expect(icon).toBeInTheDocument();
});
it('should apply custom className', () => {
const { container } = render(<ContainerTypeIcon type="docker" className="h-8 w-8" />);
expect(container.firstChild).toHaveClass('h-8', 'w-8');
});
3.5 UserCountIndicator
Purpose: Progress bar showing current users vs max capacity.
Props Interface
interface UserCountIndicatorProps {
/**
* Current number of users in container
*/
current: number;
/**
* Maximum allowed users
*/
max: number;
/**
* Show percentage text
* @default true
*/
showPercentage?: boolean;
/**
* Color threshold for warning/danger states
*/
thresholds?: {
warning: number; // default: 0.8 (80%)
danger: number; // default: 0.95 (95%)
};
}
Usage Example
import { UserCountIndicator } from '@/components/container-sessions';
export function SessionCard({ session }: { session: ContainerSession }) {
return (
<div>
<UserCountIndicator
current={session.current_user_count}
max={session.max_users}
/>
</div>
);
}
Component Structure
const percentage = (current / max) * 100;
const isWarning = percentage >= (thresholds?.warning || 80);
const isDanger = percentage >= (thresholds?.danger || 95);
return (
<div className="space-y-1">
{/* Text Label */}
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600 dark:text-gray-400">
{current} / {max} users
</span>
{showPercentage && (
<span className={cn(
'font-medium',
isDanger && 'text-red-600',
isWarning && !isDanger && 'text-yellow-600'
)}>
{percentage.toFixed(0)}%
</span>
)}
</div>
{/* Progress Bar */}
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className={cn(
'h-full transition-all duration-300',
isDanger && 'bg-red-500',
isWarning && !isDanger && 'bg-yellow-500',
!isWarning && 'bg-green-500'
)}
style={{ width: `${Math.min(percentage, 100)}%` }}
role="progressbar"
aria-valuenow={current}
aria-valuemin={0}
aria-valuemax={max}
aria-label={`${current} of ${max} users`}
/>
</div>
</div>
);
Accessibility
role="progressbar"with ARIA attributes- Descriptive
aria-label - Text label alongside visual progress bar
Testing Considerations
it('should show green progress bar when below warning threshold', () => {
render(<UserCountIndicator current={5} max={10} />);
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toHaveClass('bg-green-500');
expect(progressBar).toHaveStyle({ width: '50%' });
});
it('should show yellow progress bar when at warning threshold', () => {
render(<UserCountIndicator current={8} max={10} />);
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toHaveClass('bg-yellow-500');
});
it('should show red progress bar when at danger threshold', () => {
render(<UserCountIndicator current={10} max={10} />);
const progressBar = screen.getByRole('progressbar');
expect(progressBar).toHaveClass('bg-red-500');
expect(progressBar).toHaveStyle({ width: '100%' });
});
3.6 HeartbeatTimer
Purpose: Real-time countdown timer showing time until session expires.
Props Interface
interface HeartbeatTimerProps {
/**
* ISO 8601 timestamp when session expires
*/
expiresAt: string;
/**
* Callback when session expires
*/
onExpire?: () => void;
/**
* Warning threshold in seconds (show warning color)
* @default 120 (2 minutes)
*/
warningThreshold?: number;
/**
* Update interval in milliseconds
* @default 1000 (1 second)
*/
updateInterval?: number;
/**
* Show icon
* @default true
*/
showIcon?: boolean;
}
Usage Example
import { HeartbeatTimer } from '@/components/container-sessions';
export function SessionCard({ session }: { session: ContainerSession }) {
const queryClient = useQueryClient();
const handleExpire = () => {
// Invalidate queries to refetch updated session status
queryClient.invalidateQueries({ queryKey: containerSessionKeys.detail(session.id) });
};
return (
<div>
<HeartbeatTimer
expiresAt={session.expires_at}
onExpire={handleExpire}
/>
</div>
);
}
Component Structure
const [timeRemaining, setTimeRemaining] = useState<number>(0);
const isWarning = timeRemaining > 0 && timeRemaining <= warningThreshold;
const isExpired = timeRemaining <= 0;
useEffect(() => {
const interval = setInterval(() => {
const now = Date.now();
const expires = new Date(expiresAt).getTime();
const remaining = Math.max(0, Math.floor((expires - now) / 1000));
setTimeRemaining(remaining);
if (remaining === 0) {
onExpire?.();
}
}, updateInterval);
return () => clearInterval(interval);
}, [expiresAt, onExpire, updateInterval]);
return (
<div
className={cn(
'inline-flex items-center gap-1.5 text-sm font-medium',
isExpired && 'text-red-600',
isWarning && !isExpired && 'text-yellow-600',
!isWarning && !isExpired && 'text-gray-600'
)}
aria-live="polite"
aria-atomic="true"
>
{showIcon && <ClockIcon className="h-4 w-4" />}
{formatTimeRemaining(timeRemaining)}
</div>
);
Time Formatting
function formatTimeRemaining(seconds: number): string {
if (seconds <= 0) return 'Expired';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes >= 60) {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
return `${minutes}m ${remainingSeconds}s`;
}
Accessibility
aria-live="polite"announces updates to screen readersaria-atomic="true"reads entire content on change- Color coding with sufficient contrast
Performance Optimization
- Use
useRefto avoid re-creating interval on every render - Cleanup interval on unmount
Testing Considerations
it('should display time remaining', () => {
const expiresAt = new Date(Date.now() + 180000).toISOString(); // 3 minutes
render(<HeartbeatTimer expiresAt={expiresAt} />);
expect(screen.getByText(/3m 0s/)).toBeInTheDocument();
});
it('should show warning color when below threshold', () => {
const expiresAt = new Date(Date.now() + 60000).toISOString(); // 1 minute
const { container } = render(<HeartbeatTimer expiresAt={expiresAt} warningThreshold={120} />);
expect(container.firstChild).toHaveClass('text-yellow-600');
});
it('should call onExpire when timer reaches zero', async () => {
const onExpire = vi.fn();
const expiresAt = new Date(Date.now() + 1000).toISOString(); // 1 second
render(<HeartbeatTimer expiresAt={expiresAt} onExpire={onExpire} />);
await waitFor(() => expect(onExpire).toHaveBeenCalled(), { timeout: 2000 });
});
3.7 ActionMenu
Purpose: Dropdown menu with session actions (Terminate, View Details, Kick Users).
Props Interface
interface ActionMenuProps {
/**
* Container session
*/
session: ContainerSession;
/**
* Current user role for permission checks
*/
userRole: UserRole;
/**
* Callback when action is executed
*/
onActionComplete?: (action: string) => void;
/**
* Position of dropdown menu
* @default 'bottom-end'
*/
menuPosition?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
}
Usage Example
import { ActionMenu } from '@/components/container-sessions';
import { useAuth } from '@/hooks/useAuth';
export function SessionCard({ session }: { session: ContainerSession }) {
const { role } = useAuth();
const queryClient = useQueryClient();
const handleActionComplete = (action: string) => {
queryClient.invalidateQueries({ queryKey: containerSessionKeys.lists() });
console.log(`Action ${action} completed for session ${session.id}`);
};
return (
<ActionMenu
session={session}
userRole={role}
onActionComplete={handleActionComplete}
/>
);
}
Component Structure
import { Menu } from '@headlessui/react';
const canTerminate = ['system_admin', 'tenant_admin', 'team_manager'].includes(userRole);
const canKickUsers = canTerminate && session.current_user_count > 0;
return (
<Menu as="div" className="relative inline-block text-left">
{/* Trigger Button */}
<Menu.Button className="inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium hover:bg-gray-50">
<span>Actions</span>
<ChevronDownIcon className="h-4 w-4" aria-hidden="true" />
</Menu.Button>
{/* Dropdown Menu */}
<Menu.Items className="absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{/* View Details */}
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleViewDetails(session.id)}
className={cn(
active ? 'bg-gray-100' : '',
'block w-full px-4 py-2 text-left text-sm'
)}
>
<EyeIcon className="mr-2 h-4 w-4 inline" />
View Details
</button>
)}
</Menu.Item>
{/* Terminate Session (Admin only) */}
{canTerminate && (
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleTerminate(session.id)}
className={cn(
active ? 'bg-red-50' : '',
'block w-full px-4 py-2 text-left text-sm text-red-600'
)}
>
<XCircleIcon className="mr-2 h-4 w-4 inline" />
Terminate Session
</button>
)}
</Menu.Item>
)}
{/* Manage Users (if multi-user container) */}
{canKickUsers && (
<Menu.Item>
{({ active }) => (
<button
onClick={() => handleManageUsers(session.id)}
className={cn(
active ? 'bg-gray-100' : '',
'block w-full px-4 py-2 text-left text-sm'
)}
>
<UsersIcon className="mr-2 h-4 w-4 inline" />
Manage Users ({session.current_user_count})
</button>
)}
</Menu.Item>
)}
</div>
</Menu.Items>
</Menu>
);
Action Handlers
const { mutate: terminateSession } = useTerminateSession();
const handleTerminate = async (sessionId: string) => {
if (!window.confirm('Are you sure you want to terminate this session? All users will be disconnected.')) {
return;
}
terminateSession(sessionId, {
onSuccess: () => {
toast.success('Session terminated successfully');
onActionComplete?.('terminate');
},
onError: (error) => {
toast.error(`Failed to terminate session: ${error.message}`);
},
});
};
Accessibility
- Uses Headless UI
<Menu>for keyboard navigation - Focus management handled automatically
- Screen reader announcements
Testing Considerations
it('should show terminate action for admins', () => {
render(<ActionMenu session={mockSession} userRole="tenant_admin" />);
const button = screen.getByRole('button', { name: /actions/i });
fireEvent.click(button);
expect(screen.getByText(/terminate session/i)).toBeInTheDocument();
});
it('should hide terminate action for regular users', () => {
render(<ActionMenu session={mockSession} userRole="user" />);
const button = screen.getByRole('button', { name: /actions/i });
fireEvent.click(button);
expect(screen.queryByText(/terminate session/i)).not.toBeInTheDocument();
});
it('should confirm before terminating session', async () => {
window.confirm = vi.fn(() => false);
render(<ActionMenu session={mockSession} userRole="system_admin" />);
fireEvent.click(screen.getByRole('button', { name: /actions/i }));
await userEvent.click(screen.getByText(/terminate session/i));
expect(window.confirm).toHaveBeenCalled();
});
Layer 4: Detail Views
Components for displaying detailed session information in a drawer/modal.
4.1 SessionDetailDrawer
Purpose: Slide-in panel with tabs for session overview, user management, heartbeat timeline, and metadata.
Props Interface
interface SessionDetailDrawerProps {
/**
* Session ID to display
*/
sessionId: string | null;
/**
* Whether drawer is open
*/
isOpen: boolean;
/**
* Callback when drawer is closed
*/
onClose: () => void;
/**
* Initial tab to display
* @default 'overview'
*/
defaultTab?: 'overview' | 'users' | 'heartbeat' | 'metadata';
/**
* Current user role for permissions
*/
userRole: UserRole;
}
Usage Example
import { SessionDetailDrawer } from '@/components/container-sessions';
import { useDashboardStore } from '@/stores/dashboardStore';
export function Dashboard() {
const selectedSessionId = useDashboardStore(state => state.selectedSessionId);
const setSelectedSessionId = useDashboardStore(state => state.setSelectedSessionId);
const { role } = useAuth();
return (
<SessionDetailDrawer
sessionId={selectedSessionId}
isOpen={!!selectedSessionId}
onClose={() => setSelectedSessionId(null)}
userRole={role}
/>
);
}
Component Structure (using Headless UI Dialog)
import { Dialog, Tab } from '@headlessui/react';
const { data: session, isLoading } = useContainerSession(sessionId!, {
enabled: !!sessionId && isOpen,
});
return (
<Dialog open={isOpen} onClose={onClose} className="relative z-50">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
{/* Drawer */}
<div className="fixed inset-y-0 right-0 flex w-full max-w-2xl">
<Dialog.Panel className="w-full bg-white dark:bg-gray-900 shadow-xl">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<Dialog.Title className="text-lg font-semibold">
Session Details
</Dialog.Title>
<button
onClick={onClose}
className="rounded-md p-2 hover:bg-gray-100"
aria-label="Close drawer"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
{/* Loading State */}
{isLoading && <DetailDrawerSkeleton />}
{/* Tabs */}
{!isLoading && session && (
<Tab.Group defaultIndex={tabIndexMap[defaultTab]}>
<Tab.List className="flex border-b border-gray-200 px-6">
<Tab className={({ selected }) => cn(
'px-4 py-2 text-sm font-medium border-b-2 -mb-px',
selected ? 'border-blue-500 text-blue-600' : 'border-transparent'
)}>
Overview
</Tab>
<Tab className={tabClassName}>
Users ({session.current_user_count})
</Tab>
<Tab className={tabClassName}>
Heartbeat
</Tab>
<Tab className={tabClassName}>
Metadata
</Tab>
</Tab.List>
<Tab.Panels className="p-6 overflow-y-auto max-h-[calc(100vh-200px)]">
<Tab.Panel><SessionOverview session={session} /></Tab.Panel>
<Tab.Panel><UserSessionManager session={session} userRole={userRole} /></Tab.Panel>
<Tab.Panel><HeartbeatTimeline sessionId={session.id} /></Tab.Panel>
<Tab.Panel><SessionMetadataViewer session={session} /></Tab.Panel>
</Tab.Panels>
</Tab.Group>
)}
</Dialog.Panel>
</div>
</Dialog>
);
Accessibility
- Uses Headless UI
<Dialog>for proper focus management - Escape key closes drawer
- Focus trapped within drawer
aria-labelon close button
Testing Considerations
it('should open drawer when sessionId is provided', () => {
render(<SessionDetailDrawer sessionId="session-123" isOpen={true} onClose={vi.fn()} />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('should close drawer when close button clicked', async () => {
const onClose = vi.fn();
render(<SessionDetailDrawer sessionId="session-123" isOpen={true} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /close/i }));
expect(onClose).toHaveBeenCalled();
});
it('should switch tabs when tab is clicked', async () => {
render(<SessionDetailDrawer sessionId="session-123" isOpen={true} onClose={vi.fn()} />);
await userEvent.click(screen.getByRole('tab', { name: /users/i }));
expect(screen.getByText(/user session manager/i)).toBeInTheDocument();
});
4.2 SessionOverview
Purpose: Tab panel displaying session metadata in a structured layout.
Props Interface
interface SessionOverviewProps {
/**
* Container session data
*/
session: ContainerSession;
/**
* Show all fields or only key fields
* @default 'key'
*/
displayMode?: 'key' | 'all';
}
Usage Example
import { SessionOverview } from '@/components/container-sessions';
export function DetailDrawer({ session }: { session: ContainerSession }) {
return (
<div>
<SessionOverview session={session} displayMode="all" />
</div>
);
}
Component Structure
<dl className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Container Information */}
<div className="col-span-2">
<h3 className="text-lg font-semibold mb-4">Container Information</h3>
</div>
<DescriptionItem label="Container ID" value={session.container_id} mono />
<DescriptionItem label="Container Name" value={session.container_name || 'N/A'} />
<DescriptionItem label="Container Type" value={
<div className="flex items-center gap-2">
<ContainerTypeIcon type={session.container_type} />
{CONTAINER_TYPE_LABELS[session.container_type]}
</div>
} />
<DescriptionItem label="Hostname" value={session.hostname || 'N/A'} />
{/* License Information */}
<div className="col-span-2 mt-6">
<h3 className="text-lg font-semibold mb-4">License Information</h3>
</div>
<DescriptionItem label="License Key" value={session.license_key} mono />
<DescriptionItem label="Session Token" value={session.session_token.substring(0, 16) + '...'} mono />
{/* Session Status */}
<div className="col-span-2 mt-6">
<h3 className="text-lg font-semibold mb-4">Session Status</h3>
</div>
<DescriptionItem label="Status" value={<SessionStatusBadge status={session.status} />} />
<DescriptionItem label="Created" value={formatDateTime(session.created_at)} />
<DescriptionItem label="Last Heartbeat" value={formatDateTime(session.last_heartbeat)} />
<DescriptionItem label="Expires" value={formatDateTime(session.expires_at)} />
{/* User Capacity */}
<div className="col-span-2 mt-6">
<h3 className="text-lg font-semibold mb-4">User Capacity</h3>
</div>
<DescriptionItem label="Current Users" value={session.current_user_count} />
<DescriptionItem label="Max Users" value={session.max_users} />
<div className="col-span-2">
<UserCountIndicator current={session.current_user_count} max={session.max_users} />
</div>
</dl>
DescriptionItem Component
function DescriptionItem({
label,
value,
mono = false,
}: {
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div>
<dt className="text-sm font-medium text-gray-500">{label}</dt>
<dd className={cn(
'mt-1 text-sm text-gray-900 dark:text-white',
mono && 'font-mono'
)}>
{value}
</dd>
</div>
);
}
Accessibility
- Uses semantic
<dl>,<dt>,<dd>elements - Proper heading hierarchy
Testing Considerations
it('should display container ID', () => {
render(<SessionOverview session={mockSession} />);
expect(screen.getByText(mockSession.container_id)).toBeInTheDocument();
});
it('should display license key', () => {
render(<SessionOverview session={mockSession} />);
expect(screen.getByText(mockSession.license_key)).toBeInTheDocument();
});
4.3 HeartbeatTimeline
Purpose: Line chart showing heartbeat history over time using Recharts.
Props Interface
interface HeartbeatTimelineProps {
/**
* Session ID to fetch heartbeat data
*/
sessionId: string;
/**
* Time range to display
* @default 'hour'
*/
timeRange?: 'hour' | 'day' | 'week';
/**
* Chart height in pixels
* @default 300
*/
height?: number;
}
Usage Example
import { HeartbeatTimeline } from '@/components/container-sessions';
export function DetailDrawer({ sessionId }: { sessionId: string }) {
return (
<div>
<HeartbeatTimeline sessionId={sessionId} timeRange="day" />
</div>
);
}
Component Structure
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
const { data: metrics, isLoading } = useContainerSessionMetrics(timeRange);
if (isLoading) return <ChartSkeleton />;
return (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Heartbeat Timeline</h3>
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
className="rounded-md border-gray-300"
>
<option value="hour">Last Hour</option>
<option value="day">Last Day</option>
<option value="week">Last Week</option>
</select>
</div>
<ResponsiveContainer width="100%" height={height}>
<LineChart data={metrics?.data_points}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => formatTime(value)}
/>
<YAxis label={{ value: 'Active Users', angle: -90, position: 'insideLeft' }} />
<Tooltip
labelFormatter={(label) => formatDateTime(label)}
formatter={(value: number) => [`${value} users`, 'Active Users']}
/>
<Line
type="monotone"
dataKey="active_users"
stroke="#3b82f6"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
Data Format
interface HeartbeatDataPoint {
timestamp: string; // ISO 8601
active_users: number;
}
Accessibility
- Chart has descriptive title
- Axes have labels
- Tooltip provides data on hover
Testing Considerations
it('should render chart with data points', () => {
render(<HeartbeatTimeline sessionId="session-123" />);
expect(screen.getByText(/heartbeat timeline/i)).toBeInTheDocument();
});
it('should change time range when dropdown changes', async () => {
render(<HeartbeatTimeline sessionId="session-123" />);
await userEvent.selectOptions(screen.getByRole('combobox'), 'week');
expect(screen.getByRole('combobox')).toHaveValue('week');
});
4.4 SessionMetadataViewer
Purpose: JSON tree viewer for displaying raw session metadata.
Props Interface
interface SessionMetadataViewerProps {
/**
* Container session data
*/
session: ContainerSession;
/**
* Enable search/filter
* @default true
*/
enableSearch?: boolean;
/**
* Expanded depth level
* @default 2
*/
defaultExpandedLevel?: number;
}
Usage Example
import { SessionMetadataViewer } from '@/components/container-sessions';
export function DetailDrawer({ session }: { session: ContainerSession }) {
return (
<SessionMetadataViewer session={session} />
);
}
Component Structure (using react-json-view)
import ReactJson from 'react-json-view';
return (
<div className="space-y-4">
{enableSearch && (
<input
type="search"
placeholder="Search metadata..."
className="w-full rounded-md border-gray-300"
/>
)}
<ReactJson
src={session}
theme="rjv-default"
iconStyle="triangle"
displayDataTypes={false}
displayObjectSize={true}
enableClipboard={true}
collapsed={defaultExpandedLevel}
name="session"
/>
</div>
);
Accessibility
- Search input has label
- JSON tree keyboard navigable
Testing Considerations
it('should render JSON tree with session data', () => {
render(<SessionMetadataViewer session={mockSession} />);
expect(screen.getByText('session')).toBeInTheDocument();
});
Layer 5: Admin & System Views
Admin-only components for cross-tenant analytics and system-wide monitoring.
5.1 SystemAdminDashboard
Purpose: Platform-wide dashboard for AZ1.AI system administrators showing all tenants.
Props Interface
interface SystemAdminDashboardProps {
/**
* Show tenant selector
* @default true
*/
showTenantSelector?: boolean;
/**
* Polling interval for real-time updates
* @default 30000 (30 seconds)
*/
pollingInterval?: number;
}
Usage Example
import { SystemAdminDashboard } from '@/components/container-sessions/admin';
export function AdminPage() {
return <SystemAdminDashboard />;
}
Component Structure
<div className="space-y-6">
{/* Platform-wide Metrics */}
<SessionMetrics stats={platformStats} />
{/* Alerts */}
<SessionAlertsBanner />
{/* Tenant Overview Table */}
<TenantSessionOverview />
{/* License Utilization Chart */}
<LicenseUtilizationChart timeRange="day" />
</div>
Role Guard
if (user.role !== 'system_admin') {
return <Redirect to="/dashboard" />;
}
Testing Considerations
it('should only render for system admin role', () => {
const { container } = render(<SystemAdminDashboard />, {
wrapper: ({ children }) => (
<AuthContext.Provider value={{ role: 'tenant_admin' }}>
{children}
</AuthContext.Provider>
),
});
expect(container).toBeEmptyDOMElement();
});
5.2 TenantSessionOverview
Purpose: Table showing session statistics per tenant (System Admin only).
Props Interface
interface TenantSessionOverviewProps {
/**
* Show all tenants or filter
*/
tenantIds?: string[];
/**
* Sort column
* @default 'active_sessions'
*/
sortBy?: 'name' | 'active_sessions' | 'total_users' | 'utilization';
/**
* Sort order
* @default 'desc'
*/
sortOrder?: 'asc' | 'desc';
}
Usage Example
import { TenantSessionOverview } from '@/components/container-sessions/admin';
export function SystemDashboard() {
return <TenantSessionOverview sortBy="active_sessions" sortOrder="desc" />;
}
Component Structure
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col">Tenant</th>
<th scope="col">Active Sessions</th>
<th scope="col">Total Users</th>
<th scope="col">License Utilization</th>
<th scope="col">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{tenants.map((tenant) => (
<tr key={tenant.id}>
<td>{tenant.name}</td>
<td>{tenant.stats.active_sessions}</td>
<td>{tenant.stats.active_users} / {tenant.license_seats}</td>
<td>
<UserCountIndicator
current={tenant.stats.active_users}
max={tenant.license_seats}
/>
</td>
<td>
{tenant.has_alerts && <AlertBadge />}
</td>
</tr>
))}
</tbody>
</table>
Accessibility
- Semantic
<table>with<thead>,<tbody> scope="col"on headers- Sortable columns keyboard accessible
Testing Considerations
it('should render tenant rows', () => {
render(<TenantSessionOverview />);
expect(screen.getAllByRole('row')).toHaveLength(mockTenants.length + 1); // +1 for header
});
it('should sort by column when header clicked', async () => {
render(<TenantSessionOverview />);
await userEvent.click(screen.getByText(/active sessions/i));
// Assert sorted order
});
5.3 LicenseUtilizationChart
Purpose: Stacked area chart showing license usage across all tenants over time.
Props Interface
interface LicenseUtilizationChartProps {
/**
* Time range to display
* @default 'day'
*/
timeRange?: 'hour' | 'day' | 'week' | 'month';
/**
* Chart height in pixels
* @default 400
*/
height?: number;
/**
* Filter by tenant IDs (System Admin only)
*/
tenantIds?: string[];
}
Usage Example
import { LicenseUtilizationChart } from '@/components/container-sessions/admin';
export function UtilizationPanel() {
return <LicenseUtilizationChart timeRange="week" height={500} />;
}
Component Structure
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
const { data: metrics } = useContainerSessionMetrics(timeRange);
return (
<div>
<h3 className="text-lg font-semibold mb-4">License Utilization</h3>
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={metrics?.data_points}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="timestamp" tickFormatter={(v) => formatTime(v)} />
<YAxis label={{ value: 'Users', angle: -90, position: 'insideLeft' }} />
<Tooltip />
<Legend />
<Area type="monotone" dataKey="active_users" stackId="1" stroke="#3b82f6" fill="#3b82f6" />
<Area type="monotone" dataKey="idle_users" stackId="1" stroke="#f59e0b" fill="#f59e0b" />
</AreaChart>
</ResponsiveContainer>
</div>
);
Testing Considerations
it('should render chart with stacked areas', () => {
render(<LicenseUtilizationChart />);
expect(screen.getByText(/license utilization/i)).toBeInTheDocument();
});
5.4 SessionAlertsBanner
Purpose: Banner displaying critical alerts (license limits, expired sessions, etc.).
Props Interface
interface SessionAlertsBannerProps {
/**
* Maximum alerts to display
* @default 5
*/
maxAlerts?: number;
/**
* Auto-dismiss after seconds (0 = no auto-dismiss)
* @default 0
*/
autoDismissSeconds?: number;
}
Usage Example
import { SessionAlertsBanner } from '@/components/container-sessions/admin';
export function Dashboard() {
return <SessionAlertsBanner maxAlerts={3} />;
}
Component Structure
const alerts = [
{
id: 'alert-1',
type: 'error',
message: 'Tenant "Acme Corp" has exceeded license limit (105/100 users)',
},
{
id: 'alert-2',
type: 'warning',
message: '15 sessions will expire in the next hour',
},
];
return (
<div className="space-y-2">
{alerts.slice(0, maxAlerts).map((alert) => (
<div
key={alert.id}
className={cn(
'rounded-md p-4 flex items-center justify-between',
alert.type === 'error' && 'bg-red-50 text-red-800',
alert.type === 'warning' && 'bg-yellow-50 text-yellow-800'
)}
role="alert"
>
<div className="flex items-center gap-3">
{alert.type === 'error' && <ExclamationCircleIcon className="h-5 w-5" />}
{alert.type === 'warning' && <ExclamationTriangleIcon className="h-5 w-5" />}
<span>{alert.message}</span>
</div>
<button onClick={() => dismissAlert(alert.id)} aria-label="Dismiss alert">
<XMarkIcon className="h-5 w-5" />
</button>
</div>
))}
</div>
);
Accessibility
role="alert"for screen reader announcements- Dismiss button has
aria-label
Testing Considerations
it('should render alerts', () => {
render(<SessionAlertsBanner />);
expect(screen.getAllByRole('alert')).toHaveLength(2);
});
it('should dismiss alert when close clicked', async () => {
render(<SessionAlertsBanner />);
const dismissButtons = screen.getAllByRole('button', { name: /dismiss/i });
await userEvent.click(dismissButtons[0]);
expect(screen.getAllByRole('alert')).toHaveLength(1);
});
Supporting Infrastructure
6.1 useContainerSessions Hook
Purpose: React Query hook for fetching and managing container session data.
See: src/hooks/use-container-sessions.ts (already implemented)
Key Features:
- 5-second polling for real-time updates
- Automatic cache invalidation on mutations
- Optimistic updates for terminate/kick actions
- Query key factory for consistent caching
6.2 useDashboardStore (Zustand)
Purpose: Global UI state for dashboard filters, view mode, and selected session.
Store Interface
interface DashboardState {
// Filters
filters: ContainerSessionFilters;
setFilters: (filters: ContainerSessionFilters) => void;
clearFilters: () => void;
// View Mode
view: 'grid' | 'list';
setView: (view: 'grid' | 'list') => void;
// Selected Session
selectedSessionId: string | null;
setSelectedSessionId: (id: string | null) => void;
// Pagination
page: number;
setPage: (page: number) => void;
// Sorting
sortBy: string;
sortOrder: 'asc' | 'desc';
setSorting: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
}
Implementation
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useDashboardStore = create<DashboardState>()(
persist(
(set) => ({
filters: {},
setFilters: (filters) => set({ filters }),
clearFilters: () => set({ filters: {} }),
view: 'grid',
setView: (view) => set({ view }),
selectedSessionId: null,
setSelectedSessionId: (id) => set({ selectedSessionId: id }),
page: 1,
setPage: (page) => set({ page }),
sortBy: 'created_at',
sortOrder: 'desc',
setSorting: (sortBy, sortOrder) => set({ sortBy, sortOrder }),
}),
{
name: 'container-session-dashboard',
partialze: (state) => ({ view: state.view }), // Only persist view preference
}
)
);
Usage Example
function FilterBar() {
const filters = useDashboardStore(state => state.filters);
const setFilters = useDashboardStore(state => state.setFilters);
return <SessionFilters filters={filters} onFiltersChange={setFilters} />;
}
6.3 SessionErrorBoundary
Purpose: React error boundary for graceful error handling.
Component
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: (props: { error: Error; resetErrorBoundary: () => void }) => ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class SessionErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Session dashboard error:', error, errorInfo);
// Send to error tracking service (Sentry, etc.)
}
resetErrorBoundary = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback({
error: this.state.error!,
resetErrorBoundary: this.resetErrorBoundary,
});
}
return (
<div className="p-8 text-center">
<h2 className="text-xl font-semibold text-red-600">
Something went wrong
</h2>
<p className="mt-2 text-gray-600">{this.state.error?.message}</p>
<button
onClick={this.resetErrorBoundary}
className="mt-4 btn-primary"
>
Try again
</button>
</div>
);
}
return this.props.children;
}
}
6.4 EmptyState Component
Purpose: Friendly empty state when no sessions match filters.
Props
interface EmptyStateProps {
title?: string;
description?: string;
icon?: React.ReactNode;
action?: {
label: string;
onClick: () => void;
};
}
Component
export function EmptyState({ title, description, icon, action }: EmptyStateProps) {
return (
<div className="text-center py-12">
<div className="mx-auto h-12 w-12 text-gray-400">
{icon || <InboxIcon className="h-12 w-12" />}
</div>
<h3 className="mt-2 text-sm font-semibold text-gray-900">
{title || 'No sessions found'}
</h3>
<p className="mt-1 text-sm text-gray-500">
{description || 'Try adjusting your filters or create a new session.'}
</p>
{action && (
<button onClick={action.onClick} className="mt-4 btn-primary">
{action.label}
</button>
)}
</div>
);
}
6.5 NetworkErrorAlert
Purpose: Alert banner for API/network errors.
Props
interface NetworkErrorAlertProps {
error: Error | null;
onRetry?: () => void;
onDismiss?: () => void;
}
Component
export function NetworkErrorAlert({ error, onRetry, onDismiss }: NetworkErrorAlertProps) {
if (!error) return null;
return (
<div className="rounded-md bg-red-50 p-4" role="alert">
<div className="flex">
<ExclamationCircleIcon className="h-5 w-5 text-red-400" />
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800">
Connection Error
</h3>
<p className="mt-1 text-sm text-red-700">
{error.message}
</p>
<div className="mt-2 flex gap-2">
{onRetry && (
<button onClick={onRetry} className="btn-sm btn-outline-red">
Retry
</button>
)}
{onDismiss && (
<button onClick={onDismiss} className="btn-sm btn-text">
Dismiss
</button>
)}
</div>
</div>
</div>
</div>
);
}
Component Hierarchy
ContainerSessionDashboard
├── DashboardHeader
│ ├── Breadcrumbs
│ ├── TenantSelector (System Admin only)
│ └── RefreshButton
├── SessionMetrics
│ ├── MetricCard (Active Sessions)
│ ├── MetricCard (License Utilization)
│ └── MetricCard (Sessions by Type)
├── SessionFilters
│ ├── TenantFilter (System Admin)
│ ├── ContainerTypeFilter
│ ├── StatusFilter
│ └── SearchBar
├── ContainerSessionList
│ └── ContainerSessionCard[]
│ ├── ContainerTypeIcon
│ ├── SessionStatusBadge
│ ├── UserCountIndicator
│ ├── HeartbeatTimer
│ └── ActionMenu
└── SessionDetailDrawer
├── Tab: SessionOverview
├── Tab: UserSessionManager
├── Tab: HeartbeatTimeline
└── Tab: SessionMetadataViewer
SystemAdminDashboard (Admin Only)
├── SessionMetrics (Platform-wide)
├── SessionAlertsBanner
├── TenantSessionOverview
└── LicenseUtilizationChart
Data Flow
┌─────────────────────────────────────────────────────────────┐
│ DATA FLOW DIAGRAM │
├─────────────────────────────────────────────────────────────┤
│ │
│ Backend API (Django) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ GET /api/v1/sessions/ │ │
│ │ GET /api/v1/sessions/{id}/ │ │
│ │ GET /api/v1/sessions/stats/ │ │
│ │ DELETE /api/v1/sessions/{id}/ │ │
│ │ DELETE /api/v1/sessions/{id}/users/{user_id}/ │ │
│ └────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ React Query (Server State) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ useContainerSessions(filters) ──▶ 5s polling │ │
│ │ useContainerSession(id) ──▶ 5s polling │ │
│ │ useContainerSessionStats() ──▶ 30s polling │ │
│ │ useTerminateSession() ──▶ mutation │ │
│ │ useKickUser() ──▶ mutation │ │
│ └────────────────────┬─────────────────────────────────┘ │
│ │ │
│ ▼ │
│ React Components │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ ContainerSessionList │ │
│ │ ├── receives: sessions[], isLoading, error │ │
│ │ └── renders: ContainerSessionCard[] │ │
│ │ │ │
│ │ ContainerSessionCard │ │
│ │ ├── receives: session │ │
│ │ └── renders: StatusBadge, Timer, UserCount, Actions │ │
│ └──────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ │
│ Zustand (UI State) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ filters: ContainerSessionFilters │ │
│ │ view: 'grid' | 'list' │ │
│ │ selectedSessionId: string | null │ │
│ │ page: number │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Role-Based Rendering
| Feature | System Admin | Tenant Admin | Team Manager | User |
|---|---|---|---|---|
| View All Sessions | ✅ All tenants | ✅ Own tenant | ✅ Own team | ✅ Own sessions |
| TenantFilter | ✅ | ❌ | ❌ | ❌ |
| Terminate Session | ✅ | ✅ | ✅ | ❌ |
| Kick User | ✅ | ✅ | ✅ | ❌ |
| SystemAdminDashboard | ✅ | ❌ | ❌ | ❌ |
| TenantSessionOverview | ✅ | ❌ | ❌ | ❌ |
| LicenseUtilizationChart | ✅ | ✅ (own tenant) | ❌ | ❌ |
| SessionAlertsBanner | ✅ | ✅ (own tenant) | ❌ | ❌ |
Implementation
import { useAuth } from '@/hooks/useAuth';
function Dashboard() {
const { role } = useAuth();
return (
<div>
{/* Everyone sees basic dashboard */}
<ContainerSessionDashboard />
{/* System Admin sees cross-tenant view */}
{role === 'system_admin' && <SystemAdminDashboard />}
{/* Admins see license utilization */}
{['system_admin', 'tenant_admin'].includes(role) && (
<LicenseUtilizationChart />
)}
</div>
);
}
Storybook Examples
ContainerSessionCard Story
import type { Meta, StoryObj } from '@storybook/react';
import { ContainerSessionCard } from './ContainerSessionCard';
const meta: Meta<typeof ContainerSessionCard> = {
title: 'ContainerSessions/ContainerSessionCard',
component: ContainerSessionCard,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof ContainerSessionCard>;
export const ActiveDocker: Story = {
args: {
session: {
id: 'session-1',
container_id: 'abc123def456',
container_type: 'docker',
container_name: 'dev-container',
status: 'active',
current_user_count: 1,
max_users: 1,
hostname: 'workstation-1.local',
expires_at: new Date(Date.now() + 3600000).toISOString(),
license_key: 'CODITECT-XXXX-XXXX-XXXX',
},
userRole: 'tenant_admin',
},
};
export const CloudWorkstationMultiUser: Story = {
args: {
session: {
id: 'session-2',
container_id: 'gcp-workstation-abc',
container_type: 'cloud_workstation',
container_name: 'team-workstation',
status: 'active',
current_user_count: 8,
max_users: 10,
hostname: 'ws-team-1.gcp.coditect.ai',
expires_at: new Date(Date.now() + 600000).toISOString(),
license_key: 'CODITECT-YYYY-YYYY-YYYY',
},
userRole: 'team_manager',
},
};
export const ExpiredKubernetes: Story = {
args: {
session: {
id: 'session-3',
container_id: 'k8s-pod-xyz',
container_type: 'kubernetes',
container_name: 'ci-runner',
status: 'expired',
current_user_count: 0,
max_users: 5,
hostname: 'runner-3.k8s.cluster',
expires_at: new Date(Date.now() - 3600000).toISOString(),
license_key: 'CODITECT-ZZZZ-ZZZZ-ZZZZ',
},
userRole: 'system_admin',
},
};
Performance Optimization
1. Memoization
const MemoizedSessionCard = React.memo(ContainerSessionCard, (prev, next) => {
return (
prev.session.id === next.session.id &&
prev.session.status === next.session.status &&
prev.session.current_user_count === next.session.current_user_count
);
});
2. Virtualization (for large lists)
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualizedSessionList({ sessions }: { sessions: ContainerSession[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: sessions.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 280,
overscan: 5,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
{/* Render only visible items */}
</div>
);
}
3. Pagination
const { data } = useContainerSessions({
page: currentPage,
page_size: 20,
});
4. Optimistic Updates
const { mutate: terminateSession } = useTerminateSession();
const handleTerminate = (sessionId: string) => {
// Optimistically update cache before API response
queryClient.setQueryData(
containerSessionKeys.list(filters),
(old) => ({
...old,
results: old.results.map((s) =>
s.id === sessionId ? { ...s, status: 'released' } : s
),
})
);
terminateSession(sessionId);
};
5. Debouncing Search
import { useDebouncedCallback } from 'use-debounce';
const debouncedSearch = useDebouncedCallback((value: string) => {
setFilters({ ...filters, search: value });
}, 300);
Testing Considerations
Unit Tests
// Component rendering
it('should render session card with correct data');
it('should show loading skeleton when isLoading=true');
it('should display error message when error exists');
// User interactions
it('should call onClick when card is clicked');
it('should filter sessions when filter is changed');
it('should toggle view mode when button is clicked');
// State management
it('should update Zustand store when filters change');
it('should persist view preference to localStorage');
Integration Tests
// API integration
it('should fetch sessions from API on mount');
it('should poll for updates every 5 seconds');
it('should terminate session when action is clicked');
it('should invalidate cache after mutation');
// Multi-component workflows
it('should open detail drawer when session card is clicked');
it('should show filtered results when filters are applied');
it('should display empty state when no results match filters');
E2E Tests (Playwright)
test('admin can terminate session', async ({ page }) => {
await page.goto('/dashboard/sessions');
await page.click('[data-testid="session-card-1"]');
await page.click('[data-testid="action-menu"]');
await page.click('text=Terminate Session');
await page.click('text=Confirm');
await expect(page.locator('[data-testid="status-badge"]')).toHaveText('Released');
});
test('user cannot see terminate action', async ({ page }) => {
await page.goto('/dashboard/sessions');
await page.click('[data-testid="session-card-1"]');
await page.click('[data-testid="action-menu"]');
await expect(page.locator('text=Terminate Session')).not.toBeVisible();
});
Revision History
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0.0 | 2026-01-05 | F.5.2 | Initial comprehensive component documentation |
References
- ADR-055: Container Session Lifecycle
- ADR-056: Container Session UI Architecture
- Container Session Types
- Container Session Hooks
- CODITECT Design System
Document Owner: Frontend Team Maintained By: F.5.2 Documentation Task Status: Draft → Review → Approved Next Review: After Phase 1 implementation (Week 1 complete)