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,99 @@
import 'package:flutter/material.dart';
import '../data_grid_action.dart';
/// Actions menu for a DataGrid row.
class DataGridActionsMenu<T> extends StatelessWidget {
const DataGridActionsMenu({
super.key,
required this.item,
required this.actions,
});
final T item;
final List<DataGridAction<T>> actions;
@override
Widget build(BuildContext context) {
final visibleActions = actions.where((a) => a.shouldShow(item)).toList();
if (visibleActions.isEmpty) {
return const SizedBox.shrink();
}
return PopupMenuButton<DataGridAction<T>>(
icon: const Icon(Icons.more_vert),
tooltip: 'Actions',
onSelected: (action) => _handleAction(context, action),
itemBuilder: (context) => visibleActions.map((action) {
final colorScheme = Theme.of(context).colorScheme;
return PopupMenuItem<DataGridAction<T>>(
value: action,
child: Row(
children: [
Icon(
action.icon,
size: 20,
color: action.destructive ? colorScheme.error : null,
),
const SizedBox(width: 12),
Text(
action.label,
style: TextStyle(
color: action.destructive ? colorScheme.error : null,
),
),
],
),
);
}).toList(),
);
}
Future<void> _handleAction(
BuildContext context,
DataGridAction<T> action,
) async {
if (action.requiresConfirmation) {
final confirmed = await _showConfirmationDialog(context, action);
if (!confirmed) return;
}
await action.onTap(item);
}
Future<bool> _showConfirmationDialog(
BuildContext context,
DataGridAction<T> action,
) async {
final colorScheme = Theme.of(context).colorScheme;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(action.label),
content: Text(
action.confirmationMessage ?? 'Are you sure you want to proceed?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: action.destructive
? FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: null,
child: const Text('Confirm'),
),
],
),
) ??
false;
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import '../data_grid_action.dart';
/// Bulk actions bar shown when items are selected.
class DataGridBulkActions<T> extends StatelessWidget {
const DataGridBulkActions({
super.key,
required this.selectedCount,
required this.bulkActions,
required this.onClearSelection,
required this.getSelectedItems,
});
final int selectedCount;
final List<DataGridBulkAction<T>> bulkActions;
final VoidCallback onClearSelection;
final List<T> Function() getSelectedItems;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Text(
'$selectedCount selected',
style: TextStyle(
fontWeight: FontWeight.w500,
color: colorScheme.onPrimaryContainer,
),
),
const SizedBox(width: 16),
...bulkActions.where((a) => a.isAvailable(selectedCount)).map(
(action) => Padding(
padding: const EdgeInsets.only(right: 8),
child: _BulkActionButton(
action: action,
onPressed: () => _handleAction(context, action),
),
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: onClearSelection,
tooltip: 'Clear selection',
),
],
),
);
}
Future<void> _handleAction(
BuildContext context,
DataGridBulkAction<T> action,
) async {
if (action.requiresConfirmation) {
final confirmed = await _showConfirmationDialog(context, action);
if (!confirmed) return;
}
final items = getSelectedItems();
await action.onTap(items);
}
Future<bool> _showConfirmationDialog(
BuildContext context,
DataGridBulkAction<T> action,
) async {
final colorScheme = Theme.of(context).colorScheme;
return await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(action.label),
content: Text(
action.confirmationMessage ??
'Are you sure you want to ${action.label.toLowerCase()} $selectedCount items?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: action.destructive
? FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: null,
child: const Text('Confirm'),
),
],
),
) ??
false;
}
}
class _BulkActionButton<T> extends StatelessWidget {
const _BulkActionButton({
required this.action,
required this.onPressed,
});
final DataGridBulkAction<T> action;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (action.destructive) {
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(action.icon, size: 18),
label: Text(action.label),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
),
);
}
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(action.icon, size: 18),
label: Text(action.label),
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onPrimaryContainer,
side: BorderSide(color: colorScheme.onPrimaryContainer),
),
);
}
}
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
/// Default empty state for DataGrid.
class DataGridEmptyState extends StatelessWidget {
const DataGridEmptyState({
super.key,
this.icon = Icons.inbox_outlined,
this.title = 'No items found',
this.subtitle,
this.action,
this.onAction,
});
final IconData icon;
final String title;
final String? subtitle;
final String? action;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 64,
color: colorScheme.outline,
),
const SizedBox(height: 16),
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
textAlign: TextAlign.center,
),
],
if (action != null && onAction != null) ...[
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: onAction,
child: Text(action!),
),
],
],
),
),
);
}
}
/// Loading state for DataGrid.
class DataGridLoadingState extends StatelessWidget {
const DataGridLoadingState({super.key});
@override
Widget build(BuildContext context) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32),
child: CircularProgressIndicator(),
),
);
}
}
/// Error state for DataGrid.
class DataGridErrorState extends StatelessWidget {
const DataGridErrorState({
super.key,
required this.error,
required this.onRetry,
});
final Object error;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 64,
color: colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Failed to load data',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.outline,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../data_grid_config.dart';
/// Footer for DataGrid with item count and pagination controls.
class DataGridFooter extends StatelessWidget {
const DataGridFooter({
super.key,
required this.totalCount,
required this.displayedCount,
required this.dataMode,
this.currentPage = 0,
this.onPageChange,
this.isLoading = false,
});
final int totalCount;
final int displayedCount;
final DataGridDataMode dataMode;
final int currentPage;
final void Function(int page)? onPageChange;
final bool isLoading;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
top: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
Text(
_getCountText(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const Spacer(),
if (dataMode is PaginatedDataMode) _buildPaginationControls(context),
],
),
);
}
String _getCountText() {
return switch (dataMode) {
AllDataMode() => '$totalCount items',
PaginatedDataMode(:final pageSize) => _getPaginatedCountText(pageSize),
InfiniteDataMode() => '$displayedCount of $totalCount items',
};
}
String _getPaginatedCountText(int pageSize) {
final start = currentPage * pageSize + 1;
final end = (start + displayedCount - 1).clamp(start, totalCount);
return '$start-$end of $totalCount items';
}
Widget _buildPaginationControls(BuildContext context) {
final mode = dataMode as PaginatedDataMode;
final totalPages = (totalCount / mode.pageSize).ceil();
final canGoPrevious = currentPage > 0;
final canGoNext = currentPage < totalPages - 1;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.first_page),
onPressed: canGoPrevious ? () => onPageChange?.call(0) : null,
tooltip: 'First page',
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed:
canGoPrevious ? () => onPageChange?.call(currentPage - 1) : null,
tooltip: 'Previous page',
iconSize: 20,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'Page ${currentPage + 1} of $totalPages',
style: Theme.of(context).textTheme.bodySmall,
),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed:
canGoNext ? () => onPageChange?.call(currentPage + 1) : null,
tooltip: 'Next page',
iconSize: 20,
),
IconButton(
icon: const Icon(Icons.last_page),
onPressed:
canGoNext ? () => onPageChange?.call(totalPages - 1) : null,
tooltip: 'Last page',
iconSize: 20,
),
],
);
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import '../data_grid_column.dart';
import '../data_grid_config.dart';
/// Header row for DataGrid.
class DataGridHeader<T> extends StatelessWidget {
const DataGridHeader({
super.key,
required this.config,
required this.sortColumnIndex,
required this.sortDescending,
required this.onSort,
this.showCheckbox = false,
this.allSelected = false,
this.someSelected = false,
this.onSelectAll,
});
final DataGridConfig<T> config;
final int? sortColumnIndex;
final bool sortDescending;
final void Function(int columnIndex) onSort;
final bool showCheckbox;
final bool allSelected;
final bool someSelected;
final VoidCallback? onSelectAll;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final columns = config.visibleColumns;
return Container(
height: config.headerHeight,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: colorScheme.outlineVariant),
),
),
child: Row(
children: [
if (showCheckbox)
SizedBox(
width: 56,
child: Center(
child: Checkbox(
value: allSelected ? true : (someSelected ? null : false),
tristate: true,
onChanged: (_) => onSelectAll?.call(),
),
),
),
...columns.asMap().entries.map((entry) {
final index = entry.key;
final column = entry.value;
final isSorted = sortColumnIndex == index;
return _buildHeaderCell(
context,
column,
index,
isSorted,
isSorted && sortDescending,
);
}),
if (config.actions.isNotEmpty)
const SizedBox(width: 56), // Space for actions column
],
),
);
}
Widget _buildHeaderCell(
BuildContext context,
DataGridColumn<T> column,
int index,
bool isSorted,
bool isDescending,
) {
final colorScheme = Theme.of(context).colorScheme;
final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurfaceVariant,
);
Widget content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
column.header,
style: textStyle,
overflow: TextOverflow.ellipsis,
textAlign: column.textAlign,
),
),
if (column.sortable) ...[
const SizedBox(width: 4),
AnimatedRotation(
turns: isDescending ? 0.5 : 0,
duration: const Duration(milliseconds: 200),
child: Icon(
isSorted ? Icons.arrow_upward : Icons.unfold_more,
size: 16,
color: isSorted
? colorScheme.primary
: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
),
),
],
],
);
if (column.sortable) {
content = InkWell(
onTap: () => onSort(index),
child: Padding(
padding: config.cellPadding,
child: content,
),
);
} else {
content = Padding(
padding: config.cellPadding,
child: content,
);
}
return _wrapWithWidth(column.width, content);
}
Widget _wrapWithWidth(DataGridColumnWidth width, Widget child) {
return switch (width) {
GridFixedWidth(:final width) => SizedBox(width: width, child: child),
GridFlexWidth(:final flex) => Expanded(flex: flex, child: child),
GridFractionWidth(:final fraction) => FractionallySizedBox(
widthFactor: fraction,
child: child,
),
};
}
}
@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
import '../data_grid_column.dart';
import '../data_grid_config.dart';
import 'data_grid_actions_menu.dart';
/// A single data row in the DataGrid.
class DataGridRow<T> extends StatelessWidget {
const DataGridRow({
super.key,
required this.item,
required this.index,
required this.config,
this.isSelected = false,
this.onSelect,
this.onTap,
this.backgroundColor,
});
final T item;
final int index;
final DataGridConfig<T> config;
final bool isSelected;
final VoidCallback? onSelect;
final VoidCallback? onTap;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final columns = config.visibleColumns;
// Determine row background color
Color? bgColor = backgroundColor;
if (bgColor == null && config.alternatingRowColors) {
bgColor = index.isOdd
? colorScheme.surfaceContainerLowest
: colorScheme.surface;
}
if (isSelected) {
bgColor = colorScheme.primaryContainer.withValues(alpha: 0.3);
}
final rowContent = Container(
height: config.rowHeight,
constraints: config.rowHeight == null
? const BoxConstraints(minHeight: 48)
: null,
decoration: BoxDecoration(
color: bgColor,
border: Border(
bottom: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
),
),
),
child: Row(
children: [
if (config.rowsSelectable)
SizedBox(
width: 56,
child: Center(
child: Checkbox(
value: isSelected,
onChanged: (_) => onSelect?.call(),
),
),
),
...columns.map((column) => _buildCell(context, column)),
if (config.actions.isNotEmpty)
SizedBox(
width: 56,
child: DataGridActionsMenu<T>(
item: item,
actions: config.actions,
),
),
],
),
);
if (onTap != null) {
return InkWell(
onTap: onTap,
child: rowContent,
);
}
return rowContent;
}
Widget _buildCell(BuildContext context, DataGridColumn<T> column) {
Widget content;
if (column.cellBuilder != null) {
content = column.cellBuilder!(context, item);
} else {
content = Text(
column.valueBuilder(item),
textAlign: column.textAlign,
overflow: TextOverflow.ellipsis,
);
}
// Wrap with controls if provided
if (column.cellControlsBuilder != null) {
content = Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: content),
const SizedBox(width: 8),
column.cellControlsBuilder!(context, item),
],
);
}
// Wrap with tooltip if provided
if (column.tooltip != null) {
content = Tooltip(
message: column.tooltip!(item),
child: content,
);
}
final cell = Padding(
padding: config.cellPadding,
child: Align(
alignment: switch (column.alignment) {
DataGridColumnAlignment.start => Alignment.centerLeft,
DataGridColumnAlignment.center => Alignment.center,
DataGridColumnAlignment.end => Alignment.centerRight,
},
child: content,
),
);
return _wrapWithWidth(column.width, cell);
}
Widget _wrapWithWidth(DataGridColumnWidth width, Widget child) {
return switch (width) {
GridFixedWidth(:final width) => SizedBox(width: width, child: child),
GridFlexWidth(:final flex) => Expanded(flex: flex, child: child),
GridFractionWidth(:final fraction) => FractionallySizedBox(
widthFactor: fraction,
child: child,
),
};
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
/// Search bar for DataGrid.
class DataGridSearchBar extends StatefulWidget {
const DataGridSearchBar({
super.key,
required this.onSearch,
required this.onClear,
this.hintText = 'Search...',
this.initialValue = '',
});
final void Function(String query) onSearch;
final VoidCallback onClear;
final String hintText;
final String initialValue;
@override
State<DataGridSearchBar> createState() => _DataGridSearchBarState();
}
class _DataGridSearchBarState extends State<DataGridSearchBar> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: 300,
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: widget.hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_controller.clear();
widget.onClear();
},
)
: null,
isDense: true,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
setState(() {}); // Update clear button visibility
widget.onSearch(value);
},
),
);
}
}