it0/it0_app/lib/features/alerts/presentation/pages/alerts_page.dart

394 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/error_view.dart';
import '../../../../core/widgets/status_badge.dart';
// ---------------------------------------------------------------------------
// Phase 2C Alerts page: list with severity/status filters + acknowledge
// ---------------------------------------------------------------------------
/// Fetches alert events from the backend.
final alertEventsProvider =
FutureProvider<List<Map<String, dynamic>>>((ref) async {
final dio = ref.watch(dioClientProvider);
final response = await dio.get(ApiEndpoints.alertEvents);
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 [];
});
// ---------------------------------------------------------------------------
// Alerts page ConsumerStatefulWidget (needs local filter state)
// ---------------------------------------------------------------------------
class AlertsPage extends ConsumerStatefulWidget {
const AlertsPage({super.key});
@override
ConsumerState<AlertsPage> createState() => _AlertsPageState();
}
class _AlertsPageState extends ConsumerState<AlertsPage> {
String _severityFilter = 'all';
String _statusFilter = 'all';
static const _severities = ['all', 'info', 'warning', 'critical'];
static const _statuses = ['all', 'firing', 'resolved', 'acknowledged'];
List<Map<String, dynamic>> _applyFilters(List<Map<String, dynamic>> alerts) {
return alerts.where((a) {
final severity = (a['severity'] as String? ?? '').toLowerCase();
final status = (a['status'] as String? ?? '').toLowerCase();
if (_severityFilter != 'all' && severity != _severityFilter) return false;
if (_statusFilter != 'all' && status != _statusFilter) return false;
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
final alertsAsync = ref.watch(alertEventsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('告警中心'),
),
body: RefreshIndicator(
onRefresh: () async => ref.invalidate(alertEventsProvider),
child: Column(
children: [
// -- Filter chips ------------------------------------------------
_buildFilterSection(),
// -- Alert list --------------------------------------------------
Expanded(
child: alertsAsync.when(
data: (alerts) {
final filtered = _applyFilters(alerts);
if (filtered.isEmpty) {
return const EmptyState(
icon: Icons.notifications_off_outlined,
title: '暂无告警',
subtitle: '一切正常 — 没有匹配的告警',
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: filtered.length,
itemBuilder: (context, index) {
final alert = filtered[index];
return _AlertCard(
alert: alert,
onAcknowledge: () => _acknowledgeAlert(alert),
);
},
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => ErrorView(
error: e,
onRetry: () => ref.invalidate(alertEventsProvider),
),
),
),
],
),
),
);
}
// ---------- Filter section ------------------------------------------------
Widget _buildFilterSection() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Severity filter row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _severities.map((s) {
final selected = _severityFilter == s;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(s == 'all' ? '全部级别' : s),
selected: selected,
selectedColor: _severityChipColor(s).withOpacity(0.25),
checkmarkColor: _severityChipColor(s),
labelStyle: TextStyle(
color: selected
? _severityChipColor(s)
: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
backgroundColor: AppColors.surface,
side: BorderSide(
color: selected
? _severityChipColor(s).withOpacity(0.5)
: AppColors.surfaceLight,
),
onSelected: (_) =>
setState(() => _severityFilter = s),
),
);
}).toList(),
),
),
const SizedBox(height: 4),
// Status filter row
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _statuses.map((s) {
final selected = _statusFilter == s;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FilterChip(
label: Text(s == 'all' ? '全部状态' : s),
selected: selected,
selectedColor: AppColors.primary.withOpacity(0.25),
checkmarkColor: AppColors.primary,
labelStyle: TextStyle(
color: selected
? AppColors.primary
: AppColors.textSecondary,
fontSize: 12,
fontWeight: FontWeight.w500,
),
backgroundColor: AppColors.surface,
side: BorderSide(
color: selected
? AppColors.primary.withOpacity(0.5)
: AppColors.surfaceLight,
),
onSelected: (_) =>
setState(() => _statusFilter = s),
),
);
}).toList(),
),
),
const SizedBox(height: 4),
],
),
);
}
Color _severityChipColor(String severity) {
switch (severity) {
case 'info':
return AppColors.info;
case 'warning':
return AppColors.warning;
case 'critical':
return AppColors.error;
default:
return AppColors.primary;
}
}
// ---------- Acknowledge action --------------------------------------------
Future<void> _acknowledgeAlert(Map<String, dynamic> alert) async {
final id = alert['id']?.toString();
if (id == null) return;
try {
final dio = ref.read(dioClientProvider);
await dio.patch(
'${ApiEndpoints.alertEvents}/$id',
data: {'status': 'acknowledged'},
);
ref.invalidate(alertEventsProvider);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('告警已确认'),
backgroundColor: AppColors.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('确认失败: $e'),
backgroundColor: AppColors.error,
),
);
}
}
}
}
// ---------------------------------------------------------------------------
// Alert card widget
// ---------------------------------------------------------------------------
class _AlertCard extends StatelessWidget {
final Map<String, dynamic> alert;
final VoidCallback onAcknowledge;
const _AlertCard({required this.alert, required this.onAcknowledge});
@override
Widget build(BuildContext context) {
final message =
alert['message'] as String? ?? alert['name'] as String? ?? '无消息';
final severity = (alert['severity'] as String? ?? 'info').toLowerCase();
final status = (alert['status'] as String? ?? 'unknown').toLowerCase();
final serverName = alert['server_name'] as String? ??
alert['serverName'] as String? ??
alert['hostname'] as String? ??
'';
final createdAt =
alert['created_at'] as String? ?? alert['createdAt'] as String?;
final timeLabel = createdAt != null
? DateFormatter.timeAgo(DateTime.parse(createdAt))
: '';
// Only allow swipe-to-acknowledge if not already acknowledged
final canAcknowledge = status != 'acknowledged';
final card = Card(
color: AppColors.surface,
margin: const EdgeInsets.only(bottom: 10),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Severity icon + message + status badge
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SeverityIcon(severity: severity),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
StatusBadge(status: status),
],
),
const SizedBox(height: 8),
// Server name + timestamp row
Row(
children: [
if (serverName.isNotEmpty) ...[
const Icon(Icons.dns_outlined,
size: 14, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
serverName,
style: const TextStyle(
color: AppColors.textSecondary, fontSize: 12),
),
const SizedBox(width: 12),
],
const Icon(Icons.access_time,
size: 14, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
timeLabel,
style: const TextStyle(
color: AppColors.textMuted, fontSize: 12),
),
],
),
],
),
),
);
if (!canAcknowledge) return card;
return Dismissible(
key: ValueKey(alert['id']),
direction: DismissDirection.endToStart,
confirmDismiss: (_) async {
onAcknowledge();
return false; // don't remove the widget; let the refresh handle it
},
background: Container(
alignment: Alignment.centerRight,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: AppColors.info.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'确认',
style: TextStyle(
color: AppColors.info,
fontWeight: FontWeight.w600,
fontSize: 13),
),
SizedBox(width: 8),
Icon(Icons.check_circle_outline, color: AppColors.info),
],
),
),
child: card,
);
}
}
// ---------------------------------------------------------------------------
// Severity icon helper
// ---------------------------------------------------------------------------
class _SeverityIcon extends StatelessWidget {
final String severity;
const _SeverityIcon({required this.severity});
@override
Widget build(BuildContext context) {
final IconData icon;
final Color color;
switch (severity) {
case 'critical':
icon = Icons.error;
color = AppColors.error;
break;
case 'warning':
icon = Icons.warning;
color = AppColors.warning;
break;
case 'info':
default:
icon = Icons.info;
color = AppColors.info;
break;
}
return Icon(icon, size: 22, color: color);
}
}