- Add PageUrlState utility for URL ↔ state serialization - Add column `id` field for unique column identification in URLs - Update idSelector to return String for URL compatibility - All DataGrid pages now support URL params: search, sort, order, id - Browser URL updates via replaceState (no GoRouter rebuilds) - Add FilterPanelSemantics for filter panel semantic IDs - Add TESTING.md documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
413 lines
13 KiB
Dart
413 lines
13 KiB
Dart
import 'package:flutter/material.dart' hide Stack;
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
|
|
import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_hosts_page.dart';
|
|
import 'package:tatlock_ui/features/control_room/router.dart';
|
|
import 'package:tatlock_ui/features/control_room/stacks/data/repositories/stack_repository_impl.dart';
|
|
import 'package:tatlock_ui/features/control_room/stacks/domain/entities/stack.dart';
|
|
import 'package:tatlock_ui/features/control_room/stacks/presentation/pages/stack_detail_page.dart';
|
|
import 'package:tatlock_ui/features/control_room/stacks/presentation/providers/stacks_provider.dart';
|
|
import 'package:tatlock_ui/features/control_room/stacks/presentation/widgets/stack_list_tile.dart';
|
|
import 'package:tatlock_ui/shared/layouts/widgets/filter_panel.dart';
|
|
import 'package:tatlock_ui/shared/layouts/widgets/nav_panel.dart';
|
|
|
|
/// Main Control Room page with nav panel and section content.
|
|
class ControlRoomPage extends ConsumerWidget {
|
|
const ControlRoomPage({
|
|
super.key,
|
|
this.nav = ControlRoomNav.containers,
|
|
this.routerState,
|
|
});
|
|
|
|
/// The current nav item to display.
|
|
final ControlRoomNav nav;
|
|
|
|
/// Router state for URL deep-linking (query params).
|
|
final GoRouterState? routerState;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Scaffold(
|
|
body: Row(
|
|
children: [
|
|
// Nav Panel - section navigation with grouping
|
|
NavPanel(
|
|
title: 'Sections',
|
|
icon: Icons.dns_outlined,
|
|
items: ControlRoomNav.values.map((n) => n.toNavItem()).toList(),
|
|
selectedId: nav.id,
|
|
onItemSelected: (id) {
|
|
final newNav = ControlRoomNav.values.firstWhere(
|
|
(n) => n.id == id,
|
|
);
|
|
// Navigate to section URL
|
|
context.go(pathForNav(newNav));
|
|
// Clear stack selection when changing sections
|
|
ref.read(selectedStackProvider.notifier).clear();
|
|
},
|
|
),
|
|
// Divider
|
|
VerticalDivider(
|
|
width: 1,
|
|
thickness: 1,
|
|
color: colorScheme.outlineVariant,
|
|
),
|
|
// Section content
|
|
Expanded(
|
|
child: _SectionContent(nav: nav, routerState: routerState),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Renders content for the selected nav item.
|
|
class _SectionContent extends ConsumerWidget {
|
|
const _SectionContent({required this.nav, this.routerState});
|
|
|
|
final ControlRoomNav nav;
|
|
final GoRouterState? routerState;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
return switch (nav) {
|
|
ControlRoomNav.containers => _ContainersSection(routerState: routerState),
|
|
ControlRoomNav.proxyHosts => ProxyHostsPage(routerState: routerState),
|
|
_ => _PlaceholderSection(nav: nav),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Placeholder for nav items not yet implemented.
|
|
class _PlaceholderSection extends StatelessWidget {
|
|
const _PlaceholderSection({required this.nav});
|
|
|
|
final ControlRoomNav nav;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
nav.icon,
|
|
size: 64,
|
|
color: colorScheme.outline,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
nav.label,
|
|
style: textTheme.headlineSmall?.copyWith(
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${nav.section} • Coming soon',
|
|
style: textTheme.bodyMedium?.copyWith(
|
|
color: colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Containers section with optional stack filter.
|
|
class _ContainersSection extends ConsumerWidget {
|
|
const _ContainersSection({this.routerState});
|
|
|
|
final GoRouterState? routerState;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final selectedStack = ref.watch(selectedStackProvider);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return Row(
|
|
children: [
|
|
// Stacks filter panel
|
|
_StacksFilterPanel(routerState: routerState),
|
|
// Divider
|
|
VerticalDivider(
|
|
width: 1,
|
|
thickness: 1,
|
|
color: colorScheme.outlineVariant,
|
|
),
|
|
// Main content - containers list or stack detail
|
|
Expanded(
|
|
child: selectedStack == null
|
|
? ContainersListPage(routerState: routerState)
|
|
: StackDetailPage(stackId: selectedStack),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Stacks filter panel for Containers section (includes "All Containers" option).
|
|
class _StacksFilterPanel extends ConsumerWidget {
|
|
const _StacksFilterPanel({this.routerState});
|
|
|
|
final GoRouterState? routerState;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final stacksAsync = ref.watch(stacksProvider);
|
|
final selectedStack = ref.watch(selectedStackProvider);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return FilterPanel(
|
|
title: 'Stacks',
|
|
icon: Icons.layers,
|
|
onRefresh: () => ref.invalidate(stacksProvider),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// All containers option
|
|
ListTile(
|
|
selected: selectedStack == null,
|
|
selectedTileColor:
|
|
colorScheme.primaryContainer.withValues(alpha: 0.3),
|
|
leading: Icon(
|
|
Icons.all_inbox,
|
|
color: selectedStack == null
|
|
? colorScheme.primary
|
|
: colorScheme.onSurfaceVariant,
|
|
),
|
|
title: const Text('All Containers'),
|
|
onTap: () => ref.read(selectedStackProvider.notifier).clear(),
|
|
),
|
|
const Divider(height: 1),
|
|
// Stacks list
|
|
Expanded(
|
|
child: stacksAsync.when(
|
|
loading: () => const Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
error: (error, _) => Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
color: colorScheme.error,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Failed to load stacks',
|
|
style: TextStyle(color: colorScheme.error),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
data: (stacks) => _StacksList(
|
|
stacks: stacks,
|
|
selectedStackId: selectedStack,
|
|
routerState: routerState,
|
|
onStackSelected: (id) =>
|
|
ref.read(selectedStackProvider.notifier).select(id),
|
|
onStackAction: (id, action) =>
|
|
_handleStackAction(context, ref, id, action),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleStackAction(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
String stackId,
|
|
String action,
|
|
) async {
|
|
final repository = ref.read(stackRepositoryProvider);
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
try {
|
|
switch (action) {
|
|
case 'start':
|
|
await repository.startStack(stackId);
|
|
case 'stop':
|
|
await repository.stopStack(stackId);
|
|
case 'restart':
|
|
await repository.restartStack(stackId);
|
|
}
|
|
|
|
// Refresh data
|
|
ref.invalidate(stacksProvider);
|
|
|
|
// Show success snackbar
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text('Stack ${action}ed successfully'),
|
|
backgroundColor: colorScheme.primaryContainer,
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
} catch (e) {
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.error_outline, color: colorScheme.onErrorContainer),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Failed to $action stack: ${e.toString()}',
|
|
style: TextStyle(color: colorScheme.onErrorContainer),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: colorScheme.errorContainer,
|
|
behavior: SnackBarBehavior.floating,
|
|
duration: const Duration(seconds: 4),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class _StacksList extends StatefulWidget {
|
|
const _StacksList({
|
|
required this.stacks,
|
|
required this.selectedStackId,
|
|
required this.onStackSelected,
|
|
required this.onStackAction,
|
|
this.routerState,
|
|
});
|
|
|
|
final List<Stack> stacks;
|
|
final String? selectedStackId;
|
|
final void Function(String) onStackSelected;
|
|
final void Function(String, String) onStackAction;
|
|
final GoRouterState? routerState;
|
|
|
|
@override
|
|
State<_StacksList> createState() => _StacksListState();
|
|
}
|
|
|
|
class _StacksListState extends State<_StacksList> {
|
|
final _searchController = TextEditingController();
|
|
String _searchQuery = '';
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
List<Stack> get _filteredStacks {
|
|
if (_searchQuery.isEmpty) return widget.stacks;
|
|
final query = _searchQuery.toLowerCase();
|
|
return widget.stacks.where((s) {
|
|
return s.name.toLowerCase().contains(query);
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
final filtered = _filteredStacks;
|
|
|
|
return Column(
|
|
children: [
|
|
// Search field
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search stacks...',
|
|
prefixIcon: const Icon(Icons.search, size: 18),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, size: 16),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() => _searchQuery = '');
|
|
},
|
|
)
|
|
: null,
|
|
isDense: true,
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 8,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
style: const TextStyle(fontSize: 13),
|
|
onChanged: (value) => setState(() => _searchQuery = value),
|
|
),
|
|
),
|
|
// Stack list or empty state
|
|
Expanded(
|
|
child: filtered.isEmpty
|
|
? Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_searchQuery.isEmpty
|
|
? Icons.layers_clear
|
|
: Icons.search_off,
|
|
size: 32,
|
|
color: colorScheme.outline,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_searchQuery.isEmpty
|
|
? 'No stacks found'
|
|
: 'No stacks match "$_searchQuery"',
|
|
style: TextStyle(
|
|
color: colorScheme.outline,
|
|
fontSize: 13,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
itemCount: filtered.length,
|
|
itemBuilder: (context, index) {
|
|
final stack = filtered[index];
|
|
return StackListTile(
|
|
stack: stack,
|
|
isSelected: stack.id == widget.selectedStackId,
|
|
onTap: () => widget.onStackSelected(stack.id),
|
|
onStart: () => widget.onStackAction(stack.id, 'start'),
|
|
onStop: () => widget.onStackAction(stack.id, 'stop'),
|
|
onRestart: () => widget.onStackAction(stack.id, 'restart'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|