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),