1278 lines
42 KiB
Dart
1278 lines
42 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'dart:async';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import '../../../../shared/design_system/tokens/color_tokens.dart';
|
|
import '../../../../shared/design_system/tokens/spacing_tokens.dart';
|
|
import '../../../../shared/design_system/tokens/typography_tokens.dart';
|
|
import '../../../../shared/design_system/components/cards/uf_metric_card.dart';
|
|
import '../../../../shared/design_system/components/cards/uf_info_card.dart';
|
|
import '../../../../shared/design_system/components/inputs/uf_switch_tile.dart';
|
|
import '../../../../shared/design_system/components/inputs/uf_dropdown_tile.dart';
|
|
import '../../../../core/di/injection_container.dart';
|
|
import '../bloc/logs_monitoring_bloc.dart';
|
|
|
|
/// Page Logs & Monitoring - UnionFlow Mobile
|
|
///
|
|
/// Page complète de consultation des logs système avec monitoring en temps réel,
|
|
/// alertes, métriques système et gestion avancée des journaux.
|
|
class LogsPage extends StatefulWidget {
|
|
const LogsPage({super.key});
|
|
|
|
@override
|
|
State<LogsPage> createState() => _LogsPageState();
|
|
}
|
|
|
|
class _LogsPageState extends State<LogsPage>
|
|
with TickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
late Timer _refreshTimer;
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
// États de filtrage
|
|
String _selectedLevel = 'Tous';
|
|
String _selectedTimeRange = 'Dernières 24h';
|
|
String _selectedSource = 'Tous';
|
|
String _searchQuery = '';
|
|
bool _autoRefresh = true;
|
|
bool _isLiveMode = false;
|
|
|
|
// Données de configuration
|
|
final List<String> _levels = ['Tous', 'CRITICAL', 'ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
|
|
final List<String> _timeRanges = ['Temps réel', 'Dernière heure', 'Dernières 24h', 'Dernière semaine', 'Dernier mois'];
|
|
final List<String> _sources = ['Tous', 'API', 'Auth', 'Database', 'Cache', 'Security', 'Performance', 'System'];
|
|
|
|
// Métriques système
|
|
final Map<String, dynamic> _systemMetrics = {
|
|
'cpu': 23.5,
|
|
'memory': 67.2,
|
|
'disk': 45.8,
|
|
'network': 12.3,
|
|
'activeConnections': 1247,
|
|
'errorRate': 0.02,
|
|
'responseTime': 127,
|
|
'uptime': '15j 7h 23m',
|
|
};
|
|
|
|
// Alertes actives
|
|
final List<Map<String, dynamic>> _activeAlerts = [
|
|
{
|
|
'id': 'alert_001',
|
|
'level': 'WARNING',
|
|
'title': 'CPU élevé',
|
|
'message': 'Utilisation CPU > 80% pendant 5 minutes',
|
|
'timestamp': DateTime.now().subtract(const Duration(minutes: 12)),
|
|
'acknowledged': false,
|
|
},
|
|
{
|
|
'id': 'alert_002',
|
|
'level': 'INFO',
|
|
'title': 'Sauvegarde terminée',
|
|
'message': 'Sauvegarde automatique réussie (2.3 GB)',
|
|
'timestamp': DateTime.now().subtract(const Duration(hours: 2)),
|
|
'acknowledged': true,
|
|
},
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 5, vsync: this);
|
|
_startAutoRefresh();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
_searchController.dispose();
|
|
if (_autoRefresh) {
|
|
_refreshTimer.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
void _startAutoRefresh() {
|
|
if (_autoRefresh) {
|
|
_refreshTimer = Timer.periodic(const Duration(seconds: 5), (timer) {
|
|
if (mounted && _isLiveMode) {
|
|
setState(() {
|
|
// Simuler l'arrivée de nouveaux logs
|
|
_updateSystemMetrics();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
void _updateSystemMetrics() {
|
|
setState(() {
|
|
_systemMetrics['cpu'] = 20 + (DateTime.now().millisecond % 40);
|
|
_systemMetrics['memory'] = 60 + (DateTime.now().millisecond % 20);
|
|
_systemMetrics['network'] = 10 + (DateTime.now().millisecond % 15);
|
|
_systemMetrics['activeConnections'] = 1200 + (DateTime.now().millisecond % 100);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocProvider(
|
|
create: (_) => sl<LogsMonitoringBloc>()..add(LoadMetrics()),
|
|
child: BlocConsumer<LogsMonitoringBloc, LogsMonitoringState>(
|
|
listener: (context, state) {
|
|
if (state is LogsMonitoringSuccess) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(state.message),
|
|
backgroundColor: ColorTokens.success,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
} else if (state is LogsMonitoringError) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(state.error),
|
|
backgroundColor: ColorTokens.error,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
builder: (context, state) {
|
|
// Mettre à jour les métriques avec les données du state
|
|
if (state is MetricsLoaded) {
|
|
_updateSystemMetricsFromState(state.metrics);
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: ColorTokens.background,
|
|
body: Column(
|
|
children: [
|
|
_buildHeader(),
|
|
_buildTabBar(),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildDashboardTab(),
|
|
_buildLogsTab(),
|
|
_buildAlertsTab(),
|
|
_buildMetricsTab(),
|
|
_buildSettingsTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Header avec métriques système en temps réel
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
margin: const EdgeInsets.all(SpacingTokens.lg),
|
|
padding: const EdgeInsets.all(SpacingTokens.xl),
|
|
decoration: BoxDecoration(
|
|
gradient: const LinearGradient(
|
|
colors: ColorTokens.primaryGradient,
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: ColorTokens.primary.withOpacity(0.3),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
|
),
|
|
child: const Icon(Icons.monitor_heart, color: Colors.white, size: 24),
|
|
),
|
|
const SizedBox(width: SpacingTokens.xl),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Logs & Monitoring',
|
|
style: TypographyTokens.headlineSmall.copyWith(color: Colors.white),
|
|
),
|
|
Text(
|
|
'Surveillance système en temps réel',
|
|
style: TypographyTokens.bodyMedium.copyWith(color: Colors.white.withOpacity(0.8)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: _isLiveMode ? ColorTokens.success.withOpacity(0.3) : Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
|
),
|
|
child: IconButton(
|
|
onPressed: () => _toggleLiveMode(),
|
|
icon: Icon(
|
|
_isLiveMode ? Icons.stop : Icons.play_arrow,
|
|
color: Colors.white,
|
|
),
|
|
tooltip: _isLiveMode ? 'Arrêter le mode temps réel' : 'Mode temps réel',
|
|
),
|
|
),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
|
|
),
|
|
child: IconButton(
|
|
onPressed: () => _showExportDialog(),
|
|
icon: const Icon(Icons.download, color: Colors.white),
|
|
tooltip: 'Exporter les données',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.xl),
|
|
// Métriques système en temps réel
|
|
Row(
|
|
children: [
|
|
Expanded(child: UFMetricCard(label: 'CPU', value: '${_systemMetrics['cpu']?.toStringAsFixed(1)}%', icon: Icons.memory, color: _getCpuColor())),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Expanded(child: UFMetricCard(label: 'RAM', value: '${_systemMetrics['memory']?.toStringAsFixed(1)}%', icon: Icons.storage, color: _getMemoryColor())),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Expanded(child: UFMetricCard(label: 'Réseau', value: '${_systemMetrics['network']?.toStringAsFixed(1)} MB/s', icon: Icons.network_check, color: ColorTokens.info)),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.lg),
|
|
Row(
|
|
children: [
|
|
Expanded(child: UFMetricCard(label: 'Connexions', value: '${_systemMetrics['activeConnections']}', icon: Icons.people, color: ColorTokens.success)),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Expanded(child: UFMetricCard(label: 'Erreurs/min', value: '${(_systemMetrics['errorRate']! * 100).toStringAsFixed(1)}', icon: Icons.error, color: ColorTokens.error)),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Expanded(child: UFMetricCard(label: 'Uptime', value: _systemMetrics['uptime'], icon: Icons.schedule, color: ColorTokens.secondary)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getCpuColor() {
|
|
final cpu = _systemMetrics['cpu'] as double;
|
|
if (cpu > 80) return ColorTokens.error;
|
|
if (cpu > 60) return ColorTokens.warning;
|
|
return ColorTokens.success;
|
|
}
|
|
|
|
Color _getMemoryColor() {
|
|
final memory = _systemMetrics['memory'] as double;
|
|
if (memory > 85) return ColorTokens.error;
|
|
if (memory > 70) return ColorTokens.warning;
|
|
return ColorTokens.success;
|
|
}
|
|
|
|
|
|
|
|
/// Barre d'onglets réorganisée
|
|
Widget _buildTabBar() {
|
|
return Container(
|
|
margin: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg),
|
|
decoration: BoxDecoration(
|
|
color: ColorTokens.surface,
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
labelColor: ColorTokens.primary,
|
|
unselectedLabelColor: ColorTokens.onSurfaceVariant,
|
|
indicatorColor: ColorTokens.primary,
|
|
indicatorWeight: 3,
|
|
labelStyle: TypographyTokens.labelSmall.copyWith(fontWeight: FontWeight.w600),
|
|
isScrollable: true,
|
|
tabs: const [
|
|
Tab(icon: Icon(Icons.dashboard, size: 16), text: 'Dashboard'),
|
|
Tab(icon: Icon(Icons.list_alt, size: 16), text: 'Logs'),
|
|
Tab(icon: Icon(Icons.notification_important, size: 16), text: 'Alertes'),
|
|
Tab(icon: Icon(Icons.analytics, size: 16), text: 'Métriques'),
|
|
Tab(icon: Icon(Icons.settings, size: 16), text: 'Config'),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==================== MÉTHODES D'ACTION ====================
|
|
|
|
void _toggleLiveMode() {
|
|
setState(() {
|
|
_isLiveMode = !_isLiveMode;
|
|
if (_isLiveMode) {
|
|
_selectedTimeRange = 'Temps réel';
|
|
_startAutoRefresh();
|
|
} else {
|
|
if (_autoRefresh) {
|
|
_refreshTimer.cancel();
|
|
}
|
|
}
|
|
});
|
|
_showSuccessSnackBar(_isLiveMode ? 'Mode temps réel activé' : 'Mode temps réel désactivé');
|
|
}
|
|
|
|
void _showExportDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Exporter les données'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('Sélectionnez les données à exporter :'),
|
|
const SizedBox(height: 16),
|
|
CheckboxListTile(
|
|
title: const Text('Logs système'),
|
|
value: true,
|
|
onChanged: (value) {},
|
|
),
|
|
CheckboxListTile(
|
|
title: const Text('Métriques'),
|
|
value: true,
|
|
onChanged: (value) {},
|
|
),
|
|
CheckboxListTile(
|
|
title: const Text('Alertes'),
|
|
value: false,
|
|
onChanged: (value) {},
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
_exportLogs();
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: ColorTokens.primary,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Exporter'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _exportLogs() {
|
|
_showSuccessSnackBar('Export des données lancé - Vous recevrez un email');
|
|
}
|
|
|
|
// ==================== ONGLETS PRINCIPAUX ====================
|
|
|
|
/// Onglet Dashboard - Vue d'ensemble
|
|
Widget _buildDashboardTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: SpacingTokens.xl),
|
|
_buildSystemStatus(),
|
|
const SizedBox(height: SpacingTokens.xl),
|
|
_buildQuickStats(),
|
|
const SizedBox(height: SpacingTokens.xl),
|
|
_buildRecentAlerts(),
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Statut système
|
|
Widget _buildSystemStatus() {
|
|
return UFInfoCard(
|
|
title: 'État du système',
|
|
icon: Icons.health_and_safety,
|
|
trailing: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg, vertical: SpacingTokens.sm),
|
|
decoration: BoxDecoration(
|
|
color: ColorTokens.success,
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusCircular),
|
|
),
|
|
child: Text(
|
|
'OPÉRATIONNEL',
|
|
style: TypographyTokens.labelSmall.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildServiceStatus('API Server', true)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildServiceStatus('Database', true)),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.lg),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildServiceStatus('Keycloak', true)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildServiceStatus('CDN', false)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildServiceStatus(String service, bool isOnline) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
|
decoration: BoxDecoration(
|
|
color: isOnline ? ColorTokens.success.withOpacity(0.05) : ColorTokens.error.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
|
border: Border.all(
|
|
color: isOnline ? ColorTokens.success.withOpacity(0.2) : ColorTokens.error.withOpacity(0.2),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.circle,
|
|
color: isOnline ? ColorTokens.success : ColorTokens.error,
|
|
size: 12,
|
|
),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Expanded(
|
|
child: Text(
|
|
service,
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorTokens.onSurface,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
isOnline ? 'OK' : 'DOWN',
|
|
style: TypographyTokens.labelSmall.copyWith(
|
|
color: isOnline ? ColorTokens.success : ColorTokens.error,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Statistiques rapides
|
|
Widget _buildQuickStats() {
|
|
return UFInfoCard(
|
|
title: 'Statistiques (dernières 24h)',
|
|
icon: Icons.speed,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildStatItem('Logs totaux', '15,247', Icons.list_alt, ColorTokens.info)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildStatItem('Erreurs', '23', Icons.error, ColorTokens.error)),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.lg),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildStatItem('Warnings', '156', Icons.warning, ColorTokens.warning)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildStatItem('Temps réponse', '127ms', Icons.timer, ColorTokens.success)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatItem(String label, String value, IconData icon, Color color) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
|
border: Border.all(color: color.withOpacity(0.1)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(icon, color: color, size: 20),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
Text(
|
|
value,
|
|
style: TypographyTokens.titleMedium.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
Text(
|
|
label,
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Alertes récentes
|
|
Widget _buildRecentAlerts() {
|
|
return UFInfoCard(
|
|
title: 'Alertes récentes',
|
|
icon: Icons.notification_important,
|
|
trailing: TextButton(
|
|
onPressed: () => _tabController.animateTo(2),
|
|
child: const Text('Voir tout'),
|
|
),
|
|
child: Column(
|
|
children: _activeAlerts.take(3).map((alert) => _buildAlertItem(alert)).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAlertItem(Map<String, dynamic> alert) {
|
|
final color = _getAlertColor(alert['level']);
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: SpacingTokens.lg),
|
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
|
border: Border.all(color: color.withOpacity(0.2)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
_getAlertIcon(alert['level']),
|
|
color: color,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
alert['title'],
|
|
style: TypographyTokens.bodyMedium.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
Text(
|
|
alert['message'],
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Text(
|
|
_formatTimestamp(alert['timestamp']),
|
|
style: TypographyTokens.labelSmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Onglet Logs - Consultation détaillée
|
|
Widget _buildLogsTab() {
|
|
return Column(
|
|
children: [
|
|
_buildLogsFilters(),
|
|
Expanded(child: _buildLogsList()),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildLogsFilters() {
|
|
return Container(
|
|
margin: const EdgeInsets.all(SpacingTokens.lg),
|
|
padding: const EdgeInsets.all(SpacingTokens.xl),
|
|
decoration: BoxDecoration(
|
|
color: ColorTokens.surface,
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusXl),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
),
|
|
child: TextField(
|
|
controller: _searchController,
|
|
onChanged: (value) => setState(() => _searchQuery = value),
|
|
decoration: const InputDecoration(
|
|
hintText: 'Rechercher dans les logs...',
|
|
prefixIcon: Icon(Icons.search),
|
|
border: InputBorder.none,
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_buildFilterChip(_selectedLevel, _levels),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildFilterChip(_selectedTimeRange, _timeRanges)),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: _buildFilterChip(_selectedSource, _sources)),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton.icon(
|
|
onPressed: () => _clearFilters(),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.grey[100],
|
|
foregroundColor: Colors.grey[700],
|
|
elevation: 0,
|
|
),
|
|
icon: const Icon(Icons.clear, size: 16),
|
|
label: const Text('Reset'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChip(String value, List<String> options) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: value,
|
|
isExpanded: true,
|
|
onChanged: (newValue) {
|
|
setState(() {
|
|
if (options == _levels) _selectedLevel = newValue!;
|
|
if (options == _timeRanges) _selectedTimeRange = newValue!;
|
|
if (options == _sources) _selectedSource = newValue!;
|
|
});
|
|
},
|
|
items: options.map((option) => DropdownMenuItem(value: option, child: Text(option))).toList(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLogsList() {
|
|
final logs = _getFilteredLogs();
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
|
itemCount: logs.length,
|
|
itemBuilder: (context, index) => _buildLogEntry(logs[index]),
|
|
);
|
|
}
|
|
|
|
Widget _buildLogEntry(Map<String, dynamic> log) {
|
|
final color = _getLogColor(log['level']);
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: SpacingTokens.md),
|
|
padding: const EdgeInsets.all(SpacingTokens.lg),
|
|
decoration: BoxDecoration(
|
|
color: ColorTokens.surface,
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
|
border: Border.all(color: color.withOpacity(0.2)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 4,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm),
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(SpacingTokens.radiusLg),
|
|
),
|
|
child: Text(
|
|
log['level'],
|
|
style: TypographyTokens.labelSmall.copyWith(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: SpacingTokens.md),
|
|
Text(
|
|
log['timestamp'],
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
log['source'],
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
Text(
|
|
log['message'],
|
|
style: TypographyTokens.bodyMedium.copyWith(
|
|
color: ColorTokens.onSurface,
|
|
),
|
|
),
|
|
if (log['details'] != null) ...[
|
|
const SizedBox(height: SpacingTokens.sm),
|
|
Text(
|
|
log['details'],
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Onglet Alertes
|
|
Widget _buildAlertsTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
_buildAlertsConfiguration(),
|
|
const SizedBox(height: 16),
|
|
_buildActiveAlerts(),
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAlertsConfiguration() {
|
|
return UFInfoCard(
|
|
title: 'Configuration des alertes',
|
|
icon: Icons.tune,
|
|
child: Column(
|
|
children: [
|
|
UFSwitchTile(
|
|
title: 'CPU élevé',
|
|
subtitle: 'Alerte si CPU > 80% pendant 5 min',
|
|
value: true,
|
|
onChanged: (value) => _showSuccessSnackBar('Alerte ${value ? 'activée' : 'désactivée'}'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Mémoire faible',
|
|
subtitle: 'Alerte si RAM < 20%',
|
|
value: true,
|
|
onChanged: (value) => _showSuccessSnackBar('Alerte ${value ? 'activée' : 'désactivée'}'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Erreurs critiques',
|
|
subtitle: 'Alerte pour toute erreur CRITICAL',
|
|
value: true,
|
|
onChanged: (value) => _showSuccessSnackBar('Alerte ${value ? 'activée' : 'désactivée'}'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Connexions échouées',
|
|
subtitle: 'Alerte si > 100 échecs/min',
|
|
value: false,
|
|
onChanged: (value) => _showSuccessSnackBar('Alerte ${value ? 'activée' : 'désactivée'}'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
|
|
Widget _buildActiveAlerts() {
|
|
return UFInfoCard(
|
|
title: 'Alertes actives',
|
|
icon: Icons.notifications_active,
|
|
child: Column(
|
|
children: _activeAlerts.map((alert) => _buildDetailedAlertItem(alert)).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailedAlertItem(Map<String, dynamic> alert) {
|
|
final color = _getAlertColor(alert['level']);
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withOpacity(0.2)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(_getAlertIcon(alert['level']), color: color, size: 20),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
alert['title'],
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
),
|
|
if (!alert['acknowledged'])
|
|
ElevatedButton(
|
|
onPressed: () => _acknowledgeAlert(alert['id']),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: color,
|
|
foregroundColor: Colors.white,
|
|
minimumSize: const Size(80, 32),
|
|
),
|
|
child: const Text('Acquitter', style: TextStyle(fontSize: 12)),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
alert['message'],
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Il y a ${_formatTimestamp(alert['timestamp'])}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[500],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Onglet Métriques
|
|
Widget _buildMetricsTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
_buildSystemMetrics(),
|
|
const SizedBox(height: 16),
|
|
_buildPerformanceMetrics(),
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSystemMetrics() {
|
|
return UFInfoCard(
|
|
title: 'Métriques système',
|
|
icon: Icons.computer,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildMetricProgress('CPU', _systemMetrics['cpu'], '%', _getCpuColor())),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildMetricProgress('Mémoire', _systemMetrics['memory'], '%', _getMemoryColor())),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.lg),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildMetricProgress('Disque', _systemMetrics['disk'], '%', ColorTokens.warning)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildMetricValue('Uptime', _systemMetrics['uptime'], '', ColorTokens.secondary)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricProgress(String label, double value, String unit, Color color) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TypographyTokens.bodyMedium.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorTokens.onSurface,
|
|
),
|
|
),
|
|
Text(
|
|
'${value.toStringAsFixed(1)}$unit',
|
|
style: TypographyTokens.bodyMedium.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
LinearProgressIndicator(
|
|
value: value / 100,
|
|
backgroundColor: ColorTokens.surfaceVariant,
|
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricValue(String label, dynamic value, String unit, Color color) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TypographyTokens.bodyMedium.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
color: ColorTokens.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
Text(
|
|
'$value$unit',
|
|
style: TypographyTokens.headlineSmall.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPerformanceMetrics() {
|
|
return UFInfoCard(
|
|
title: 'Métriques de performance',
|
|
icon: Icons.speed,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildMetricValue('Connexions actives', _systemMetrics['activeConnections'], '', ColorTokens.success)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildMetricValue('Temps de réponse', '${_systemMetrics['responseTime']}', 'ms', ColorTokens.info)),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.lg),
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildMetricValue('Taux d\'erreur', '${(_systemMetrics['errorRate']! * 100).toStringAsFixed(2)}', '%', ColorTokens.error)),
|
|
const SizedBox(width: SpacingTokens.lg),
|
|
Expanded(child: _buildMetricValue('Réseau', '${_systemMetrics['network']?.toStringAsFixed(1)}', ' MB/s', ColorTokens.warning)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Onglet Configuration
|
|
Widget _buildSettingsTab() {
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
_buildLoggingSettings(),
|
|
const SizedBox(height: 16),
|
|
_buildMonitoringSettings(),
|
|
const SizedBox(height: 80),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLoggingSettings() {
|
|
return UFInfoCard(
|
|
title: 'Configuration des logs',
|
|
icon: Icons.settings,
|
|
child: Column(
|
|
children: [
|
|
UFDropdownTile<String>(
|
|
title: 'Niveau de log',
|
|
value: 'INFO',
|
|
items: const ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'CRITICAL'],
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre mis à jour'),
|
|
),
|
|
UFDropdownTile<String>(
|
|
title: 'Rétention',
|
|
value: '30 jours',
|
|
items: const ['7 jours', '30 jours', '90 jours', '1 an'],
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre mis à jour'),
|
|
),
|
|
UFDropdownTile<String>(
|
|
title: 'Format',
|
|
value: 'JSON',
|
|
items: const ['JSON', 'Plain Text', 'Structured'],
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre mis à jour'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Logs détaillés',
|
|
subtitle: 'Inclure les stack traces',
|
|
value: true,
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre ${value ? 'activé' : 'désactivé'}'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Compression',
|
|
subtitle: 'Compresser les anciens logs',
|
|
value: true,
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre ${value ? 'activé' : 'désactivé'}'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMonitoringSettings() {
|
|
return UFInfoCard(
|
|
title: 'Configuration du monitoring',
|
|
icon: Icons.monitor,
|
|
child: Column(
|
|
children: [
|
|
UFDropdownTile<String>(
|
|
title: 'Intervalle de collecte',
|
|
value: '5 secondes',
|
|
items: const ['1 seconde', '5 secondes', '30 secondes', '1 minute'],
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre mis à jour'),
|
|
),
|
|
UFDropdownTile<String>(
|
|
title: 'Historique des métriques',
|
|
value: '7 jours',
|
|
items: const ['1 jour', '7 jours', '30 jours', '90 jours'],
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre mis à jour'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Monitoring temps réel',
|
|
subtitle: 'Mise à jour automatique',
|
|
value: _autoRefresh,
|
|
onChanged: (value) {
|
|
setState(() => _autoRefresh = value);
|
|
if (value) {
|
|
_startAutoRefresh();
|
|
} else {
|
|
_refreshTimer.cancel();
|
|
}
|
|
_showSuccessSnackBar('Paramètre ${value ? 'activé' : 'désactivé'}');
|
|
},
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Alertes email',
|
|
subtitle: 'Notifications par email',
|
|
value: true,
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre ${value ? 'activé' : 'désactivé'}'),
|
|
),
|
|
UFSwitchTile(
|
|
title: 'Alertes push',
|
|
subtitle: 'Notifications push mobile',
|
|
value: false,
|
|
onChanged: (value) => _showSuccessSnackBar('Paramètre ${value ? 'activé' : 'désactivé'}'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
|
|
// ==================== MÉTHODES UTILITAIRES ====================
|
|
|
|
List<Map<String, dynamic>> _getFilteredLogs() {
|
|
List<Map<String, dynamic>> allLogs = [
|
|
{'level': 'CRITICAL', 'timestamp': '16:15:42', 'source': 'Database', 'message': 'Connexion à la base de données perdue', 'details': 'Pool de connexions épuisé'},
|
|
{'level': 'ERROR', 'timestamp': '16:12:33', 'source': 'API', 'message': 'Erreur 500 sur /api/members', 'details': 'NullPointerException dans MemberService.findAll()'},
|
|
{'level': 'WARN', 'timestamp': '16:10:15', 'source': 'Auth', 'message': 'Tentative de connexion avec mot de passe incorrect', 'details': 'IP: 192.168.1.100 - Utilisateur: admin@test.com'},
|
|
{'level': 'INFO', 'timestamp': '16:08:22', 'source': 'System', 'message': 'Sauvegarde automatique terminée', 'details': 'Taille: 2.3 GB - Durée: 45 secondes'},
|
|
{'level': 'DEBUG', 'timestamp': '16:05:45', 'source': 'Cache', 'message': 'Cache invalidé pour user_sessions', 'details': 'Raison: Expiration automatique'},
|
|
{'level': 'TRACE', 'timestamp': '16:03:12', 'source': 'Performance', 'message': 'Requête SQL exécutée', 'details': 'SELECT * FROM members WHERE active = true - Durée: 23ms'},
|
|
];
|
|
|
|
// Filtrage par niveau
|
|
if (_selectedLevel != 'Tous') {
|
|
allLogs = allLogs.where((log) => log['level'] == _selectedLevel).toList();
|
|
}
|
|
|
|
// Filtrage par source
|
|
if (_selectedSource != 'Tous') {
|
|
allLogs = allLogs.where((log) => log['source'] == _selectedSource).toList();
|
|
}
|
|
|
|
// Filtrage par recherche
|
|
if (_searchQuery.isNotEmpty) {
|
|
allLogs = allLogs.where((log) =>
|
|
log['message'].toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
|
log['source'].toLowerCase().contains(_searchQuery.toLowerCase())
|
|
).toList();
|
|
}
|
|
|
|
return allLogs;
|
|
}
|
|
|
|
Color _getLogColor(String level) {
|
|
switch (level) {
|
|
case 'CRITICAL': return ColorTokens.secondary;
|
|
case 'ERROR': return ColorTokens.error;
|
|
case 'WARN': return ColorTokens.warning;
|
|
case 'INFO': return ColorTokens.info;
|
|
case 'DEBUG': return ColorTokens.success;
|
|
case 'TRACE': return ColorTokens.onSurfaceVariant;
|
|
default: return ColorTokens.onSurfaceVariant;
|
|
}
|
|
}
|
|
|
|
Color _getAlertColor(String level) {
|
|
switch (level) {
|
|
case 'CRITICAL': return ColorTokens.secondary;
|
|
case 'ERROR': return ColorTokens.error;
|
|
case 'WARNING': return ColorTokens.warning;
|
|
case 'INFO': return ColorTokens.info;
|
|
default: return ColorTokens.onSurfaceVariant;
|
|
}
|
|
}
|
|
|
|
IconData _getAlertIcon(String level) {
|
|
switch (level) {
|
|
case 'CRITICAL': return Icons.dangerous;
|
|
case 'ERROR': return Icons.error;
|
|
case 'WARNING': return Icons.warning;
|
|
case 'INFO': return Icons.info;
|
|
default: return Icons.notifications;
|
|
}
|
|
}
|
|
|
|
String _formatTimestamp(DateTime timestamp) {
|
|
final now = DateTime.now();
|
|
final difference = now.difference(timestamp);
|
|
|
|
if (difference.inMinutes < 60) {
|
|
return '${difference.inMinutes}min';
|
|
} else if (difference.inHours < 24) {
|
|
return '${difference.inHours}h';
|
|
} else {
|
|
return '${difference.inDays}j';
|
|
}
|
|
}
|
|
|
|
void _acknowledgeAlert(String alertId) {
|
|
setState(() {
|
|
final alert = _activeAlerts.firstWhere((a) => a['id'] == alertId);
|
|
alert['acknowledged'] = true;
|
|
});
|
|
_showSuccessSnackBar('Alerte acquittée');
|
|
}
|
|
|
|
void _clearFilters() {
|
|
setState(() {
|
|
_selectedLevel = 'Tous';
|
|
_selectedTimeRange = 'Dernières 24h';
|
|
_selectedSource = 'Tous';
|
|
_searchQuery = '';
|
|
_searchController.clear();
|
|
});
|
|
_showSuccessSnackBar('Filtres réinitialisés');
|
|
}
|
|
|
|
void _updateSystemMetricsFromState(dynamic metrics) {
|
|
if (metrics == null) return;
|
|
setState(() {
|
|
_systemMetrics['cpu'] = metrics.cpuUsagePercent ?? 23.5;
|
|
_systemMetrics['memory'] = metrics.memoryUsagePercent ?? 67.2;
|
|
_systemMetrics['disk'] = metrics.diskUsagePercent ?? 45.8;
|
|
_systemMetrics['network'] = metrics.networkUsageMbps ?? 12.3;
|
|
_systemMetrics['activeConnections'] = metrics.activeConnections ?? 1247;
|
|
_systemMetrics['errorRate'] = metrics.errorRate ?? 0.02;
|
|
_systemMetrics['responseTime'] = metrics.averageResponseTimeMs ?? 127;
|
|
_systemMetrics['uptime'] = metrics.uptimeFormatted ?? '15j 7h 23m';
|
|
});
|
|
}
|
|
|
|
void _showSuccessSnackBar(String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: ColorTokens.success,
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
);
|
|
}
|
|
}
|