176 lines
6.0 KiB
Dart
176 lines
6.0 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../core/models/models.dart';
|
|
import '../../core/models/severity_mapping.dart';
|
|
import '../../core/network/api_exception.dart';
|
|
import '../../core/theme/status_colors.dart';
|
|
import '../../core/theme/theme_x.dart';
|
|
import '../../core/utils/relative_time.dart';
|
|
import '../../core/widgets/error_banner.dart';
|
|
import '../../core/widgets/stat_tile.dart';
|
|
import '../../data/repositories/nodes_repository.dart';
|
|
import 'providers/dashboard_summary.dart';
|
|
import 'providers/dashboard_summary_provider.dart';
|
|
import 'widgets/activity_feed.dart';
|
|
import 'widgets/quick_actions_row.dart';
|
|
|
|
class DashboardScreen extends ConsumerWidget {
|
|
const DashboardScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final summaryAsync = ref.watch(dashboardSummaryProvider);
|
|
final fleetAsync = ref.watch(aggregatedNodesProvider);
|
|
|
|
return summaryAsync.when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (error, stackTrace) => Center(
|
|
child: ErrorBanner(
|
|
message: error is ApiException ? error.userMessage : error.toString(),
|
|
onRetry: () => ref.invalidate(dashboardSummaryProvider),
|
|
),
|
|
),
|
|
data: (summary) => ListView(
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
_StatRow(summary: summary, fleetAsync: fleetAsync),
|
|
const SizedBox(height: 22),
|
|
Text('QUICK ACTIONS', style: context.text.titleSmall),
|
|
const SizedBox(height: 10),
|
|
const QuickActionsRow(),
|
|
const SizedBox(height: 22),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('RECENT ACTIVITY', style: context.text.titleSmall),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Card(child: ActivityFeed(node: summary.node)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatRow extends StatelessWidget {
|
|
const _StatRow({required this.summary, required this.fleetAsync});
|
|
|
|
final DashboardSummary summary;
|
|
final AsyncValue<List<AggregatedNodeStatus>> fleetAsync;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final services = summary.services;
|
|
final up = services.where((s) => s.status == 'up').length;
|
|
final down = services.where((s) => s.status == 'down').length;
|
|
final unknown = services.length - up - down;
|
|
|
|
final servicesSeverity = down > 0
|
|
? Severity.critical
|
|
: unknown > 0
|
|
? Severity.warning
|
|
: Severity.good;
|
|
final servicesCaption = services.isEmpty
|
|
? 'No services discovered yet'
|
|
: [
|
|
if (down > 0) '$down down',
|
|
if (unknown > 0) '$unknown unknown',
|
|
].isEmpty
|
|
? 'All running'
|
|
: [if (down > 0) '$down down', if (unknown > 0) '$unknown unknown'].join(' · ');
|
|
|
|
final history = [...summary.node.backup.history]
|
|
..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
final lastBackup = history.isEmpty ? null : history.first;
|
|
|
|
final updates = [...summary.node.updateHistory]..sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
|
final lastUpdate = updates.isEmpty ? null : updates.first;
|
|
|
|
return Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
SizedBox(
|
|
width: 260,
|
|
child: StatTile(
|
|
label: 'Services',
|
|
value: '$up',
|
|
valueQualifier: '/ ${services.length} up',
|
|
caption: servicesCaption,
|
|
severity: services.isEmpty ? Severity.neutral : servicesSeverity,
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 260,
|
|
child: StatTile(
|
|
label: 'Last backup',
|
|
value: lastBackup == null ? 'Never' : formatRelative(lastBackup.timestamp),
|
|
caption: lastBackup == null
|
|
? 'No backups run yet'
|
|
: (lastBackup.success ? '${lastBackup.durationSeconds}s' : lastBackup.message),
|
|
severity: lastBackup == null ? Severity.neutral : severityForSuccess(lastBackup.success),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 260,
|
|
child: StatTile(
|
|
label: 'Last system update',
|
|
value: lastUpdate == null ? 'Never' : formatRelative(lastUpdate.timestamp),
|
|
caption: lastUpdate == null
|
|
? 'No updates recorded yet'
|
|
: '${lastUpdate.packages.length} package(s)',
|
|
severity: lastUpdate == null ? Severity.neutral : severityForSuccess(lastUpdate.success),
|
|
),
|
|
),
|
|
SizedBox(width: 260, child: _FleetTile(fleetAsync: fleetAsync)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FleetTile extends StatelessWidget {
|
|
const _FleetTile({required this.fleetAsync});
|
|
|
|
final AsyncValue<List<AggregatedNodeStatus>> fleetAsync;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return fleetAsync.when(
|
|
loading: () => const StatTile(
|
|
label: 'Fleet',
|
|
value: '…',
|
|
severity: Severity.neutral,
|
|
),
|
|
error: (error, stackTrace) => StatTile(
|
|
label: 'Fleet',
|
|
value: '—',
|
|
caption: error is ApiException ? error.userMessage : error.toString(),
|
|
severity: Severity.critical,
|
|
),
|
|
data: (nodes) {
|
|
if (nodes.isEmpty) {
|
|
return const StatTile(
|
|
label: 'Fleet',
|
|
value: '0',
|
|
caption: 'No nodes registered',
|
|
severity: Severity.neutral,
|
|
);
|
|
}
|
|
final online = nodes.where((n) => n.error == null).length;
|
|
final unreachable = nodes.where((n) => n.error != null).toList();
|
|
return StatTile(
|
|
label: 'Fleet',
|
|
value: '$online',
|
|
valueQualifier: '/ ${nodes.length} online',
|
|
caption: unreachable.isEmpty
|
|
? 'All nodes reachable'
|
|
: 'unreachable: ${unreachable.map((n) => n.node.name).join(', ')}',
|
|
severity: unreachable.isEmpty ? Severity.good : Severity.critical,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|