chore: release v0.3.3
Build and Push / build (release) Successful in 3m6s

Features:
- Health check endpoint for Portainer monitoring
- Local search filtering in DataGrid
- Container status badges reflect health (green/orange/blue)

Improvements:
- Standardized 56px header heights across panels
- Container grid parses Docker API format correctly
- Search bar styling improvements
- Status badges have consistent width

Fixes:
- Quick links persistence (link type, form refresh)
- Iframe switching closes existing content first
- ContainerState type conflict resolved

Branding:
- Updated favicon and icons with Tatlock bucket logo

🤖 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-03 16:12:18 +01:00
co-authored by Claude Opus 4.5
parent 0d15d3dbb5
commit bdced8739c
26 changed files with 499 additions and 136 deletions
@@ -7,6 +7,9 @@ import 'package:tatlock_ui/features/control_room/containers/presentation/widgets
import 'package:tatlock_ui/shared/components/data_grid/adapters/core_api_source.dart';
import 'package:tatlock_ui/shared/components/data_grid/data_grid_exports.dart';
/// Health status of a container.
enum ContainerHealth { healthy, unhealthy, starting, none }
/// Container data class for DataGrid display.
class ContainerData {
ContainerData({
@@ -17,25 +20,45 @@ class ContainerData {
required this.state,
required this.status,
required this.ports,
required this.health,
});
factory ContainerData.fromJson(Map<String, dynamic> json) {
final ports = (json['ports'] as List<dynamic>?)
// Docker API uses capitalized keys
final ports = (json['Ports'] as List<dynamic>?)
?.map((p) => ContainerPort.fromJson(p as Map<String, dynamic>))
.toList() ??
[];
final fullId = json['Id'] as String? ?? '';
final names = json['Names'] as List<dynamic>? ?? [];
final name = names.isNotEmpty
? (names.first as String).replaceFirst('/', '')
: 'Unknown';
final status = json['Status'] as String? ?? 'Unknown';
return ContainerData(
id: json['id'] as String,
fullId: json['full_id'] as String? ?? json['id'] as String,
name: json['name'] as String,
image: json['image'] as String,
state: json['state'] as String,
status: json['status'] as String,
id: fullId.length > 12 ? fullId.substring(0, 12) : fullId,
fullId: fullId,
name: name,
image: json['Image'] as String? ?? 'Unknown',
state: json['State'] as String? ?? 'unknown',
status: status,
ports: ports,
health: _parseHealth(status),
);
}
/// Parse health status from Docker status string (e.g., "Up 2 hours (healthy)")
static ContainerHealth _parseHealth(String status) {
final lower = status.toLowerCase();
if (lower.contains('(healthy)')) return ContainerHealth.healthy;
if (lower.contains('(unhealthy)')) return ContainerHealth.unhealthy;
if (lower.contains('(health: starting)')) return ContainerHealth.starting;
return ContainerHealth.none;
}
final String id;
final String fullId;
final String name;
@@ -43,6 +66,16 @@ class ContainerData {
final String state;
final String status;
final List<ContainerPort> ports;
final ContainerHealth health;
/// Status string with health info stripped (just shows uptime).
String get displayStatus {
return status
.replaceAll(RegExp(r'\s*\(healthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(unhealthy\)', caseSensitive: false), '')
.replaceAll(RegExp(r'\s*\(health: starting\)', caseSensitive: false), '')
.trim();
}
bool get canStart => state == 'exited' || state == 'created';
bool get canStop => state == 'running';
@@ -53,9 +86,10 @@ class ContainerPort {
ContainerPort({required this.privatePort, this.publicPort, this.type = 'tcp'});
factory ContainerPort.fromJson(Map<String, dynamic> json) => ContainerPort(
privatePort: json['private_port'] as int? ?? json['PrivatePort'] as int? ?? 0,
publicPort: json['public_port'] as int? ?? json['PublicPort'] as int?,
type: json['type'] as String? ?? json['Type'] as String? ?? 'tcp',
// Docker API uses PrivatePort/PublicPort
privatePort: json['PrivatePort'] as int? ?? 0,
publicPort: json['PublicPort'] as int?,
type: json['Type'] as String? ?? 'tcp',
);
final int privatePort;
@@ -99,7 +133,7 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
columns: [
DataGridColumn<ContainerData>(
header: 'Container',
valueBuilder: (c) => c.name,
valueBuilder: (c) => '${c.name} ${c.image}',
sortable: true,
searchable: true,
width: const DataGridColumnWidth.flex(2),
@@ -113,8 +147,8 @@ class _ContainersListPageState extends ConsumerState<ContainersListPage> {
),
DataGridColumn<ContainerData>(
header: 'Status',
valueBuilder: (c) => c.status,
width: const DataGridColumnWidth.fixed(140),
valueBuilder: (c) => c.displayStatus,
width: const DataGridColumnWidth.fixed(160),
alignment: DataGridColumnAlignment.end,
),
],
@@ -242,7 +276,7 @@ class _ContainerCell extends StatelessWidget {
return Row(
children: [
ContainerStatusBadge.fromString(container.state),
ContainerStatusBadge.fromString(container.state, health: container.health),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -1,48 +1,67 @@
import 'package:flutter/material.dart' hide Container;
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/pages/containers_list_page.dart';
/// Container state enum for status display.
enum ContainerState {
created,
running,
paused,
restarting,
removing,
exited,
dead;
/// Status badge for container state with optional health indication.
class ContainerStatusBadge extends StatelessWidget {
const ContainerStatusBadge({
super.key,
required this.state,
this.health,
this.showLabel = true,
});
/// Parse a string to ContainerState.
static ContainerState fromString(String value) {
/// Create badge from a string state value.
factory ContainerStatusBadge.fromString(
String state, {
ContainerHealth? health,
bool showLabel = true,
}) {
return ContainerStatusBadge(
state: _parseState(state),
health: health,
showLabel: showLabel,
);
}
/// Create badge from state enum and status string (parses health from status).
factory ContainerStatusBadge.withStatus({
required ContainerState state,
required String status,
bool showLabel = true,
}) {
return ContainerStatusBadge(
state: state,
health: _parseHealthFromStatus(status),
showLabel: showLabel,
);
}
/// Parse health status from Docker status string.
static ContainerHealth _parseHealthFromStatus(String status) {
final lower = status.toLowerCase();
if (lower.contains('(healthy)')) return ContainerHealth.healthy;
if (lower.contains('(unhealthy)')) return ContainerHealth.unhealthy;
if (lower.contains('(health: starting)')) return ContainerHealth.starting;
return ContainerHealth.none;
}
final ContainerState state;
final ContainerHealth? health;
final bool showLabel;
static ContainerState _parseState(String value) {
return ContainerState.values.firstWhere(
(s) => s.name == value.toLowerCase(),
orElse: () => ContainerState.exited,
);
}
}
/// Status badge for container state.
class ContainerStatusBadge extends StatelessWidget {
const ContainerStatusBadge({
super.key,
required this.state,
this.showLabel = true,
});
/// Create badge from a string state value.
factory ContainerStatusBadge.fromString(String state, {bool showLabel = true}) {
return ContainerStatusBadge(
state: ContainerState.fromString(state),
showLabel: showLabel,
);
}
final ContainerState state;
final bool showLabel;
@override
Widget build(BuildContext context) {
final (color, icon, label) = _getStateStyle(context);
return DecoratedBox(
Widget badge = DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
@@ -69,17 +88,22 @@ class ContainerStatusBadge extends StatelessWidget {
),
),
);
if (showLabel) {
badge = ConstrainedBox(
constraints: const BoxConstraints(minWidth: 90),
child: badge,
);
}
return badge;
}
(Color, IconData, String) _getStateStyle(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return switch (state) {
ContainerState.running => (
Colors.green,
Icons.play_circle,
'Running',
),
ContainerState.running => _getRunningStyle(colorScheme),
ContainerState.paused => (
Colors.orange,
Icons.pause_circle,
@@ -112,4 +136,30 @@ class ContainerStatusBadge extends StatelessWidget {
),
};
}
/// Get style for running state, factoring in health status.
(Color, IconData, String) _getRunningStyle(ColorScheme colorScheme) {
return switch (health) {
ContainerHealth.healthy => (
Colors.green,
Icons.play_circle,
'Running',
),
ContainerHealth.unhealthy => (
Colors.orange,
Icons.warning_amber_rounded,
'Unhealthy',
),
ContainerHealth.starting => (
Colors.blue,
Icons.hourglass_top,
'Starting',
),
ContainerHealth.none || null => (
Colors.green,
Icons.play_circle,
'Running',
),
};
}
}
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart' hide Stack;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tatlock_ui/features/control_room/containers/domain/entities/container.dart'
as container_entity;
import 'package:tatlock_ui/features/control_room/containers/presentation/providers/containers_provider.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_logs_viewer.dart';
import 'package:tatlock_ui/features/control_room/containers/presentation/widgets/container_status_badge.dart';
@@ -133,7 +135,9 @@ class _StackEditor extends ConsumerWidget {
children: [
// Header with stack info and actions
Container(
padding: const EdgeInsets.all(16),
height: 56,
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
alignment: Alignment.bottomCenter,
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
border: Border(
@@ -141,11 +145,13 @@ class _StackEditor extends ConsumerWidget {
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(Icons.layers, color: colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@@ -211,7 +217,7 @@ class _CompactContainerList extends ConsumerWidget {
required this.stackId,
});
final List<dynamic> containers;
final List<container_entity.Container> containers;
final String stackId;
@override
@@ -269,7 +275,10 @@ class _CompactContainerList extends ConsumerWidget {
),
child: ListTile(
dense: true,
leading: ContainerStatusBadge(state: container.state),
leading: ContainerStatusBadge.withStatus(
state: container.state,
status: container.status,
),
title: Text(
container.name,
style: const TextStyle(fontSize: 13),
@@ -52,7 +52,7 @@ class QuickLinksDatasource {
final model = QuickLinkModel.fromEntity(link);
final response = await _dio.post<Map<String, dynamic>>(
_basePath,
data: model.toCreateJson(),
data: model.toJson(),
);
final data = response.data;
@@ -69,7 +69,7 @@ class QuickLinksDatasource {
final id = int.tryParse(link.id) ?? 0;
final response = await _dio.put<Map<String, dynamic>>(
'$_basePath/$id',
data: model.toCreateJson(),
data: model.toJson(),
);
final data = response.data;
@@ -4,17 +4,18 @@ import 'package:tatlock_ui/features/front_hall/domain/entities/quick_link.dart';
part 'quick_link_model.freezed.dart';
part 'quick_link_model.g.dart';
/// Quick link data model for API serialization.
/// Quick link data model matching Core API dashboard/quick-links endpoint.
///
/// Maps to Core API dashboard/quick-links endpoint:
/// Field mapping:
/// - title (API) ↔ name (Flutter entity)
/// - icon (API) ↔ iconName (Flutter entity)
/// - position (API) ↔ sortOrder (Flutter entity)
/// - is_visible (API) ↔ isActive (Flutter entity)
/// - link_type (API) ↔ type (Flutter entity)
@freezed
sealed class QuickLinkModel with _$QuickLinkModel {
const factory QuickLinkModel({
required int id,
@Default(0) int id,
required String title,
required String url,
String? icon,
@@ -22,6 +23,7 @@ sealed class QuickLinkModel with _$QuickLinkModel {
String? category,
@Default(0) int position,
@JsonKey(name: 'is_visible') @Default(true) bool isVisible,
@JsonKey(name: 'link_type') @Default('iframe') String linkType,
String? color,
@JsonKey(name: 'background_color') String? backgroundColor,
}) = _QuickLinkModel;
@@ -39,7 +41,7 @@ sealed class QuickLinkModel with _$QuickLinkModel {
url: url,
iconName: icon ?? 'link',
category: category,
type: QuickLinkType.iframe,
type: _parseQuickLinkType(linkType),
sortOrder: position,
isActive: isVisible,
);
@@ -55,21 +57,23 @@ sealed class QuickLinkModel with _$QuickLinkModel {
category: entity.category,
position: entity.sortOrder,
isVisible: entity.isActive,
linkType: _formatQuickLinkType(entity.type),
);
}
}
/// Creates a JSON map for creating a new quick link (without id).
Map<String, dynamic> toCreateJson() {
return {
'title': title,
'url': url,
if (icon != null) 'icon': icon,
if (description != null) 'description': description,
if (category != null) 'category': category,
'position': position,
'is_visible': isVisible,
if (color != null) 'color': color,
if (backgroundColor != null) 'background_color': backgroundColor,
};
}
/// Parses API link_type string to QuickLinkType enum.
QuickLinkType _parseQuickLinkType(String linkType) {
return switch (linkType) {
'new_tab' => QuickLinkType.newTab,
_ => QuickLinkType.iframe,
};
}
/// Formats QuickLinkType enum to API link_type string.
String _formatQuickLinkType(QuickLinkType type) {
return switch (type) {
QuickLinkType.newTab => 'new_tab',
QuickLinkType.iframe => 'iframe',
};
}
@@ -51,6 +51,7 @@ class FrontHallPage extends ConsumerWidget {
case FrontHallMode.iframe:
return IframeView(
key: ValueKey(state.activeIframeUrl),
url: state.activeIframeUrl!,
title: state.activeIframeTitle ?? 'External Content',
onClose: () =>
@@ -83,7 +83,7 @@ class _IframeViewState extends State<IframeView> {
children: [
// Header bar
Container(
height: 48,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
@@ -67,24 +67,21 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
try {
final actions = ref.read(quickLinkActionsProvider.notifier);
final category = _categoryController.text.trim();
// Generate ID for new links
final id = widget.quickLink?.id ??
_nameController.text.toLowerCase().replaceAll(RegExp(r'\s+'), '-');
final link = QuickLink(
id: id,
name: _nameController.text.trim(),
url: _urlController.text.trim(),
iconName: _iconName,
category:
_categoryController.text.trim().isEmpty ? null : _categoryController.text.trim(),
type: _type,
isActive: _isActive,
sortOrder: widget.quickLink?.sortOrder ?? 0,
);
final QuickLink link;
if (isCreate) {
// Create new entity
link = QuickLink(
id: _nameController.text.toLowerCase().replaceAll(RegExp(r'\s+'), '-'),
name: _nameController.text.trim(),
url: _urlController.text.trim(),
iconName: _iconName,
category: category.isEmpty ? null : category,
type: _type,
isActive: _isActive,
sortOrder: 0,
);
await actions.create(link);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -95,6 +92,15 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
);
}
} else {
// Update: use copyWith to preserve all existing data
link = widget.quickLink!.copyWith(
name: _nameController.text.trim(),
url: _urlController.text.trim(),
iconName: _iconName,
category: category.isEmpty ? null : category,
type: _type,
isActive: _isActive,
);
await actions.update(link);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -106,6 +112,9 @@ class _QuickLinkFormState extends ConsumerState<QuickLinkForm> {
}
}
// Refresh the list before exiting
ref.invalidate(quickLinksProvider);
widget.onSaved();
} catch (e) {
if (mounted) {
@@ -53,7 +53,8 @@ class _QuickLinkSettingsContentState
body: QuickLinkForm(
mode: EntityPageMode.create,
onCancel: _stopCreating,
onSaved: _stopCreating,
onSaved: () =>
ref.read(frontHallStateProvider.notifier).exitSettings(),
),
);
}
@@ -131,14 +132,14 @@ class _QuickLinkSettingsContentState
title: Text('Edit: ${link.name}'),
),
body: QuickLinkForm(
key: ValueKey(link.id), // Force recreation when link changes
mode: EntityPageMode.edit,
quickLink: link,
onCancel: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
onSaved: () => ref
.read(frontHallStateProvider.notifier)
.clearSelectedLink(),
onSaved: () =>
ref.read(frontHallStateProvider.notifier).exitSettings(),
),
),
);
@@ -297,7 +297,10 @@ class _QuickLinksList extends ConsumerWidget {
// In settings mode, select link for editing
ref.read(frontHallStateProvider.notifier).selectLink(link.id);
} else {
// In normal mode, open link
// Close any open content first to ensure clean state
ref.read(frontHallStateProvider.notifier).showDashboard();
// Then open the new link
if (link.isIframe) {
ref
.read(frontHallStateProvider.notifier)