initial commit
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user