From 88e88489533c2b9e14baff1a6208980140ea74b1 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 1 Jan 2026 22:56:37 +0100 Subject: [PATCH] refactor: convert Control Room views to DataGrid pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor containers_list_page to use shared DataGrid component - Refactor proxy_hosts_page to use shared DataGrid component - Add ContainerStatusBadge.fromString() factory for string state values - Extend CoreApiDataSource to handle raw array API responses - Simplify data classes (no freezed, just fromJson factories) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../pages/containers_list_page.dart | 549 ++++++++---------- .../widgets/container_status_badge.dart | 28 +- .../presentation/pages/proxy_hosts_page.dart | 471 +++++++-------- .../data_grid/adapters/core_api_source.dart | 33 +- 4 files changed, 475 insertions(+), 606 deletions(-) diff --git a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart index 67cf47f..4bc955d 100644 --- a/lib/features/control_room/containers/presentation/pages/containers_list_page.dart +++ b/lib/features/control_room/containers/presentation/pages/containers_list_page.dart @@ -1,17 +1,180 @@ import 'package:flutter/material.dart' hide Container; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart'; import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart'; +import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart'; +import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart'; + +/// Container data class for DataGrid display. +class ContainerData { + ContainerData({ + required this.id, + required this.fullId, + required this.name, + required this.image, + required this.state, + required this.status, + required this.ports, + }); + + factory ContainerData.fromJson(Map json) { + final ports = (json['ports'] as List?) + ?.map((p) => ContainerPort.fromJson(p as Map)) + .toList() ?? + []; + + return ContainerData( + id: json['id'] as String, + fullId: json['full_id'] as String? ?? json['id'] as String, + name: json['name'] as String, + image: json['image'] as String, + state: json['state'] as String, + status: json['status'] as String, + ports: ports, + ); + } + + final String id; + final String fullId; + final String name; + final String image; + final String state; + final String status; + final List ports; + + bool get canStart => state == 'exited' || state == 'created'; + bool get canStop => state == 'running'; + bool get canRestart => state == 'running'; +} + +class ContainerPort { + ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'}); + + factory ContainerPort.fromJson(Map json) => ContainerPort( + privatePort: json['private_port'] as int? ?? json['PrivatePort'] as int? ?? 0, + publicPort: json['public_port'] as int? ?? json['PublicPort'] as int?, + type: json['type'] as String? ?? json['Type'] as String? ?? 'tcp', + ); + + final int privatePort; + final int? publicPort; + final String type; + + String get formatted => publicPort != null ? '$publicPort:$privatePort' : '$privatePort'; +} /// Page displaying the list of containers using DataGrid. -class ContainersListPage extends ConsumerWidget { +class ContainersListPage extends ConsumerStatefulWidget { const ContainersListPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final containersAsync = ref.watch(containersProvider); + ConsumerState createState() => _ContainersListPageState(); +} + +class _ContainersListPageState extends ConsumerState { + late final StateNotifierProvider, + DataGridState> _gridProvider; + + @override + void initState() { + super.initState(); + final dio = ref.read(coreApiClientProvider); + final source = CoreApiDataSource( + dio: dio, + endpoint: '/infrastructure/containers', + fromJson: ContainerData.fromJson, + ); + + _gridProvider = dataGridProvider( + source: source, + config: _buildConfig(), + idSelector: (c) => c.id, + ); + } + + DataGridConfig _buildConfig() { + return DataGridConfig( + columns: [ + DataGridColumn( + header: 'Container', + valueBuilder: (c) => c.name, + sortable: true, + searchable: true, + width: const DataGridColumnWidth.flex(2), + cellBuilder: (context, c) => _ContainerCell(container: c), + ), + DataGridColumn( + header: 'Ports', + valueBuilder: (c) => c.ports.map((p) => p.formatted).join(', '), + width: const DataGridColumnWidth.flex(1), + cellBuilder: (context, c) => _PortsCell(ports: c.ports), + ), + DataGridColumn( + header: 'Status', + valueBuilder: (c) => c.status, + width: const DataGridColumnWidth.fixed(140), + alignment: DataGridColumnAlignment.end, + ), + ], + actions: [ + DataGridAction( + icon: Icons.play_arrow, + label: 'Start', + onTap: (c) async => _handleAction(c, 'start'), + showWhen: (c) => c.canStart, + ), + DataGridAction( + icon: Icons.stop, + label: 'Stop', + onTap: (c) async => _handleAction(c, 'stop'), + showWhen: (c) => c.canStop, + ), + DataGridAction( + icon: Icons.refresh, + label: 'Restart', + onTap: (c) async => _handleAction(c, 'restart'), + showWhen: (c) => c.canRestart, + ), + DataGridAction( + icon: Icons.article, + label: 'View Logs', + onTap: (c) async => _showLogs(c), + ), + ], + enableSearch: true, + searchHint: 'Search containers...', + showHeader: true, + showFooter: true, + ); + } + + Future _handleAction(ContainerData container, String action) async { + final actions = ref.read(containerActionsProvider.notifier); + switch (action) { + case 'start': + await actions.start(container.fullId); + case 'stop': + await actions.stop(container.fullId); + case 'restart': + await actions.restart(container.fullId); + } + // Refresh the grid after action + ref.read(_gridProvider.notifier).refresh(); + } + + Future _showLogs(ContainerData container) async { + if (!mounted) return; + showContainerLogs( + context, + containerId: container.fullId, + containerName: container.name, + ); + } + + @override + Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; // Listen for container action results to show snackbars @@ -53,343 +216,87 @@ class ContainersListPage extends ConsumerWidget { } }); - return containersAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: colorScheme.error, - ), - const SizedBox(height: 16), - const Text('Failed to load containers'), - const SizedBox(height: 8), - Text( - error.toString(), - style: TextStyle(color: colorScheme.outline), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => ref.invalidate(containersProvider), - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), - ], - ), - ), - data: (containers) => _ContainersList( - containers: containers, - onAction: (container, action) => _handleAction(ref, container, action), - onViewLogs: (c) => showContainerLogs( - context, - containerId: c.fullId, - containerName: c.name, - ), - onRefresh: () => ref.invalidate(containersProvider), - ), - ); - } - - void _handleAction(WidgetRef ref, Container container, String action) { - final actions = ref.read(containerActionsProvider.notifier); - switch (action) { - case 'start': - actions.start(container.fullId); - case 'stop': - actions.stop(container.fullId); - case 'restart': - actions.restart(container.fullId); - } - } -} - -class _ContainersList extends StatefulWidget { - const _ContainersList({ - required this.containers, - required this.onAction, - required this.onViewLogs, - required this.onRefresh, - }); - - final List containers; - final void Function(Container, String) onAction; - final void Function(Container) onViewLogs; - final VoidCallback onRefresh; - - @override - State<_ContainersList> createState() => _ContainersListState(); -} - -class _ContainersListState extends State<_ContainersList> { - final _searchController = TextEditingController(); - String _searchQuery = ''; - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - List get _filteredContainers { - if (_searchQuery.isEmpty) return widget.containers; - final query = _searchQuery.toLowerCase(); - return widget.containers.where((c) { - return c.name.toLowerCase().contains(query) || - c.image.toLowerCase().contains(query) || - c.status.toLowerCase().contains(query); - }).toList(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final filtered = _filteredContainers; - - return Column( - children: [ - // Toolbar with search - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - '${filtered.length} of ${widget.containers.length} containers', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - SizedBox( - width: 250, - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search containers...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onChanged: (value) => setState(() => _searchQuery = value), - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - onPressed: widget.onRefresh, - ), - ], - ), - ), - // Container list or empty state - Expanded( - child: filtered.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _searchQuery.isEmpty - ? Icons.inbox_outlined - : Icons.search_off, - size: 64, - color: colorScheme.outline, - ), - const SizedBox(height: 16), - Text( - _searchQuery.isEmpty - ? 'No containers found' - : 'No containers match "$_searchQuery"', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (_searchQuery.isNotEmpty) ...[ - const SizedBox(height: 8), - TextButton( - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - child: const Text('Clear search'), - ), - ], - ], - ), - ) - : ListView.builder( - itemCount: filtered.length, - itemBuilder: (context, index) { - final container = filtered[index]; - return _ContainerListTile( - container: container, - onAction: (action) => widget.onAction(container, action), - onViewLogs: () => widget.onViewLogs(container), - ); - }, - ), + return DataGrid( + provider: _gridProvider, + config: _buildConfig(), + idSelector: (c) => c.id, + toolbarActions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => ref.read(_gridProvider.notifier).refresh(), ), ], ); } } -class _ContainerListTile extends StatelessWidget { - const _ContainerListTile({ - required this.container, - required this.onAction, - required this.onViewLogs, - }); +class _ContainerCell extends StatelessWidget { + const _ContainerCell({required this.container}); - final Container container; - final void Function(String) onAction; - final VoidCallback onViewLogs; + final ContainerData container; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Status and name - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ContainerStatusBadge(state: container.state), - const SizedBox(width: 8), - Expanded( - child: Text( - container.name, - style: const TextStyle(fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - container.image, - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ], + return Row( + children: [ + ContainerStatusBadge.fromString(container.state), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + container.name, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, ), - ), - // Ports - if (container.ports.isNotEmpty) - Expanded( - child: Text( - container.ports.map((p) => p.formatted).join(', '), - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - ), - ), - // Status text - SizedBox( - width: 120, - child: Text( - container.status, + Text( + container.image, style: TextStyle( fontSize: 12, color: colorScheme.onSurfaceVariant, ), - textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, ), - ), - // Actions - const SizedBox(width: 8), - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: 'Actions', - onSelected: (action) { - if (action == 'logs') { - onViewLogs(); - } else { - onAction(action); - } - }, - itemBuilder: (context) => [ - if (container.canStart) - const PopupMenuItem( - value: 'start', - child: Row( - children: [ - Icon(Icons.play_arrow, size: 18), - SizedBox(width: 8), - Text('Start'), - ], - ), - ), - if (container.canStop) - const PopupMenuItem( - value: 'stop', - child: Row( - children: [ - Icon(Icons.stop, size: 18), - SizedBox(width: 8), - Text('Stop'), - ], - ), - ), - if (container.canRestart) - const PopupMenuItem( - value: 'restart', - child: Row( - children: [ - Icon(Icons.refresh, size: 18), - SizedBox(width: 8), - Text('Restart'), - ], - ), - ), - const PopupMenuItem( - value: 'logs', - child: Row( - children: [ - Icon(Icons.article, size: 18), - SizedBox(width: 8), - Text('View Logs'), - ], - ), - ), - ], - ), - ], + ], + ), ), + ], + ); + } +} + +class _PortsCell extends StatelessWidget { + const _PortsCell({required this.ports}); + + final List ports; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + if (ports.isEmpty) { + return Text( + '-', + style: TextStyle( + fontSize: 12, + color: colorScheme.outline, + ), + ); + } + + return Text( + ports.map((p) => p.formatted).join(', '), + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', ), + overflow: TextOverflow.ellipsis, ); } } diff --git a/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart b/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart index 348781d..feadb52 100644 --- a/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart +++ b/lib/features/control_room/containers/presentation/widgets/container_status_badge.dart @@ -1,5 +1,23 @@ import 'package:flutter/material.dart' hide Container; -import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart'; + +/// Container state enum for status display. +enum ContainerState { + created, + running, + paused, + restarting, + removing, + exited, + dead; + + /// Parse a string to ContainerState. + static ContainerState fromString(String value) { + return ContainerState.values.firstWhere( + (s) => s.name == value.toLowerCase(), + orElse: () => ContainerState.exited, + ); + } +} /// Status badge for container state. class ContainerStatusBadge extends StatelessWidget { @@ -9,6 +27,14 @@ class ContainerStatusBadge extends StatelessWidget { this.showLabel = true, }); + /// Create badge from a string state value. + factory ContainerStatusBadge.fromString(String state, {bool showLabel = true}) { + return ContainerStatusBadge( + state: ContainerState.fromString(state), + showLabel: showLabel, + ); + } + final ContainerState state; final bool showLabel; diff --git a/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart b/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart index db12e98..eb79d8d 100644 --- a/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart +++ b/lib/features/control_room/npm/presentation/pages/proxy_hosts_page.dart @@ -1,24 +1,124 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart'; +import 'package:tatlock_ui/core/api/api_client.dart'; import 'package:tatlock_ui/features/control_room/npm/presentation/pages/proxy_host_page.dart'; import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart'; +import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart'; +import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart'; + +/// Domain info data class for DataGrid display. +class DomainData { + DomainData({ + required this.domain, + required this.service, + required this.proxyHostId, + required this.sslEnabled, + this.certificateId, + }); + + factory DomainData.fromJson(Map json) => DomainData( + domain: json['domain'] as String, + service: json['service'] as String, + proxyHostId: json['proxy_host_id'] as int, + sslEnabled: json['ssl_enabled'] as bool? ?? false, + certificateId: json['certificate_id'] as int?, + ); + + final String domain; + final String service; + final int proxyHostId; + final bool sslEnabled; + final int? certificateId; +} /// Page displaying the list of proxy hosts (domains) from NPM. -class ProxyHostsPage extends ConsumerWidget { +class ProxyHostsPage extends ConsumerStatefulWidget { const ProxyHostsPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final domainsAsync = ref.watch(domainsProvider); + ConsumerState createState() => _ProxyHostsPageState(); +} + +class _ProxyHostsPageState extends ConsumerState { + late final StateNotifierProvider, + DataGridState> _gridProvider; + + @override + void initState() { + super.initState(); + final dio = ref.read(coreApiClientProvider); + final source = CoreApiDataSource( + dio: dio, + endpoint: '/infrastructure/domains', + fromJson: DomainData.fromJson, + ); + + _gridProvider = dataGridProvider( + source: source, + config: _buildConfig(), + idSelector: (d) => d.proxyHostId.toString(), + ); + } + + DataGridConfig _buildConfig() { + return DataGridConfig( + columns: [ + DataGridColumn( + header: 'Domain', + valueBuilder: (d) => d.domain, + sortable: true, + searchable: true, + width: const DataGridColumnWidth.flex(2), + cellBuilder: (context, d) => _DomainCell(domain: d), + ), + DataGridColumn( + header: 'Service', + valueBuilder: (d) => d.service, + width: const DataGridColumnWidth.flex(1), + cellBuilder: (context, d) => _ServiceCell(service: d.service), + ), + DataGridColumn( + header: 'SSL', + valueBuilder: (d) => d.sslEnabled ? 'Enabled' : 'Disabled', + width: const DataGridColumnWidth.fixed(100), + alignment: DataGridColumnAlignment.end, + cellBuilder: (context, d) => _SslBadge(enabled: d.sslEnabled), + ), + ], + actions: [ + DataGridAction( + icon: Icons.edit, + label: 'Edit', + onTap: (d) async => _viewDomain(d), + ), + ], + enableSearch: true, + searchHint: 'Search domains...', + showHeader: true, + showFooter: true, + ); + } + + void _viewDomain(DomainData domain) { + ref.read(selectedProxyHostProvider.notifier).select(domain.proxyHostId); + } + + void _createNew() { + ref.read(creatingProxyHostProvider.notifier).start(); + } + + @override + Widget build(BuildContext context) { final selectedId = ref.watch(selectedProxyHostProvider); final isCreating = ref.watch(creatingProxyHostProvider); - final colorScheme = Theme.of(context).colorScheme; // Show create page if creating new if (isCreating) { return ProxyHostPage.create( - onClose: () => ref.read(creatingProxyHostProvider.notifier).stop(), + onClose: () { + ref.read(creatingProxyHostProvider.notifier).stop(); + ref.read(_gridProvider.notifier).refresh(); + }, ); } @@ -26,289 +126,116 @@ class ProxyHostsPage extends ConsumerWidget { if (selectedId != null) { return ProxyHostPage( proxyHostId: selectedId, - onClose: () => ref.read(selectedProxyHostProvider.notifier).clear(), + onClose: () { + ref.read(selectedProxyHostProvider.notifier).clear(); + ref.read(_gridProvider.notifier).refresh(); + }, ); } - return domainsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, stack) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.error_outline, - size: 48, - color: colorScheme.error, - ), - const SizedBox(height: 16), - const Text('Failed to load proxy hosts'), - const SizedBox(height: 8), - Text( - error.toString(), - style: TextStyle(color: colorScheme.outline), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: () => ref.invalidate(domainsProvider), - icon: const Icon(Icons.refresh), - label: const Text('Retry'), - ), - ], + return DataGrid( + provider: _gridProvider, + config: _buildConfig(), + idSelector: (d) => d.proxyHostId.toString(), + toolbarActions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: () => ref.read(_gridProvider.notifier).refresh(), ), - ), - data: (domains) => _DomainsList( - domains: domains, - onRefresh: () => ref.invalidate(domainsProvider), - onSelect: (id) => ref.read(selectedProxyHostProvider.notifier).select(id), - onCreateNew: () => ref.read(creatingProxyHostProvider.notifier).start(), - ), - ); - } -} - -class _DomainsList extends StatefulWidget { - const _DomainsList({ - required this.domains, - required this.onRefresh, - required this.onSelect, - required this.onCreateNew, - }); - - final List domains; - final VoidCallback onRefresh; - final void Function(int proxyHostId) onSelect; - final VoidCallback onCreateNew; - - @override - State<_DomainsList> createState() => _DomainsListState(); -} - -class _DomainsListState extends State<_DomainsList> { - final _searchController = TextEditingController(); - String _searchQuery = ''; - - @override - void dispose() { - _searchController.dispose(); - super.dispose(); - } - - List get _filteredDomains { - if (_searchQuery.isEmpty) return widget.domains; - final query = _searchQuery.toLowerCase(); - return widget.domains.where((d) { - return d.domain.toLowerCase().contains(query) || - d.service.toLowerCase().contains(query); - }).toList(); - } - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final filtered = _filteredDomains; - - return Column( - children: [ - // Toolbar with search - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Text( - '${filtered.length} of ${widget.domains.length} proxy hosts', - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const Spacer(), - SizedBox( - width: 250, - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Search domains...', - prefixIcon: const Icon(Icons.search, size: 20), - suffixIcon: _searchQuery.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - ) - : null, - isDense: true, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onChanged: (value) => setState(() => _searchQuery = value), - ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - onPressed: widget.onRefresh, - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: widget.onCreateNew, - icon: const Icon(Icons.add), - label: const Text('New'), - ), - ], - ), - ), - // Domain list or empty state - Expanded( - child: filtered.isEmpty - ? Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _searchQuery.isEmpty - ? Icons.public_off - : Icons.search_off, - size: 64, - color: colorScheme.outline, - ), - const SizedBox(height: 16), - Text( - _searchQuery.isEmpty - ? 'No proxy hosts configured' - : 'No domains match "$_searchQuery"', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (_searchQuery.isNotEmpty) ...[ - const SizedBox(height: 8), - TextButton( - onPressed: () { - _searchController.clear(); - setState(() => _searchQuery = ''); - }, - child: const Text('Clear search'), - ), - ], - ], - ), - ) - : ListView.builder( - itemCount: filtered.length, - itemBuilder: (context, index) { - final domain = filtered[index]; - return _DomainListTile( - domain: domain, - onTap: () => widget.onSelect(domain.proxyHostId), - ); - }, - ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _createNew, + icon: const Icon(Icons.add), + label: const Text('New'), ), ], ); } } -class _DomainListTile extends StatelessWidget { - const _DomainListTile({required this.domain, required this.onTap}); +class _DomainCell extends StatelessWidget { + const _DomainCell({required this.domain}); - final DomainInfoModel domain; - final VoidCallback onTap; + final DomainData domain; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // SSL status icon - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: domain.sslEnabled - ? Colors.green.withValues(alpha: 0.1) - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - domain.sslEnabled ? Icons.lock : Icons.lock_open, - size: 20, - color: domain.sslEnabled ? Colors.green : colorScheme.outline, - ), - ), - const SizedBox(width: 12), - // Domain and service info - Expanded( - flex: 2, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - domain.domain, - style: const TextStyle(fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - domain.service, - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - // Service target - Expanded( - child: Text( - domain.service, - style: TextStyle( - fontSize: 12, - color: colorScheme.onSurfaceVariant, - fontFamily: 'monospace', - ), - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.end, - ), - ), - // SSL badge - const SizedBox(width: 12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: domain.sslEnabled - ? Colors.green.withValues(alpha: 0.1) - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - domain.sslEnabled ? 'SSL' : 'HTTP', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w500, - color: domain.sslEnabled ? Colors.green : colorScheme.outline, - ), - ), - ), - ], + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: domain.sslEnabled + ? Colors.green.withValues(alpha: 0.1) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), ), + child: Icon( + domain.sslEnabled ? Icons.lock : Icons.lock_open, + size: 20, + color: domain.sslEnabled ? Colors.green : colorScheme.outline, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + domain.domain, + style: const TextStyle(fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _ServiceCell extends StatelessWidget { + const _ServiceCell({required this.service}); + + final String service; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Text( + service, + style: TextStyle( + fontSize: 12, + color: colorScheme.onSurfaceVariant, + fontFamily: 'monospace', + ), + overflow: TextOverflow.ellipsis, + ); + } +} + +class _SslBadge extends StatelessWidget { + const _SslBadge({required this.enabled}); + + final bool enabled; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: enabled + ? Colors.green.withValues(alpha: 0.1) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + enabled ? 'SSL' : 'HTTP', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: enabled ? Colors.green : colorScheme.outline, ), ), ); diff --git a/lib/shared/components/data_grid/adapters/core_api_source.dart b/lib/shared/components/data_grid/adapters/core_api_source.dart index 4858521..6575e26 100644 --- a/lib/shared/components/data_grid/adapters/core_api_source.dart +++ b/lib/shared/components/data_grid/adapters/core_api_source.dart @@ -88,28 +88,37 @@ class CoreApiDataSource extends DataGridSource { queryParams[limitParam] = limit; } - final response = await dio.get>( + final response = await dio.get( endpoint, queryParameters: queryParams, ); - final data = response.data!; + final responseData = response.data!; // Handle both paginated and wrapped responses List itemsJson; int totalCount; - if (data.containsKey(itemsKey)) { - // Paginated response: { items: [...], total: N } - itemsJson = data[itemsKey] as List; - totalCount = data[totalCountKey] as int? ?? itemsJson.length; + if (responseData is List) { + // Direct array response: [...] + itemsJson = responseData; + totalCount = itemsJson.length; + } else if (responseData is Map) { + final data = responseData; + if (data.containsKey(itemsKey)) { + // Paginated response: { items: [...], total: N } + itemsJson = data[itemsKey] as List; + totalCount = data[totalCountKey] as int? ?? itemsJson.length; + } else { + // Try common wrapper patterns: { data: [...] } or { results: [...] } + itemsJson = (data['data'] ?? data['results'] ?? []) as List; + totalCount = data['count'] as int? ?? + data['total'] as int? ?? + data['totalCount'] as int? ?? + itemsJson.length; + } } else { - // Try common wrapper patterns: { data: [...] } or { results: [...] } - itemsJson = (data['data'] ?? data['results'] ?? []) as List; - totalCount = data['count'] as int? ?? - data['total'] as int? ?? - data['totalCount'] as int? ?? - itemsJson.length; + throw FormatException('Unexpected response type: ${responseData.runtimeType}'); } final items = itemsJson