refactor: convert Control Room views to DataGrid pattern
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
a5a8e7ba88
commit
88e8848953
+228
-321
@@ -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<String, dynamic> json) {
|
||||
final ports = (json['ports'] as List<dynamic>?)
|
||||
?.map((p) => ContainerPort.fromJson(p as Map<String, dynamic>))
|
||||
.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<ContainerPort> 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<String, dynamic> 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<ContainersListPage> createState() => _ContainersListPageState();
|
||||
}
|
||||
|
||||
class _ContainersListPageState extends ConsumerState<ContainersListPage> {
|
||||
late final StateNotifierProvider<DataGridController<ContainerData>,
|
||||
DataGridState<ContainerData>> _gridProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final dio = ref.read(coreApiClientProvider);
|
||||
final source = CoreApiDataSource<ContainerData>(
|
||||
dio: dio,
|
||||
endpoint: '/infrastructure/containers',
|
||||
fromJson: ContainerData.fromJson,
|
||||
);
|
||||
|
||||
_gridProvider = dataGridProvider<ContainerData>(
|
||||
source: source,
|
||||
config: _buildConfig(),
|
||||
idSelector: (c) => c.id,
|
||||
);
|
||||
}
|
||||
|
||||
DataGridConfig<ContainerData> _buildConfig() {
|
||||
return DataGridConfig<ContainerData>(
|
||||
columns: [
|
||||
DataGridColumn<ContainerData>(
|
||||
header: 'Container',
|
||||
valueBuilder: (c) => c.name,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: const DataGridColumnWidth.flex(2),
|
||||
cellBuilder: (context, c) => _ContainerCell(container: c),
|
||||
),
|
||||
DataGridColumn<ContainerData>(
|
||||
header: 'Ports',
|
||||
valueBuilder: (c) => c.ports.map((p) => p.formatted).join(', '),
|
||||
width: const DataGridColumnWidth.flex(1),
|
||||
cellBuilder: (context, c) => _PortsCell(ports: c.ports),
|
||||
),
|
||||
DataGridColumn<ContainerData>(
|
||||
header: 'Status',
|
||||
valueBuilder: (c) => c.status,
|
||||
width: const DataGridColumnWidth.fixed(140),
|
||||
alignment: DataGridColumnAlignment.end,
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.play_arrow,
|
||||
label: 'Start',
|
||||
onTap: (c) async => _handleAction(c, 'start'),
|
||||
showWhen: (c) => c.canStart,
|
||||
),
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
onTap: (c) async => _handleAction(c, 'stop'),
|
||||
showWhen: (c) => c.canStop,
|
||||
),
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.refresh,
|
||||
label: 'Restart',
|
||||
onTap: (c) async => _handleAction(c, 'restart'),
|
||||
showWhen: (c) => c.canRestart,
|
||||
),
|
||||
DataGridAction<ContainerData>(
|
||||
icon: Icons.article,
|
||||
label: 'View Logs',
|
||||
onTap: (c) async => _showLogs(c),
|
||||
),
|
||||
],
|
||||
enableSearch: true,
|
||||
searchHint: 'Search containers...',
|
||||
showHeader: true,
|
||||
showFooter: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<Container> 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<Container> 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<ContainerData>(
|
||||
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<String>(
|
||||
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<ContainerPort> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, dynamic> 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<ProxyHostsPage> createState() => _ProxyHostsPageState();
|
||||
}
|
||||
|
||||
class _ProxyHostsPageState extends ConsumerState<ProxyHostsPage> {
|
||||
late final StateNotifierProvider<DataGridController<DomainData>,
|
||||
DataGridState<DomainData>> _gridProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final dio = ref.read(coreApiClientProvider);
|
||||
final source = CoreApiDataSource<DomainData>(
|
||||
dio: dio,
|
||||
endpoint: '/infrastructure/domains',
|
||||
fromJson: DomainData.fromJson,
|
||||
);
|
||||
|
||||
_gridProvider = dataGridProvider<DomainData>(
|
||||
source: source,
|
||||
config: _buildConfig(),
|
||||
idSelector: (d) => d.proxyHostId.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
DataGridConfig<DomainData> _buildConfig() {
|
||||
return DataGridConfig<DomainData>(
|
||||
columns: [
|
||||
DataGridColumn<DomainData>(
|
||||
header: 'Domain',
|
||||
valueBuilder: (d) => d.domain,
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
width: const DataGridColumnWidth.flex(2),
|
||||
cellBuilder: (context, d) => _DomainCell(domain: d),
|
||||
),
|
||||
DataGridColumn<DomainData>(
|
||||
header: 'Service',
|
||||
valueBuilder: (d) => d.service,
|
||||
width: const DataGridColumnWidth.flex(1),
|
||||
cellBuilder: (context, d) => _ServiceCell(service: d.service),
|
||||
),
|
||||
DataGridColumn<DomainData>(
|
||||
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<DomainData>(
|
||||
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<DomainData>(
|
||||
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<DomainInfoModel> 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<DomainInfoModel> 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -88,28 +88,37 @@ class CoreApiDataSource<T> extends DataGridSource<T> {
|
||||
queryParams[limitParam] = limit;
|
||||
}
|
||||
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
final response = await dio.get<dynamic>(
|
||||
endpoint,
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
|
||||
final data = response.data!;
|
||||
final responseData = response.data!;
|
||||
|
||||
// Handle both paginated and wrapped responses
|
||||
List<dynamic> itemsJson;
|
||||
int totalCount;
|
||||
|
||||
if (data.containsKey(itemsKey)) {
|
||||
// Paginated response: { items: [...], total: N }
|
||||
itemsJson = data[itemsKey] as List<dynamic>;
|
||||
totalCount = data[totalCountKey] as int? ?? itemsJson.length;
|
||||
if (responseData is List) {
|
||||
// Direct array response: [...]
|
||||
itemsJson = responseData;
|
||||
totalCount = itemsJson.length;
|
||||
} else if (responseData is Map<String, dynamic>) {
|
||||
final data = responseData;
|
||||
if (data.containsKey(itemsKey)) {
|
||||
// Paginated response: { items: [...], total: N }
|
||||
itemsJson = data[itemsKey] as List<dynamic>;
|
||||
totalCount = data[totalCountKey] as int? ?? itemsJson.length;
|
||||
} else {
|
||||
// Try common wrapper patterns: { data: [...] } or { results: [...] }
|
||||
itemsJson = (data['data'] ?? data['results'] ?? []) as List<dynamic>;
|
||||
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<dynamic>;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user