442 lines
13 KiB
Dart
442 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../../../core/config/api_endpoints.dart';
|
||
import '../../../../core/network/dio_client.dart';
|
||
import '../../../../core/theme/app_colors.dart';
|
||
import '../../../../core/utils/date_formatter.dart';
|
||
import '../../../../core/widgets/empty_state.dart';
|
||
import '../../../../core/widgets/status_badge.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// TODO 38 – Dashboard providers & page
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Summary model for server fleet status.
|
||
class _ServerSummary {
|
||
final int total;
|
||
final int online;
|
||
final int warning;
|
||
final int offline;
|
||
|
||
const _ServerSummary({
|
||
required this.total,
|
||
required this.online,
|
||
required this.warning,
|
||
required this.offline,
|
||
});
|
||
}
|
||
|
||
/// Summary model for alert severity counts.
|
||
class _AlertSummary {
|
||
final int critical;
|
||
final int warning;
|
||
final int info;
|
||
|
||
const _AlertSummary({
|
||
required this.critical,
|
||
required this.warning,
|
||
required this.info,
|
||
});
|
||
|
||
int get total => critical + warning + info;
|
||
}
|
||
|
||
/// Fetches server list and computes online/warning/offline counts.
|
||
final serverSummaryProvider = FutureProvider<_ServerSummary>((ref) async {
|
||
final dio = ref.watch(dioClientProvider);
|
||
final response = await dio.get(ApiEndpoints.servers);
|
||
final servers = (response.data as List?) ?? [];
|
||
|
||
int online = 0;
|
||
int warning = 0;
|
||
int offline = 0;
|
||
|
||
for (final s in servers) {
|
||
final status = (s['status'] as String? ?? '').toLowerCase();
|
||
switch (status) {
|
||
case 'online':
|
||
case 'healthy':
|
||
case 'running':
|
||
case 'active':
|
||
online++;
|
||
break;
|
||
case 'warning':
|
||
case 'degraded':
|
||
warning++;
|
||
break;
|
||
default:
|
||
offline++;
|
||
}
|
||
}
|
||
|
||
return _ServerSummary(
|
||
total: servers.length,
|
||
online: online,
|
||
warning: warning,
|
||
offline: offline,
|
||
);
|
||
});
|
||
|
||
/// Fetches alert events and counts by severity.
|
||
final alertSummaryProvider = FutureProvider<_AlertSummary>((ref) async {
|
||
final dio = ref.watch(dioClientProvider);
|
||
final response = await dio.get(ApiEndpoints.alertEvents);
|
||
final events = (response.data as List?) ?? [];
|
||
|
||
int critical = 0;
|
||
int warning = 0;
|
||
int info = 0;
|
||
|
||
for (final e in events) {
|
||
final severity = (e['severity'] as String? ?? '').toLowerCase();
|
||
switch (severity) {
|
||
case 'critical':
|
||
case 'error':
|
||
critical++;
|
||
break;
|
||
case 'warning':
|
||
warning++;
|
||
break;
|
||
default:
|
||
info++;
|
||
}
|
||
}
|
||
|
||
return _AlertSummary(critical: critical, warning: warning, info: info);
|
||
});
|
||
|
||
/// Fetches the 5 most recent ops tasks.
|
||
final recentOpsProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||
final dio = ref.watch(dioClientProvider);
|
||
final response = await dio.get(
|
||
ApiEndpoints.opsTasks,
|
||
queryParameters: {'limit': 5},
|
||
);
|
||
final data = response.data;
|
||
if (data is List) {
|
||
return data.cast<Map<String, dynamic>>();
|
||
}
|
||
if (data is Map && data.containsKey('items')) {
|
||
return (data['items'] as List).cast<Map<String, dynamic>>();
|
||
}
|
||
return [];
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Dashboard page – ConsumerWidget
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class DashboardPage extends ConsumerWidget {
|
||
const DashboardPage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final serverAsync = ref.watch(serverSummaryProvider);
|
||
final alertAsync = ref.watch(alertSummaryProvider);
|
||
final recentOpsAsync = ref.watch(recentOpsProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('仪表盘'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.refresh),
|
||
onPressed: () {
|
||
ref.invalidate(serverSummaryProvider);
|
||
ref.invalidate(alertSummaryProvider);
|
||
ref.invalidate(recentOpsProvider);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
body: RefreshIndicator(
|
||
onRefresh: () async {
|
||
ref.invalidate(serverSummaryProvider);
|
||
ref.invalidate(alertSummaryProvider);
|
||
ref.invalidate(recentOpsProvider);
|
||
},
|
||
child: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
// ------------- Summary cards row ---------------------------------
|
||
const Text(
|
||
'概览',
|
||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
// Server card
|
||
Expanded(child: _buildServerCard(serverAsync)),
|
||
const SizedBox(width: 12),
|
||
// Alert card
|
||
Expanded(child: _buildAlertCard(alertAsync)),
|
||
const SizedBox(width: 12),
|
||
// Tasks card
|
||
Expanded(child: _buildTasksCard(recentOpsAsync)),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
// ------------- Recent operations --------------------------------
|
||
const Text(
|
||
'最近操作',
|
||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildRecentOps(recentOpsAsync),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Card builders -------------------------------------------------------
|
||
|
||
Widget _buildServerCard(AsyncValue<_ServerSummary> value) {
|
||
return value.when(
|
||
data: (summary) => _SummaryCard(
|
||
icon: Icons.dns,
|
||
iconColor: AppColors.success,
|
||
title: '服务器',
|
||
mainValue: '${summary.online}/${summary.total}',
|
||
subtitle: '在线',
|
||
details: [
|
||
if (summary.warning > 0)
|
||
_DetailRow(label: '告警', count: summary.warning, color: AppColors.warning),
|
||
if (summary.offline > 0)
|
||
_DetailRow(label: '离线', count: summary.offline, color: AppColors.error),
|
||
],
|
||
),
|
||
loading: () => const _SummaryCardLoading(),
|
||
error: (e, _) => _SummaryCardError(message: e.toString()),
|
||
);
|
||
}
|
||
|
||
Widget _buildAlertCard(AsyncValue<_AlertSummary> value) {
|
||
return value.when(
|
||
data: (summary) => _SummaryCard(
|
||
icon: Icons.warning_amber_rounded,
|
||
iconColor: summary.critical > 0 ? AppColors.error : AppColors.warning,
|
||
title: '告警',
|
||
mainValue: '${summary.total}',
|
||
subtitle: '活跃',
|
||
details: [
|
||
if (summary.critical > 0)
|
||
_DetailRow(label: '严重', count: summary.critical, color: AppColors.error),
|
||
if (summary.warning > 0)
|
||
_DetailRow(label: '警告', count: summary.warning, color: AppColors.warning),
|
||
if (summary.info > 0)
|
||
_DetailRow(label: '信息', count: summary.info, color: AppColors.info),
|
||
],
|
||
),
|
||
loading: () => const _SummaryCardLoading(),
|
||
error: (e, _) => _SummaryCardError(message: e.toString()),
|
||
);
|
||
}
|
||
|
||
Widget _buildTasksCard(AsyncValue<List<Map<String, dynamic>>> value) {
|
||
return value.when(
|
||
data: (tasks) {
|
||
final running =
|
||
tasks.where((t) => (t['status'] as String?)?.toLowerCase() == 'running').length;
|
||
return _SummaryCard(
|
||
icon: Icons.assignment,
|
||
iconColor: AppColors.info,
|
||
title: '任务',
|
||
mainValue: '$running',
|
||
subtitle: '运行中',
|
||
details: [
|
||
_DetailRow(label: '最近', count: tasks.length, color: AppColors.textSecondary),
|
||
],
|
||
);
|
||
},
|
||
loading: () => const _SummaryCardLoading(),
|
||
error: (e, _) => _SummaryCardError(message: e.toString()),
|
||
);
|
||
}
|
||
|
||
// ---- Recent ops list ------------------------------------------------------
|
||
|
||
Widget _buildRecentOps(AsyncValue<List<Map<String, dynamic>>> value) {
|
||
return value.when(
|
||
data: (tasks) {
|
||
if (tasks.isEmpty) {
|
||
return const EmptyState(
|
||
icon: Icons.assignment_outlined,
|
||
title: '暂无操作记录',
|
||
subtitle: '操作任务将显示在此处',
|
||
);
|
||
}
|
||
return Column(
|
||
children: tasks.map((task) {
|
||
final title = task['title'] as String? ?? task['name'] as String? ?? '未命名';
|
||
final status = task['status'] as String? ?? 'unknown';
|
||
final createdAt = task['created_at'] as String? ?? task['createdAt'] as String?;
|
||
final timeLabel = createdAt != null
|
||
? DateFormatter.timeAgo(DateTime.parse(createdAt))
|
||
: '';
|
||
|
||
return Card(
|
||
color: AppColors.surface,
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
child: ListTile(
|
||
title: Text(title),
|
||
subtitle: Text(timeLabel, style: const TextStyle(color: AppColors.textSecondary)),
|
||
trailing: StatusBadge(status: status),
|
||
),
|
||
);
|
||
}).toList(),
|
||
);
|
||
},
|
||
loading: () => const Center(
|
||
child: Padding(
|
||
padding: EdgeInsets.all(32),
|
||
child: CircularProgressIndicator(),
|
||
),
|
||
),
|
||
error: (e, _) => Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(32),
|
||
child: Text('加载失败: $e', style: const TextStyle(color: AppColors.error)),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Private helper widgets
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class _DetailRow {
|
||
final String label;
|
||
final int count;
|
||
final Color color;
|
||
const _DetailRow({required this.label, required this.count, required this.color});
|
||
}
|
||
|
||
class _SummaryCard extends StatelessWidget {
|
||
final IconData icon;
|
||
final Color iconColor;
|
||
final String title;
|
||
final String mainValue;
|
||
final String subtitle;
|
||
final List<_DetailRow> details;
|
||
|
||
const _SummaryCard({
|
||
required this.icon,
|
||
required this.iconColor,
|
||
required this.title,
|
||
required this.mainValue,
|
||
required this.subtitle,
|
||
this.details = const [],
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
color: AppColors.surface,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(icon, color: iconColor, size: 20),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(
|
||
title,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
mainValue,
|
||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||
),
|
||
Text(subtitle, style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
|
||
if (details.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
...details.map((d) => Padding(
|
||
padding: const EdgeInsets.only(top: 2),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(color: d.color, shape: BoxShape.circle),
|
||
),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'${d.count} ${d.label}',
|
||
style: TextStyle(color: d.color, fontSize: 11),
|
||
),
|
||
],
|
||
),
|
||
)),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SummaryCardLoading extends StatelessWidget {
|
||
const _SummaryCardLoading();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const Card(
|
||
color: AppColors.surface,
|
||
child: SizedBox(
|
||
height: 140,
|
||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SummaryCardError extends StatelessWidget {
|
||
final String message;
|
||
const _SummaryCardError({required this.message});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
color: AppColors.surface,
|
||
child: SizedBox(
|
||
height: 140,
|
||
child: Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
const Icon(Icons.error_outline, color: AppColors.error, size: 28),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
message,
|
||
style: const TextStyle(color: AppColors.error, fontSize: 11),
|
||
textAlign: TextAlign.center,
|
||
maxLines: 3,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|