feat: add stack detail view with YAML and env vars editor
When a stack is selected, the right pane now shows a split view: - Top: Stack editor with tabs for Compose YAML and Environment Variables - Bottom: Docked compact container list for the selected stack Includes Deploy button to redeploy stack with updated configuration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
19f81c8749
commit
5c09bac3d2
@@ -6,6 +6,7 @@ import '../../../../core/config/app_config.dart';
|
||||
import '../../containers/presentation/pages/containers_list_page.dart';
|
||||
import '../../stacks/data/repositories/stack_repository_impl.dart';
|
||||
import '../../stacks/domain/entities/stack.dart';
|
||||
import '../../stacks/presentation/pages/stack_detail_page.dart';
|
||||
import '../../stacks/presentation/providers/stacks_provider.dart';
|
||||
import '../../stacks/presentation/widgets/stack_list_tile.dart';
|
||||
|
||||
@@ -15,6 +16,8 @@ class ControlRoomPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedStack = ref.watch(selectedStackProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
@@ -26,9 +29,11 @@ class ControlRoomPage extends ConsumerWidget {
|
||||
thickness: 1,
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
// Main content area
|
||||
const Expanded(
|
||||
child: ContainersListPage(),
|
||||
// Main content area - shows either all containers or stack detail
|
||||
Expanded(
|
||||
child: selectedStack == null
|
||||
? const ContainersListPage()
|
||||
: StackDetailPage(stackId: selectedStack),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import '../../../../../core/api/api_client.dart';
|
||||
import '../../../containers/data/datasources/containers_datasource.dart';
|
||||
import '../../../containers/domain/entities/container.dart';
|
||||
import '../../domain/entities/stack.dart';
|
||||
@@ -9,11 +11,11 @@ part 'stacks_datasource.g.dart';
|
||||
/// Data source for stack operations.
|
||||
///
|
||||
/// Stacks are derived from container labels (Docker Compose convention).
|
||||
/// The Core API doesn't have a dedicated stacks endpoint, so we aggregate
|
||||
/// from containers.
|
||||
/// The Core API provides endpoints for stack YAML and environment variables.
|
||||
class StacksDatasource {
|
||||
StacksDatasource(this._containersDatasource);
|
||||
StacksDatasource(this._dio, this._containersDatasource);
|
||||
|
||||
final Dio _dio;
|
||||
final ContainersDatasource _containersDatasource;
|
||||
|
||||
/// Gets all stacks by aggregating container data.
|
||||
@@ -74,11 +76,53 @@ class StacksDatasource {
|
||||
await _containersDatasource.containerAction(container.fullId, action);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the compose YAML for a stack.
|
||||
Future<String> getStackYaml(String stackId) async {
|
||||
final response = await _dio.get<String>(
|
||||
'/infrastructure/stacks/$stackId/compose',
|
||||
);
|
||||
return response.data ?? '';
|
||||
}
|
||||
|
||||
/// Updates the compose YAML for a stack.
|
||||
Future<void> updateStackYaml(String stackId, String yaml) async {
|
||||
await _dio.put<void>(
|
||||
'/infrastructure/stacks/$stackId/compose',
|
||||
data: yaml,
|
||||
options: Options(contentType: 'text/yaml'),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gets the environment variables for a stack.
|
||||
Future<Map<String, String>> getStackEnvVars(String stackId) async {
|
||||
final response = await _dio.get<Map<String, dynamic>>(
|
||||
'/infrastructure/stacks/$stackId/env',
|
||||
);
|
||||
return response.data?.map((k, v) => MapEntry(k, v.toString())) ?? {};
|
||||
}
|
||||
|
||||
/// Updates the environment variables for a stack.
|
||||
Future<void> updateStackEnvVars(
|
||||
String stackId,
|
||||
Map<String, String> envVars,
|
||||
) async {
|
||||
await _dio.put<void>(
|
||||
'/infrastructure/stacks/$stackId/env',
|
||||
data: envVars,
|
||||
);
|
||||
}
|
||||
|
||||
/// Deploys/redeploys a stack with current configuration.
|
||||
Future<void> deployStack(String stackId) async {
|
||||
await _dio.post<void>('/infrastructure/stacks/$stackId/deploy');
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the stacks datasource.
|
||||
@riverpod
|
||||
StacksDatasource stacksDatasource(StacksDatasourceRef ref) {
|
||||
final dio = ref.watch(coreApiClientProvider);
|
||||
final containersDatasource = ref.watch(containersDatasourceProvider);
|
||||
return StacksDatasource(containersDatasource);
|
||||
return StacksDatasource(dio, containersDatasource);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,31 @@ class StackRepositoryImpl implements StackRepository {
|
||||
// TODO: Implement stack removal
|
||||
throw UnimplementedError('Stack removal not yet implemented');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getStackYaml(String id) async {
|
||||
return _datasource.getStackYaml(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStackYaml(String id, String yaml) async {
|
||||
await _datasource.updateStackYaml(id, yaml);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, String>> getStackEnvVars(String id) async {
|
||||
return _datasource.getStackEnvVars(id);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateStackEnvVars(String id, Map<String, String> envVars) async {
|
||||
await _datasource.updateStackEnvVars(id, envVars);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deployStack(String id) async {
|
||||
await _datasource.deployStack(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the stack repository.
|
||||
|
||||
@@ -27,6 +27,12 @@ class Stack with _$Stack {
|
||||
/// Path to the compose file (if applicable).
|
||||
String? composeFile,
|
||||
|
||||
/// Compose YAML content.
|
||||
String? yaml,
|
||||
|
||||
/// Environment variables for the stack.
|
||||
@Default({}) Map<String, String> envVars,
|
||||
|
||||
/// Environment name (e.g., production, staging).
|
||||
String? environment,
|
||||
|
||||
|
||||
@@ -19,4 +19,19 @@ abstract class StackRepository {
|
||||
|
||||
/// Removes a stack (stops and removes containers).
|
||||
Future<void> removeStack(String id);
|
||||
|
||||
/// Gets the compose YAML for a stack.
|
||||
Future<String> getStackYaml(String id);
|
||||
|
||||
/// Updates the compose YAML for a stack.
|
||||
Future<void> updateStackYaml(String id, String yaml);
|
||||
|
||||
/// Gets the environment variables for a stack.
|
||||
Future<Map<String, String>> getStackEnvVars(String id);
|
||||
|
||||
/// Updates the environment variables for a stack.
|
||||
Future<void> updateStackEnvVars(String id, Map<String, String> envVars);
|
||||
|
||||
/// Deploys/redeploys a stack with current configuration.
|
||||
Future<void> deployStack(String id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import 'package:flutter/material.dart' hide Stack;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../containers/presentation/providers/containers_provider.dart';
|
||||
import '../../../containers/presentation/widgets/container_logs_viewer.dart';
|
||||
import '../../../containers/presentation/widgets/container_status_badge.dart';
|
||||
import '../../domain/entities/stack.dart';
|
||||
import '../providers/stacks_provider.dart';
|
||||
import '../widgets/env_vars_editor.dart';
|
||||
import '../widgets/yaml_editor.dart';
|
||||
|
||||
/// Detail page for a selected stack with YAML editor, env vars, and container list.
|
||||
class StackDetailPage extends ConsumerStatefulWidget {
|
||||
const StackDetailPage({
|
||||
super.key,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
ConsumerState<StackDetailPage> createState() => _StackDetailPageState();
|
||||
}
|
||||
|
||||
class _StackDetailPageState extends ConsumerState<StackDetailPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 2, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final stackAsync = ref.watch(stackProvider(widget.stackId));
|
||||
final containersAsync = ref.watch(containersProvider);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Listen for config action results
|
||||
ref.listen<AsyncValue<void>>(stackConfigActionsProvider, (previous, next) {
|
||||
if (previous?.isLoading == true && !next.isLoading) {
|
||||
next.whenOrNull(
|
||||
data: (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Configuration saved'),
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, _) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to save: $error'),
|
||||
backgroundColor: colorScheme.errorContainer,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Stack editor (top section - 60%)
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: stackAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data: (stack) => _StackEditor(
|
||||
stack: stack,
|
||||
tabController: _tabController,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Divider with drag handle appearance
|
||||
Container(
|
||||
height: 8,
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Container list (bottom section - 40%)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: containersAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(child: Text('Error: $error')),
|
||||
data: (containers) => _CompactContainerList(
|
||||
containers: containers,
|
||||
stackId: widget.stackId,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Stack configuration editor with tabs for YAML and environment variables.
|
||||
class _StackEditor extends ConsumerWidget {
|
||||
const _StackEditor({
|
||||
required this.stack,
|
||||
required this.tabController,
|
||||
});
|
||||
|
||||
final Stack stack;
|
||||
final TabController tabController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Header with stack info and actions
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.layers, color: colorScheme.primary),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
stack.name,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${stack.runningCount}/${stack.containerCount} containers running',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
ref.read(stackConfigActionsProvider.notifier).deploy(stack.id);
|
||||
},
|
||||
icon: const Icon(Icons.rocket_launch, size: 18),
|
||||
label: const Text('Deploy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Tab bar
|
||||
TabBar(
|
||||
controller: tabController,
|
||||
tabs: const [
|
||||
Tab(text: 'Compose YAML'),
|
||||
Tab(text: 'Environment Variables'),
|
||||
],
|
||||
),
|
||||
// Tab content
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
children: [
|
||||
YamlEditor(stackId: stack.id),
|
||||
EnvVarsEditor(stackId: stack.id),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact container list for the docked bottom section.
|
||||
class _CompactContainerList extends ConsumerWidget {
|
||||
const _CompactContainerList({
|
||||
required this.containers,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final List<dynamic> containers;
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final actions = ref.read(containerActionsProvider.notifier);
|
||||
|
||||
// Filter containers for this stack
|
||||
final stackContainers = containers
|
||||
.where((c) => c.stackId == stackId || c.stackName == stackId)
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.dns, size: 18, color: colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Containers (${stackContainers.length})',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: () => ref.invalidate(containersProvider),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Container list
|
||||
Expanded(
|
||||
child: stackContainers.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
'No containers in this stack',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: stackContainers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final container = stackContainers[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: ContainerStatusBadge(state: container.state),
|
||||
title: Text(
|
||||
container.name,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
container.image,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
itemBuilder: (context) => [
|
||||
if (container.canStart)
|
||||
const PopupMenuItem(
|
||||
value: 'start',
|
||||
child: Text('Start'),
|
||||
),
|
||||
if (container.canStop)
|
||||
const PopupMenuItem(
|
||||
value: 'stop',
|
||||
child: Text('Stop'),
|
||||
),
|
||||
if (container.canRestart)
|
||||
const PopupMenuItem(
|
||||
value: 'restart',
|
||||
child: Text('Restart'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'logs',
|
||||
child: Text('View Logs'),
|
||||
),
|
||||
],
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
actions.start(container.fullId);
|
||||
case 'stop':
|
||||
actions.stop(container.fullId);
|
||||
case 'restart':
|
||||
actions.restart(container.fullId);
|
||||
case 'logs':
|
||||
showContainerLogs(
|
||||
context,
|
||||
containerId: container.fullId,
|
||||
containerName: container.name,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,51 @@ class SelectedStack extends _$SelectedStack {
|
||||
state = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides the YAML content for a stack.
|
||||
@riverpod
|
||||
Future<String> stackYaml(StackYamlRef ref, String stackId) async {
|
||||
final repository = ref.watch(stackRepositoryProvider);
|
||||
return repository.getStackYaml(stackId);
|
||||
}
|
||||
|
||||
/// Provides the environment variables for a stack.
|
||||
@riverpod
|
||||
Future<Map<String, String>> stackEnvVars(StackEnvVarsRef ref, String stackId) async {
|
||||
final repository = ref.watch(stackRepositoryProvider);
|
||||
return repository.getStackEnvVars(stackId);
|
||||
}
|
||||
|
||||
/// Controller for stack configuration actions.
|
||||
@riverpod
|
||||
class StackConfigActions extends _$StackConfigActions {
|
||||
@override
|
||||
AsyncValue<void> build() => const AsyncValue.data(null);
|
||||
|
||||
Future<void> saveYaml(String stackId, String yaml) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.updateStackYaml(stackId, yaml);
|
||||
ref.invalidate(stackYamlProvider(stackId));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> saveEnvVars(String stackId, Map<String, String> envVars) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.updateStackEnvVars(stackId, envVars);
|
||||
ref.invalidate(stackEnvVarsProvider(stackId));
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deploy(String stackId) async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() async {
|
||||
final repository = ref.read(stackRepositoryProvider);
|
||||
await repository.deployStack(stackId);
|
||||
ref.invalidate(stacksProvider);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/stacks_provider.dart';
|
||||
|
||||
/// Environment variables editor for stack configuration.
|
||||
class EnvVarsEditor extends ConsumerStatefulWidget {
|
||||
const EnvVarsEditor({
|
||||
super.key,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
ConsumerState<EnvVarsEditor> createState() => _EnvVarsEditorState();
|
||||
}
|
||||
|
||||
class _EnvVarsEditorState extends ConsumerState<EnvVarsEditor> {
|
||||
final List<_EnvVarEntry> _entries = [];
|
||||
Map<String, String> _originalEnvVars = {};
|
||||
bool _hasChanges = false;
|
||||
|
||||
void _initializeEntries(Map<String, String> envVars) {
|
||||
if (_originalEnvVars.isEmpty && envVars.isNotEmpty) {
|
||||
_originalEnvVars = Map.from(envVars);
|
||||
_entries.clear();
|
||||
for (final entry in envVars.entries) {
|
||||
_entries.add(_EnvVarEntry(
|
||||
keyController: TextEditingController(text: entry.key),
|
||||
valueController: TextEditingController(text: entry.value),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _checkChanges() {
|
||||
final currentMap = <String, String>{};
|
||||
for (final entry in _entries) {
|
||||
final key = entry.keyController.text.trim();
|
||||
if (key.isNotEmpty) {
|
||||
currentMap[key] = entry.valueController.text;
|
||||
}
|
||||
}
|
||||
|
||||
final hasChanges = !_mapsEqual(currentMap, _originalEnvVars);
|
||||
if (hasChanges != _hasChanges) {
|
||||
setState(() => _hasChanges = hasChanges);
|
||||
}
|
||||
}
|
||||
|
||||
bool _mapsEqual(Map<String, String> a, Map<String, String> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final key in a.keys) {
|
||||
if (!b.containsKey(key) || a[key] != b[key]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _addEntry() {
|
||||
setState(() {
|
||||
_entries.add(_EnvVarEntry(
|
||||
keyController: TextEditingController(),
|
||||
valueController: TextEditingController(),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
void _removeEntry(int index) {
|
||||
setState(() {
|
||||
_entries[index].dispose();
|
||||
_entries.removeAt(index);
|
||||
_checkChanges();
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
final envVars = <String, String>{};
|
||||
for (final entry in _entries) {
|
||||
final key = entry.keyController.text.trim();
|
||||
if (key.isNotEmpty) {
|
||||
envVars[key] = entry.valueController.text;
|
||||
}
|
||||
}
|
||||
|
||||
ref
|
||||
.read(stackConfigActionsProvider.notifier)
|
||||
.saveEnvVars(widget.stackId, envVars);
|
||||
|
||||
setState(() {
|
||||
_originalEnvVars = Map.from(envVars);
|
||||
_hasChanges = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onReset() {
|
||||
for (final entry in _entries) {
|
||||
entry.dispose();
|
||||
}
|
||||
_entries.clear();
|
||||
|
||||
for (final entry in _originalEnvVars.entries) {
|
||||
_entries.add(_EnvVarEntry(
|
||||
keyController: TextEditingController(text: entry.key),
|
||||
valueController: TextEditingController(text: entry.value),
|
||||
));
|
||||
}
|
||||
|
||||
setState(() => _hasChanges = false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final entry in _entries) {
|
||||
entry.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final envVarsAsync = ref.watch(stackEnvVarsProvider(widget.stackId));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final configState = ref.watch(stackConfigActionsProvider);
|
||||
final isLoading = configState.isLoading;
|
||||
|
||||
return envVarsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load environment variables',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
ref.invalidate(stackEnvVarsProvider(widget.stackId)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (envVars) {
|
||||
_initializeEntries(envVars);
|
||||
return Column(
|
||||
children: [
|
||||
// Toolbar
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'.env',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_hasChanges) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Modified',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add variable',
|
||||
onPressed: _addEntry,
|
||||
),
|
||||
if (_hasChanges) ...[
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : _onReset,
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: isLoading ? null : _onSave,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save, size: 18),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Environment variables list
|
||||
Expanded(
|
||||
child: _entries.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.settings_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No environment variables',
|
||||
style: TextStyle(color: colorScheme.outline),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _addEntry,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Variable'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _entries[index];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Key field
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: entry.keyController,
|
||||
onChanged: (_) => _checkChanges(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Key',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Equals sign
|
||||
Text(
|
||||
'=',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: colorScheme.outline,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Value field
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: TextField(
|
||||
controller: entry.valueController,
|
||||
onChanged: (_) => _checkChanges(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Value',
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Delete button
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.delete_outline,
|
||||
color: colorScheme.error,
|
||||
),
|
||||
tooltip: 'Remove',
|
||||
onPressed: () => _removeEntry(index),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to manage key-value pair controllers.
|
||||
class _EnvVarEntry {
|
||||
_EnvVarEntry({
|
||||
required this.keyController,
|
||||
required this.valueController,
|
||||
});
|
||||
|
||||
final TextEditingController keyController;
|
||||
final TextEditingController valueController;
|
||||
|
||||
void dispose() {
|
||||
keyController.dispose();
|
||||
valueController.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/stacks_provider.dart';
|
||||
|
||||
/// YAML editor for stack compose configuration.
|
||||
class YamlEditor extends ConsumerStatefulWidget {
|
||||
const YamlEditor({
|
||||
super.key,
|
||||
required this.stackId,
|
||||
});
|
||||
|
||||
final String stackId;
|
||||
|
||||
@override
|
||||
ConsumerState<YamlEditor> createState() => _YamlEditorState();
|
||||
}
|
||||
|
||||
class _YamlEditorState extends ConsumerState<YamlEditor> {
|
||||
final _controller = TextEditingController();
|
||||
bool _hasChanges = false;
|
||||
String _originalYaml = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _initializeController(String yaml) {
|
||||
if (_originalYaml.isEmpty && yaml.isNotEmpty) {
|
||||
_originalYaml = yaml;
|
||||
_controller.text = yaml;
|
||||
}
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
setState(() {
|
||||
_hasChanges = value != _originalYaml;
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() {
|
||||
ref
|
||||
.read(stackConfigActionsProvider.notifier)
|
||||
.saveYaml(widget.stackId, _controller.text);
|
||||
setState(() {
|
||||
_originalYaml = _controller.text;
|
||||
_hasChanges = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _onReset() {
|
||||
_controller.text = _originalYaml;
|
||||
setState(() {
|
||||
_hasChanges = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final yamlAsync = ref.watch(stackYamlProvider(widget.stackId));
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final configState = ref.watch(stackConfigActionsProvider);
|
||||
final isLoading = configState.isLoading;
|
||||
|
||||
return yamlAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, color: colorScheme.error, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load YAML',
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(color: colorScheme.outline, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: () => ref.invalidate(stackYamlProvider(widget.stackId)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (yaml) {
|
||||
_initializeController(yaml);
|
||||
return Column(
|
||||
children: [
|
||||
// Toolbar
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.code,
|
||||
size: 18,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'docker-compose.yml',
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_hasChanges) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Modified',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: colorScheme.onTertiaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (_hasChanges) ...[
|
||||
TextButton(
|
||||
onPressed: isLoading ? null : _onReset,
|
||||
child: const Text('Reset'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: isLoading ? null : _onSave,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save, size: 18),
|
||||
label: const Text('Save'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
// Editor
|
||||
Expanded(
|
||||
child: Container(
|
||||
color: colorScheme.surfaceContainerLowest,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
onChanged: _onChanged,
|
||||
maxLines: null,
|
||||
expands: true,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
style: TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 13,
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.5,
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
contentPadding: EdgeInsets.all(16),
|
||||
border: InputBorder.none,
|
||||
hintText: 'Enter docker-compose.yml content...',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user