135 lines
4.4 KiB
Dart
135 lines
4.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/models/models.dart';
|
|
import '../../../core/models/severity_mapping.dart';
|
|
import '../../../core/theme/theme_x.dart';
|
|
import '../../../core/widgets/empty_state.dart';
|
|
import '../../../core/widgets/status_pill.dart';
|
|
|
|
/// One card per registered node showing its live services (via
|
|
/// `/nodes/aggregated`), or an inline error card if this particular node
|
|
/// was unreachable — a per-entry condition the local API itself reports,
|
|
/// distinct from the aggregated fetch as a whole failing.
|
|
class FleetCardGrid extends StatelessWidget {
|
|
const FleetCardGrid({super.key, required this.entries});
|
|
|
|
final List<AggregatedNodeStatus> entries;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (entries.isEmpty) {
|
|
return const EmptyState(
|
|
icon: Icons.hub_outlined,
|
|
title: 'Nothing to show yet',
|
|
message: 'Register a node to see its live services here.',
|
|
);
|
|
}
|
|
|
|
return GridView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
|
maxCrossAxisExtent: 320,
|
|
mainAxisExtent: 180,
|
|
crossAxisSpacing: 12,
|
|
mainAxisSpacing: 12,
|
|
),
|
|
itemCount: entries.length,
|
|
itemBuilder: (context, index) => _NodeCard(entry: entries[index]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _NodeCard extends StatelessWidget {
|
|
const _NodeCard({required this.entry});
|
|
|
|
final AggregatedNodeStatus entry;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hasError = entry.error != null;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surface,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(
|
|
color: hasError ? context.status.critical.withValues(alpha: 0.4) : context.colors.outlineVariant,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(entry.node.name, style: context.text.labelLarge),
|
|
const SizedBox(height: 10),
|
|
Expanded(
|
|
child: hasError
|
|
? _ErrorCallout(message: entry.error!)
|
|
: entry.services.isEmpty
|
|
? Text('No services reported.', style: context.text.bodySmall)
|
|
: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
for (final service in entry.services)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3.5),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
service.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: context.text.bodyMedium,
|
|
),
|
|
),
|
|
StatusPill(
|
|
label: service.status,
|
|
severity: severityForServiceStatus(service.status),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ErrorCallout extends StatelessWidget {
|
|
const _ErrorCallout({required this.message});
|
|
|
|
final String message;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: Color.alphaBlend(context.status.critical.withValues(alpha: 0.08), context.colors.surface),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: context.status.critical.withValues(alpha: 0.35)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.error_outline, size: 15, color: context.status.critical),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
message,
|
|
style: context.dataStyles.dataMonoSmall,
|
|
maxLines: 3,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|