feat: implement NPM Proxy Hosts section with reusable entity patterns

Add full Proxy Hosts feature for Control Room:
- Data layer: datasource, model with custom JSON converters for NPM API quirks
- Domain layer: ProxyHost entity with SSL, caching, websocket support
- Presentation: list page, detail/edit page, form widget

Add reusable entity page patterns for Control Room sections:
- EntityPageScaffold, EntitySection, EntitySettingRow components
- EntityForm with create/view/edit mode support
- EntityPageModeMixin for consistent mode management
- CreateOnlyField for immutable-after-creation fields

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-01-01 15:02:06 +01:00
co-authored by Claude Opus 4.5
parent 67d2f777c8
commit 2adb50b352
11 changed files with 1839 additions and 1 deletions
-1
View File
@@ -12,7 +12,6 @@ pubspec.lock
*.freezed.dart
*.gr.dart
*.mocks.dart
lib/generated_plugin_registrant.dart
# Keep version.g.dart - it's generated but should be committed
# so CI/CD builds have version info without running the generator
@@ -0,0 +1,69 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/core/api/api_client.dart';
import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart';
part 'proxy_hosts_datasource.g.dart';
/// Remote data source for NPM proxy host operations via Core API.
class ProxyHostsDatasource {
ProxyHostsDatasource(this._dio);
final Dio _dio;
/// Gets all configured domains from Core API.
Future<List<DomainInfoModel>> getDomains() async {
final response = await _dio.get<List<dynamic>>(
'/infrastructure/domains',
);
return response.data!
.map((json) => DomainInfoModel.fromJson(json as Map<String, dynamic>))
.toList();
}
/// Gets detailed proxy host configuration by ID.
Future<ProxyHostModel> getProxyHost(int proxyId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/infrastructure/proxy/$proxyId',
);
return ProxyHostModel.fromJson(response.data!);
}
/// Creates a new proxy host.
Future<void> createProxyHost({
required List<String> domainNames,
required String forwardScheme,
required String forwardHost,
required int forwardPort,
bool sslEnabled = false,
}) async {
await _dio.post<void>(
'/infrastructure/proxy',
data: {
'domain_names': domainNames,
'forward_scheme': forwardScheme,
'forward_host': forwardHost,
'forward_port': forwardPort,
'ssl_enabled': sslEnabled,
},
);
}
/// Updates an existing proxy host.
Future<void> updateProxyHost(int proxyId, Map<String, dynamic> config) async {
await _dio.put<void>(
'/infrastructure/proxy/$proxyId',
data: config,
);
}
}
/// Provides the proxy hosts datasource.
@riverpod
ProxyHostsDatasource proxyHostsDatasource(Ref ref) {
final dio = ref.watch(coreApiClientProvider);
return ProxyHostsDatasource(dio);
}
@@ -0,0 +1,137 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
part 'proxy_host_model.freezed.dart';
part 'proxy_host_model.g.dart';
/// Converts API values that can be either int or bool (false) to int?.
/// NPM returns false instead of null for missing IDs.
class NullableIntOrBoolConverter implements JsonConverter<int?, dynamic> {
const NullableIntOrBoolConverter();
@override
int? fromJson(dynamic json) {
if (json == null || json == false) return null;
if (json is int) return json;
if (json is num) return json.toInt();
return null;
}
@override
dynamic toJson(int? object) => object;
}
/// Converts API values that can be either int or bool to int.
/// NPM returns true/false for some 0/1 fields.
class IntOrBoolConverter implements JsonConverter<int, dynamic> {
const IntOrBoolConverter();
@override
int fromJson(dynamic json) {
if (json == null) return 0;
if (json is bool) return json ? 1 : 0;
if (json is int) return json;
if (json is num) return json.toInt();
return 0;
}
@override
dynamic toJson(int object) => object;
}
/// Proxy host data model for API serialization.
///
/// Maps to the NPM API response format via Core API.
@freezed
class ProxyHostModel with _$ProxyHostModel {
const factory ProxyHostModel({
required int id,
@JsonKey(name: 'domain_names') required List<String> domainNames,
@JsonKey(name: 'forward_scheme') required String forwardScheme,
@JsonKey(name: 'forward_host') required String forwardHost,
@JsonKey(name: 'forward_port') required int forwardPort,
@JsonKey(name: 'ssl_forced') @Default(false) bool sslForced,
@NullableIntOrBoolConverter() @JsonKey(name: 'certificate_id') int? certificateId,
@IntOrBoolConverter() @Default(1) int enabled,
@IntOrBoolConverter() @JsonKey(name: 'http2_support') @Default(0) int http2Support,
@IntOrBoolConverter() @JsonKey(name: 'hsts_enabled') @Default(0) int hstsEnabled,
@NullableIntOrBoolConverter() @JsonKey(name: 'access_list_id') int? accessListId,
@IntOrBoolConverter() @JsonKey(name: 'caching_enabled') @Default(0) int cachingEnabled,
@IntOrBoolConverter() @JsonKey(name: 'block_exploits') @Default(0) int blockExploits,
@IntOrBoolConverter() @JsonKey(name: 'allow_websocket_upgrade') @Default(0) int allowWebsocketUpgrade,
@JsonKey(name: 'created_on') String? createdOn,
@JsonKey(name: 'modified_on') String? modifiedOn,
@Default([]) List<ProxyLocationModel> locations,
}) = _ProxyHostModel;
const ProxyHostModel._();
factory ProxyHostModel.fromJson(Map<String, dynamic> json) =>
_$ProxyHostModelFromJson(json);
/// Converts to domain entity.
ProxyHost toEntity() {
return ProxyHost(
id: id,
domainNames: domainNames,
forwardScheme: forwardScheme,
forwardHost: forwardHost,
forwardPort: forwardPort,
sslEnabled: certificateId != null && certificateId! > 0,
certificateId: certificateId,
enabled: enabled == 1,
http2Support: http2Support == 1,
hstsEnabled: hstsEnabled == 1,
forceSSL: sslForced,
accessListId: accessListId,
cacheAssets: cachingEnabled == 1,
blockExploits: blockExploits == 1,
websocketSupport: allowWebsocketUpgrade == 1,
locations: locations.map((l) => l.toEntity()).toList(),
createdAt: createdOn != null ? DateTime.tryParse(createdOn!) : null,
modifiedAt: modifiedOn != null ? DateTime.tryParse(modifiedOn!) : null,
);
}
}
/// Proxy location model.
@freezed
class ProxyLocationModel with _$ProxyLocationModel {
const factory ProxyLocationModel({
required String path,
@JsonKey(name: 'forward_scheme') required String forwardScheme,
@JsonKey(name: 'forward_host') required String forwardHost,
@JsonKey(name: 'forward_port') required int forwardPort,
}) = _ProxyLocationModel;
const ProxyLocationModel._();
factory ProxyLocationModel.fromJson(Map<String, dynamic> json) =>
_$ProxyLocationModelFromJson(json);
ProxyLocation toEntity() {
return ProxyLocation(
path: path,
forwardScheme: forwardScheme,
forwardHost: forwardHost,
forwardPort: forwardPort,
);
}
}
/// Domain info model for the /infrastructure/domains endpoint.
///
/// This is a simpler model used for listing domains.
@freezed
class DomainInfoModel with _$DomainInfoModel {
const factory DomainInfoModel({
required String domain,
required String service,
@JsonKey(name: 'proxy_host_id') required int proxyHostId,
@JsonKey(name: 'ssl_enabled') @Default(false) bool sslEnabled,
@JsonKey(name: 'certificate_id') int? certificateId,
}) = _DomainInfoModel;
factory DomainInfoModel.fromJson(Map<String, dynamic> json) =>
_$DomainInfoModelFromJson(json);
}
@@ -0,0 +1,93 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'proxy_host.freezed.dart';
/// NPM proxy host entity representing a domain configuration.
@freezed
class ProxyHost with _$ProxyHost {
const factory ProxyHost({
/// Proxy host ID.
required int id,
/// Domain names (can have multiple).
required List<String> domainNames,
/// Forward scheme (http or https).
required String forwardScheme,
/// Forward host (IP or hostname).
required String forwardHost,
/// Forward port.
required int forwardPort,
/// Whether SSL is enabled.
required bool sslEnabled,
/// SSL certificate ID (if enabled).
int? certificateId,
/// Whether the host is enabled.
@Default(true) bool enabled,
/// Whether HTTP/2 is enabled.
@Default(false) bool http2Support,
/// Whether HSTS is enabled.
@Default(false) bool hstsEnabled,
/// Whether to force SSL.
@Default(false) bool forceSSL,
/// Custom locations (advanced nginx config).
@Default([]) List<ProxyLocation> locations,
/// Access list ID (for auth).
int? accessListId,
/// Cache assets enabled.
@Default(false) bool cacheAssets,
/// Block common exploits.
@Default(false) bool blockExploits,
/// Websocket support.
@Default(false) bool websocketSupport,
/// Created timestamp.
DateTime? createdAt,
/// Modified timestamp.
DateTime? modifiedAt,
}) = _ProxyHost;
const ProxyHost._();
/// Primary domain (first in list).
String get primaryDomain =>
domainNames.isNotEmpty ? domainNames.first : 'Unknown';
/// Forward URL (scheme://host:port).
String get forwardUrl => '$forwardScheme://$forwardHost:$forwardPort';
/// SSL status label.
String get sslStatus => sslEnabled ? 'SSL Enabled' : 'No SSL';
}
/// Proxy location for advanced routing.
@freezed
class ProxyLocation with _$ProxyLocation {
const factory ProxyLocation({
/// Location path.
required String path,
/// Forward scheme.
required String forwardScheme,
/// Forward host.
required String forwardHost,
/// Forward port.
required int forwardPort,
}) = _ProxyLocation;
}
@@ -0,0 +1,297 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/widgets/proxy_host_form.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// Unified page for proxy host create, view, and edit.
///
/// Usage:
/// - Create: `ProxyHostPage.create(onClose: ...)`
/// - View/Edit: `ProxyHostPage(proxyHostId: 123, onClose: ...)`
class ProxyHostPage extends ConsumerStatefulWidget {
const ProxyHostPage({
super.key,
required this.proxyHostId,
required this.onClose,
}) : _isCreate = false;
const ProxyHostPage.create({
super.key,
required this.onClose,
}) : proxyHostId = null,
_isCreate = true;
final int? proxyHostId;
final VoidCallback onClose;
final bool _isCreate;
@override
ConsumerState<ProxyHostPage> createState() => _ProxyHostPageState();
}
class _ProxyHostPageState extends ConsumerState<ProxyHostPage>
with EntityPageModeMixin {
@override
void initState() {
super.initState();
// Start in create mode if no ID, otherwise view mode
mode = widget._isCreate ? EntityPageMode.create : EntityPageMode.view;
}
String get _title {
switch (mode) {
case EntityPageMode.create:
return 'New Proxy Host';
case EntityPageMode.view:
return 'Proxy Host Details';
case EntityPageMode.edit:
return 'Edit Proxy Host';
}
}
void _handleSaved() {
if (widget._isCreate) {
// After create, close and return to list
widget.onClose();
} else {
// After edit, return to view mode and refresh
stopEditing();
ref.invalidate(proxyHostProvider(widget.proxyHostId!));
}
ref.invalidate(domainsProvider);
}
@override
Widget build(BuildContext context) {
// Create mode - no need to fetch existing data
if (widget._isCreate) {
return EntityPageScaffold(
title: _title,
onBack: widget.onClose,
child: ProxyHostForm(
mode: EntityPageMode.create,
onCancel: widget.onClose,
onSaved: _handleSaved,
),
);
}
// View/Edit mode - fetch existing proxy host
final proxyHostAsync = ref.watch(proxyHostProvider(widget.proxyHostId!));
return EntityPageScaffold(
title: _title,
onBack: widget.onClose,
actions: [
if (isViewing)
IconButton(
icon: const Icon(Icons.edit),
tooltip: 'Edit',
onPressed: startEditing,
),
IconButton(
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
onPressed: () =>
ref.invalidate(proxyHostProvider(widget.proxyHostId!)),
),
],
child: EntityAsyncContent<ProxyHost>(
isLoading: proxyHostAsync.isLoading,
error: proxyHostAsync.error,
data: proxyHostAsync.valueOrNull,
onRetry: () => ref.invalidate(proxyHostProvider(widget.proxyHostId!)),
builder: (proxyHost) {
if (isEditing) {
return ProxyHostForm(
mode: EntityPageMode.edit,
proxyHost: proxyHost,
onCancel: stopEditing,
onSaved: _handleSaved,
);
}
return _ProxyHostView(proxyHost: proxyHost);
},
),
);
}
}
/// Read-only view of proxy host details.
class _ProxyHostView extends StatelessWidget {
const _ProxyHostView({required this.proxyHost});
final ProxyHost proxyHost;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Domain names
EntitySection(
title: 'Domain Names',
icon: Icons.public,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: proxyHost.domainNames.map((domain) {
return Chip(
avatar: Icon(
proxyHost.sslEnabled ? Icons.lock : Icons.lock_open,
size: 16,
color: proxyHost.sslEnabled ? Colors.green : null,
),
label: Text(domain),
);
}).toList(),
),
),
const SizedBox(height: 24),
// Forward destination
EntitySection(
title: 'Forward Destination',
icon: Icons.arrow_forward,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Icon(Icons.dns,
color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
proxyHost.forwardUrl,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontFamily: 'monospace'),
),
const SizedBox(height: 4),
Text(
'${proxyHost.forwardScheme.toUpperCase()}${proxyHost.forwardHost}:${proxyHost.forwardPort}',
style: TextStyle(
color: Theme.of(context).colorScheme.outline),
),
],
),
),
],
),
),
),
),
const SizedBox(height: 24),
// SSL Settings
EntitySection(
title: 'SSL Settings',
icon: Icons.verified_user,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
EntitySettingRow(
label: 'SSL Enabled',
value: proxyHost.sslEnabled,
),
if (proxyHost.sslEnabled) ...[
const Divider(),
EntitySettingRow(
label: 'Force SSL',
value: proxyHost.forceSSL,
subtitle: 'Redirect HTTP to HTTPS',
),
const Divider(),
EntitySettingRow(
label: 'HTTP/2 Support',
value: proxyHost.http2Support,
),
const Divider(),
EntitySettingRow(
label: 'HSTS Enabled',
value: proxyHost.hstsEnabled,
),
],
],
),
),
),
),
const SizedBox(height: 24),
// Advanced Settings
EntitySection(
title: 'Advanced Settings',
icon: Icons.settings,
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
EntitySettingRow(
label: 'WebSocket Support',
value: proxyHost.websocketSupport,
),
const Divider(),
EntitySettingRow(
label: 'Block Exploits',
value: proxyHost.blockExploits,
),
const Divider(),
EntitySettingRow(
label: 'Cache Assets',
value: proxyHost.cacheAssets,
),
const Divider(),
EntitySettingRow(
label: 'Enabled',
value: proxyHost.enabled,
),
],
),
),
),
),
// Locations (if any)
if (proxyHost.locations.isNotEmpty) ...[
const SizedBox(height: 24),
EntitySection(
title: 'Custom Locations',
icon: Icons.route,
child: Card(
child: ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: proxyHost.locations.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final loc = proxyHost.locations[index];
return ListTile(
leading: const Icon(Icons.subdirectory_arrow_right),
title: Text(loc.path,
style: const TextStyle(fontFamily: 'monospace')),
subtitle: Text(
'${loc.forwardScheme}://${loc.forwardHost}:${loc.forwardPort}'),
);
},
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,316 @@
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/features/control_room/npm/presentation/pages/proxy_host_page.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
/// Page displaying the list of proxy hosts (domains) from NPM.
class ProxyHostsPage extends ConsumerWidget {
const ProxyHostsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final domainsAsync = ref.watch(domainsProvider);
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(),
);
}
// Show detail page if a proxy host is selected
if (selectedId != null) {
return ProxyHostPage(
proxyHostId: selectedId,
onClose: () => ref.read(selectedProxyHostProvider.notifier).clear(),
);
}
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'),
),
],
),
),
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),
);
},
),
),
],
);
}
}
class _DomainListTile extends StatelessWidget {
const _DomainListTile({required this.domain, required this.onTap});
final DomainInfoModel domain;
final VoidCallback onTap;
@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,
),
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:tatlock_ui/features/control_room/npm/data/datasources/proxy_hosts_datasource.dart';
import 'package:tatlock_ui/features/control_room/npm/data/models/proxy_host_model.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
part 'proxy_hosts_provider.g.dart';
/// Provider for the list of configured domains.
@riverpod
Future<List<DomainInfoModel>> domains(Ref ref) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
return datasource.getDomains();
}
/// Provider for a specific proxy host details.
@riverpod
Future<ProxyHost> proxyHost(Ref ref, int proxyId) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
final model = await datasource.getProxyHost(proxyId);
return model.toEntity();
}
/// Provider for selected proxy host ID (for detail view).
@riverpod
class SelectedProxyHost extends _$SelectedProxyHost {
@override
int? build() => null;
void select(int id) => state = id;
void clear() => state = null;
}
/// Provider for tracking if we're creating a new proxy host.
@riverpod
class CreatingProxyHost extends _$CreatingProxyHost {
@override
bool build() => false;
void start() => state = true;
void stop() => state = false;
}
/// Provider for creating a new proxy host.
@riverpod
Future<void> createProxyHost(
Ref ref, {
required List<String> domainNames,
required String forwardScheme,
required String forwardHost,
required int forwardPort,
bool sslEnabled = false,
}) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
await datasource.createProxyHost(
domainNames: domainNames,
forwardScheme: forwardScheme,
forwardHost: forwardHost,
forwardPort: forwardPort,
sslEnabled: sslEnabled,
);
}
/// Provider for updating an existing proxy host.
@riverpod
Future<void> updateProxyHost(
Ref ref, {
required int id,
required Map<String, dynamic> config,
}) async {
final datasource = ref.watch(proxyHostsDatasourceProvider);
await datasource.updateProxyHost(id, config);
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/npm/domain/entities/proxy_host.dart';
import 'package:tatlock_ui/features/control_room/npm/presentation/providers/proxy_hosts_provider.dart';
import 'package:tatlock_ui/shared/widgets/entity_form_dialog.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// Form for creating or editing a proxy host.
class ProxyHostForm extends ConsumerStatefulWidget {
const ProxyHostForm({
super.key,
this.proxyHost,
this.mode = EntityPageMode.create,
required this.onCancel,
required this.onSaved,
});
/// Existing proxy host for editing (null for create).
final ProxyHost? proxyHost;
/// Form mode - create or edit.
final EntityPageMode mode;
final VoidCallback onCancel;
final VoidCallback onSaved;
bool get isEditing => mode == EntityPageMode.edit;
@override
ConsumerState<ProxyHostForm> createState() => _ProxyHostFormState();
}
class _ProxyHostFormState extends ConsumerState<ProxyHostForm> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _domainController;
late final TextEditingController _forwardHostController;
late final TextEditingController _forwardPortController;
late String _forwardScheme;
late bool _forceSSL;
late bool _http2Support;
late bool _websocketSupport;
late bool _blockExploits;
late bool _cacheAssets;
bool _isSaving = false;
String? _error;
@override
void initState() {
super.initState();
final host = widget.proxyHost;
_domainController = TextEditingController(
text: host?.domainNames.join(', ') ?? '',
);
_forwardHostController = TextEditingController(
text: host?.forwardHost ?? '',
);
_forwardPortController = TextEditingController(
text: host?.forwardPort.toString() ?? '80',
);
_forwardScheme = host?.forwardScheme ?? 'http';
_forceSSL = host?.forceSSL ?? false;
_http2Support = host?.http2Support ?? false;
_websocketSupport = host?.websocketSupport ?? false;
_blockExploits = host?.blockExploits ?? true;
_cacheAssets = host?.cacheAssets ?? false;
}
@override
void dispose() {
_domainController.dispose();
_forwardHostController.dispose();
_forwardPortController.dispose();
super.dispose();
}
Future<void> _handleSave() async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_isSaving = true;
_error = null;
});
try {
final domains = _domainController.text
.split(',')
.map((d) => d.trim())
.where((d) => d.isNotEmpty)
.toList();
if (widget.isEditing) {
await ref.read(updateProxyHostProvider(
id: widget.proxyHost!.id,
config: {
'domain_names': domains,
'forward_scheme': _forwardScheme,
'forward_host': _forwardHostController.text,
'forward_port': int.parse(_forwardPortController.text),
'ssl_forced': _forceSSL,
'http2_support': _http2Support ? 1 : 0,
'allow_websocket_upgrade': _websocketSupport ? 1 : 0,
'block_exploits': _blockExploits ? 1 : 0,
'caching_enabled': _cacheAssets ? 1 : 0,
},
).future);
} else {
await ref.read(createProxyHostProvider(
domainNames: domains,
forwardScheme: _forwardScheme,
forwardHost: _forwardHostController.text,
forwardPort: int.parse(_forwardPortController.text),
).future);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.isEditing
? 'Proxy host updated successfully'
: 'Proxy host created successfully'),
backgroundColor: Colors.green,
),
);
widget.onSaved();
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) {
setState(() => _isSaving = false);
}
}
}
@override
Widget build(BuildContext context) {
return EntityForm(
formKey: _formKey,
mode: widget.mode,
onCancel: widget.onCancel,
onSave: _handleSave,
isSaving: _isSaving,
error: _error,
children: [
// Domain Names
Text('Domain Names', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
TextFormField(
controller: _domainController,
decoration: const InputDecoration(
hintText: 'example.com, www.example.com',
helperText: 'Separate multiple domains with commas',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'At least one domain is required';
}
return null;
},
),
const SizedBox(height: 24),
// Forward Destination
Text('Forward Destination', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Scheme dropdown
SizedBox(
width: 120,
child: DropdownButtonFormField<String>(
initialValue: _forwardScheme,
decoration: const InputDecoration(
labelText: 'Scheme',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(value: 'http', child: Text('HTTP')),
DropdownMenuItem(value: 'https', child: Text('HTTPS')),
],
onChanged: (value) {
if (value != null) setState(() => _forwardScheme = value);
},
),
),
const SizedBox(width: 12),
// Host
Expanded(
flex: 2,
child: TextFormField(
controller: _forwardHostController,
decoration: const InputDecoration(
labelText: 'Host',
hintText: '192.168.1.100 or hostname',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Host is required';
}
return null;
},
),
),
const SizedBox(width: 12),
// Port
SizedBox(
width: 100,
child: TextFormField(
controller: _forwardPortController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Required';
}
final port = int.tryParse(value);
if (port == null || port < 1 || port > 65535) {
return 'Invalid';
}
return null;
},
),
),
],
),
const SizedBox(height: 24),
// SSL & Security Options
Text('SSL & Security', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('Force SSL'),
subtitle: const Text('Redirect HTTP to HTTPS'),
value: _forceSSL,
onChanged: (v) => setState(() => _forceSSL = v),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('HTTP/2 Support'),
value: _http2Support,
onChanged: (v) => setState(() => _http2Support = v),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Block Common Exploits'),
value: _blockExploits,
onChanged: (v) => setState(() => _blockExploits = v),
),
],
),
),
const SizedBox(height: 24),
// Advanced Options
Text('Advanced', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Column(
children: [
SwitchListTile(
title: const Text('WebSocket Support'),
value: _websocketSupport,
onChanged: (v) => setState(() => _websocketSupport = v),
),
const Divider(height: 1),
SwitchListTile(
title: const Text('Cache Assets'),
value: _cacheAssets,
onChanged: (v) => setState(() => _cacheAssets = v),
),
],
),
),
],
);
}
}
@@ -2,6 +2,7 @@ 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';
@@ -71,6 +72,8 @@ class _SectionContent extends ConsumerWidget {
switch (nav) {
case ControlRoomNav.containers:
return const _ContainersSection();
case ControlRoomNav.proxyHosts:
return const ProxyHostsPage();
default:
return _PlaceholderSection(nav: nav);
}
+288
View File
@@ -0,0 +1,288 @@
import 'package:flutter/material.dart';
import 'package:tatlock_ui/shared/widgets/entity_page.dart';
/// A reusable dialog wrapper for entity create/edit forms.
///
/// Provides consistent styling across all Control Room sections:
/// - Constrained max width/height
/// - AppBar with title and close button
/// - Scrollable content area
///
/// Usage:
/// ```dart
/// showEntityFormDialog(
/// context: context,
/// title: 'New Proxy Host',
/// child: ProxyHostForm(
/// onCancel: () => Navigator.of(context).pop(),
/// onSaved: () {
/// Navigator.of(context).pop();
/// ref.invalidate(domainsProvider);
/// },
/// ),
/// );
/// ```
void showEntityFormDialog({
required BuildContext context,
required String title,
required Widget child,
double maxWidth = 600,
double maxHeight = 700,
}) {
showDialog(
context: context,
builder: (context) => EntityFormDialog(
title: title,
maxWidth: maxWidth,
maxHeight: maxHeight,
child: child,
),
);
}
/// Dialog widget for entity forms.
class EntityFormDialog extends StatelessWidget {
const EntityFormDialog({
super.key,
required this.title,
required this.child,
this.maxWidth = 600,
this.maxHeight = 700,
});
final String title;
final Widget child;
final double maxWidth;
final double maxHeight;
@override
Widget build(BuildContext context) {
return Dialog(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: maxWidth,
maxHeight: maxHeight,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppBar(
title: Text(title),
automaticallyImplyLeading: false,
actions: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
Flexible(child: child),
],
),
),
);
}
}
/// A standardized form wrapper with common patterns.
///
/// Handles:
/// - Error display banner
/// - Loading state on save button
/// - Cancel/Save button row
/// - Form key management
/// - Create vs Edit mode awareness
///
/// Use [mode] to indicate whether this is a create or edit form.
/// Child widgets can use [EntityFormScope.of(context)] to check the mode
/// and disable fields that should only be editable during creation.
class EntityForm extends StatelessWidget {
const EntityForm({
super.key,
required this.formKey,
required this.onCancel,
required this.onSave,
required this.isSaving,
required this.children,
this.mode = EntityPageMode.create,
this.error,
this.saveLabel,
this.cancelLabel = 'Cancel',
});
final GlobalKey<FormState> formKey;
final VoidCallback onCancel;
final VoidCallback onSave;
final bool isSaving;
final List<Widget> children;
final EntityPageMode mode;
final String? error;
final String? saveLabel;
final String cancelLabel;
bool get isCreating => mode == EntityPageMode.create;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final effectiveSaveLabel = saveLabel ?? (isCreating ? 'Create' : 'Save');
return EntityFormScope(
mode: mode,
child: Form(
key: formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Error banner
if (error != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(Icons.error, color: colorScheme.onErrorContainer),
const SizedBox(width: 8),
Expanded(
child: Text(
error!,
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
],
),
),
// Form fields
...children,
const SizedBox(height: 32),
// Action buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
OutlinedButton(
onPressed: isSaving ? null : onCancel,
child: Text(cancelLabel),
),
const SizedBox(width: 12),
FilledButton.icon(
onPressed: isSaving ? null : onSave,
icon: isSaving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: Text(effectiveSaveLabel),
),
],
),
],
),
),
),
);
}
}
/// InheritedWidget to provide form mode to descendants.
///
/// Allows form fields to check if they're in create or edit mode
/// and adjust their behavior accordingly (e.g., disable create-only fields).
class EntityFormScope extends InheritedWidget {
const EntityFormScope({
super.key,
required this.mode,
required super.child,
});
final EntityPageMode mode;
bool get isCreating => mode == EntityPageMode.create;
bool get isEditing => mode == EntityPageMode.edit;
static EntityFormScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<EntityFormScope>();
}
static EntityFormScope of(BuildContext context) {
final scope = maybeOf(context);
assert(scope != null, 'No EntityFormScope found in context');
return scope!;
}
@override
bool updateShouldNotify(EntityFormScope oldWidget) => mode != oldWidget.mode;
}
/// A form field wrapper that can be marked as create-only.
///
/// When [createOnly] is true, the field will be disabled in edit mode.
/// Shows a lock icon and tooltip to indicate the field is immutable.
///
/// Usage:
/// ```dart
/// CreateOnlyField(
/// createOnly: true,
/// child: TextFormField(
/// controller: _nameController,
/// decoration: InputDecoration(labelText: 'Name'),
/// ),
/// )
/// ```
class CreateOnlyField extends StatelessWidget {
const CreateOnlyField({
super.key,
required this.child,
this.createOnly = true,
this.disabledHint = 'This field cannot be changed after creation',
});
final Widget child;
final bool createOnly;
final String disabledHint;
@override
Widget build(BuildContext context) {
final scope = EntityFormScope.maybeOf(context);
final isEditing = scope?.isEditing ?? false;
final shouldDisable = createOnly && isEditing;
if (!shouldDisable) {
return child;
}
return Tooltip(
message: disabledHint,
child: AbsorbPointer(
absorbing: true,
child: Opacity(
opacity: 0.6,
child: Stack(
children: [
child,
Positioned(
right: 8,
top: 8,
child: Icon(
Icons.lock,
size: 16,
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
),
);
}
}
+272
View File
@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
/// Reusable scaffold for entity pages (create, view, edit).
///
/// Provides consistent layout across all Control Room entity pages:
/// - AppBar with back button, title, and customizable actions
/// - Loading, error, and content states
/// - Consistent padding and styling
///
/// Usage:
/// ```dart
/// EntityPageScaffold(
/// title: 'Proxy Host Details',
/// onBack: () => Navigator.pop(context),
/// actions: [
/// IconButton(icon: Icon(Icons.edit), onPressed: onEdit),
/// ],
/// child: MyContent(),
/// )
/// ```
class EntityPageScaffold extends StatelessWidget {
const EntityPageScaffold({
super.key,
required this.title,
required this.child,
this.onBack,
this.actions,
this.floatingActionButton,
});
final String title;
final Widget child;
final VoidCallback? onBack;
final List<Widget>? actions;
final Widget? floatingActionButton;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: onBack != null
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
)
: null,
title: Text(title),
actions: actions,
),
body: child,
floatingActionButton: floatingActionButton,
);
}
}
/// Async content wrapper with loading, error, and data states.
///
/// Use with Riverpod AsyncValue for consistent loading/error handling.
class EntityAsyncContent<T> extends StatelessWidget {
const EntityAsyncContent({
super.key,
required this.isLoading,
required this.error,
required this.data,
required this.onRetry,
required this.builder,
});
final bool isLoading;
final Object? error;
final T? data;
final VoidCallback onRetry;
final Widget Function(T data) builder;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (error != null) {
return 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'),
const SizedBox(height: 8),
Text(
error.toString(),
style: TextStyle(color: colorScheme.outline),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
);
}
if (data != null) {
return builder(data as T);
}
return const SizedBox.shrink();
}
}
/// Section header for entity detail pages.
///
/// Consistent styling for grouping related fields.
class EntitySection extends StatelessWidget {
const EntitySection({
super.key,
required this.title,
required this.icon,
required this.child,
this.trailing,
});
final String title;
final IconData icon;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 8),
child,
],
);
}
}
/// Row displaying a boolean setting with label and indicator.
class EntitySettingRow extends StatelessWidget {
const EntitySettingRow({
super.key,
required this.label,
required this.value,
this.subtitle,
});
final String label;
final bool value;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label),
if (subtitle != null)
Text(
subtitle!,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.outline,
),
),
],
),
),
Icon(
value ? Icons.check_circle : Icons.cancel,
color: value ? Colors.green : Colors.grey,
size: 20,
),
],
);
}
}
/// Row displaying a key-value pair.
class EntityInfoRow extends StatelessWidget {
const EntityInfoRow({
super.key,
required this.label,
required this.value,
this.monospace = false,
});
final String label;
final String value;
final bool monospace;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(color: colorScheme.outline),
),
),
Expanded(
child: Text(
value,
style: monospace
? const TextStyle(fontFamily: 'monospace')
: null,
),
),
],
);
}
}
/// Enum for entity page modes.
enum EntityPageMode {
create,
view,
edit,
}
/// Mixin for pages that support view/edit mode toggle.
///
/// Provides standard mode management for entity detail pages.
mixin EntityPageModeMixin<T extends StatefulWidget> on State<T> {
EntityPageMode _mode = EntityPageMode.view;
EntityPageMode get mode => _mode;
set mode(EntityPageMode value) => _mode = value;
bool get isViewing => _mode == EntityPageMode.view;
bool get isEditing => _mode == EntityPageMode.edit;
bool get isCreating => _mode == EntityPageMode.create;
void setMode(EntityPageMode newMode) {
setState(() => _mode = newMode);
}
void startEditing() => setMode(EntityPageMode.edit);
void stopEditing() => setMode(EntityPageMode.view);
}