feat: add Control Room with containers and stacks management

- Add Control Room page with stacks sidebar and containers list
- Implement container actions (start/stop/restart) with snackbars
- Add container logs viewer dialog
- Add search/filter for both stacks and containers lists
- Add external links for Portainer and Netdata (url_launcher)
- Add VS Code launch configuration for Flutter web debugging

🤖 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
2025-12-31 02:01:01 +01:00
co-authored by Claude Opus 4.5
parent dd6bcbdbda
commit 3b89ed8c18
42 changed files with 4166 additions and 21 deletions
@@ -0,0 +1,84 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../../containers/data/datasources/containers_datasource.dart';
import '../../../containers/domain/entities/container.dart';
import '../../domain/entities/stack.dart';
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.
class StacksDatasource {
StacksDatasource(this._containersDatasource);
final ContainersDatasource _containersDatasource;
/// Gets all stacks by aggregating container data.
Future<List<Stack>> getStacks() async {
final containers = await _containersDatasource.getContainers();
final containerEntities = containers.map((c) => c.toEntity()).toList();
// Group containers by stack name
final stackMap = <String, List<Container>>{};
for (final container in containerEntities) {
final stackName = container.stackName;
if (stackName != null) {
stackMap.putIfAbsent(stackName, () => []).add(container);
}
}
// Convert to Stack entities
return stackMap.entries.map((entry) {
final name = entry.key;
final stackContainers = entry.value;
final runningCount = stackContainers.where((c) => c.isRunning).length;
return Stack(
id: name, // Use name as ID for compose stacks
name: name,
type: StackType.compose,
status: runningCount == stackContainers.length
? StackStatus.active
: runningCount > 0
? StackStatus.active
: StackStatus.inactive,
containerCount: stackContainers.length,
runningCount: runningCount,
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
}
/// Gets a single stack by ID.
Future<Stack> getStack(String id) async {
final stacks = await getStacks();
return stacks.firstWhere(
(s) => s.id == id,
orElse: () => throw Exception('Stack not found: $id'),
);
}
/// Performs an action on all containers in a stack.
Future<void> stackAction(String stackId, String action) async {
final containers = await _containersDatasource.getContainers();
final stackContainers = containers
.map((c) => c.toEntity())
.where((c) => c.stackId == stackId)
.toList();
for (final container in stackContainers) {
await _containersDatasource.containerAction(container.fullId, action);
}
}
}
/// Provides the stacks datasource.
@riverpod
StacksDatasource stacksDatasource(StacksDatasourceRef ref) {
final containersDatasource = ref.watch(containersDatasourceProvider);
return StacksDatasource(containersDatasource);
}
@@ -0,0 +1,62 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/entities/stack.dart';
part 'stack_model.freezed.dart';
part 'stack_model.g.dart';
/// Stack data model for API serialization.
@freezed
class StackModel with _$StackModel {
const factory StackModel({
required String id,
required String name,
@JsonKey(name: 'type') String? typeString,
@JsonKey(name: 'status') String? statusString,
@JsonKey(name: 'container_count') @Default(0) int containerCount,
@JsonKey(name: 'running_count') @Default(0) int runningCount,
@JsonKey(name: 'compose_file') String? composeFile,
String? environment,
@JsonKey(name: 'created_at') String? createdAt,
@JsonKey(name: 'updated_at') String? updatedAt,
}) = _StackModel;
const StackModel._();
factory StackModel.fromJson(Map<String, dynamic> json) =>
_$StackModelFromJson(json);
/// Converts to domain entity.
Stack toEntity() {
return Stack(
id: id,
name: name,
type: _parseStackType(typeString),
status: _parseStackStatus(statusString),
containerCount: containerCount,
runningCount: runningCount,
composeFile: composeFile,
environment: environment,
createdAt: createdAt != null ? DateTime.tryParse(createdAt!) : null,
updatedAt: updatedAt != null ? DateTime.tryParse(updatedAt!) : null,
);
}
StackType _parseStackType(String? type) {
return switch (type?.toLowerCase()) {
'compose' => StackType.compose,
'swarm' => StackType.swarm,
'kubernetes' || 'k8s' => StackType.kubernetes,
_ => StackType.compose,
};
}
StackStatus _parseStackStatus(String? status) {
return switch (status?.toLowerCase()) {
'active' || 'running' => StackStatus.active,
'inactive' || 'stopped' => StackStatus.inactive,
'error' => StackStatus.error,
_ => StackStatus.unknown,
};
}
}
@@ -0,0 +1,52 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/stack.dart';
import '../../domain/repositories/stack_repository.dart';
import '../datasources/stacks_datasource.dart';
part 'stack_repository_impl.g.dart';
/// Implementation of StackRepository.
class StackRepositoryImpl implements StackRepository {
StackRepositoryImpl(this._datasource);
final StacksDatasource _datasource;
@override
Future<List<Stack>> getStacks() async {
return _datasource.getStacks();
}
@override
Future<Stack> getStack(String id) async {
return _datasource.getStack(id);
}
@override
Future<void> startStack(String id) async {
await _datasource.stackAction(id, 'start');
}
@override
Future<void> stopStack(String id) async {
await _datasource.stackAction(id, 'stop');
}
@override
Future<void> restartStack(String id) async {
await _datasource.stackAction(id, 'restart');
}
@override
Future<void> removeStack(String id) async {
// TODO: Implement stack removal
throw UnimplementedError('Stack removal not yet implemented');
}
}
/// Provides the stack repository.
@riverpod
StackRepository stackRepository(StackRepositoryRef ref) {
final datasource = ref.watch(stacksDatasourceProvider);
return StackRepositoryImpl(datasource);
}