diff --git a/FinlyticApp/lib/features/admin/models/log_message_dto.dart b/FinlyticApp/lib/features/admin/models/log_message_dto.dart new file mode 100644 index 0000000..d28dc34 --- /dev/null +++ b/FinlyticApp/lib/features/admin/models/log_message_dto.dart @@ -0,0 +1,44 @@ +class LogMessageDto { + final DateTime timestamp; + final String serviceName; + final String channel; + final String level; // 'Information', 'Warning', 'Error', 'Debug', 'Trace' + final String message; + final String? exception; + + const LogMessageDto({ + required this.timestamp, + required this.serviceName, + required this.channel, + required this.level, + required this.message, + this.exception, + }); + + factory LogMessageDto.fromJson(Map json) { + DateTime parsedTime = DateTime.now(); + if (json['timestamp'] != null) { + parsedTime = DateTime.tryParse(json['timestamp'].toString()) ?? DateTime.now(); + } + + return LogMessageDto( + timestamp: parsedTime.toLocal(), + serviceName: json['serviceName']?.toString() ?? '', + channel: json['channel']?.toString() ?? '', + level: json['level']?.toString() ?? 'Information', + message: json['message']?.toString() ?? '', + exception: json['exception']?.toString(), + ); + } + + Map toJson() { + return { + 'timestamp': timestamp.toUtc().toIso8601String(), + 'serviceName': serviceName, + 'channel': channel, + 'level': level, + 'message': message, + 'exception': exception, + }; + } +} diff --git a/FinlyticApp/lib/features/admin/models/service_setting_dto.dart b/FinlyticApp/lib/features/admin/models/service_setting_dto.dart index a779d86..991edcc 100644 --- a/FinlyticApp/lib/features/admin/models/service_setting_dto.dart +++ b/FinlyticApp/lib/features/admin/models/service_setting_dto.dart @@ -1,11 +1,13 @@ class ServiceSettingDto { final String key; final String value; + final String type; // 'bool', 'int', 'double', 'string' final String description; const ServiceSettingDto({ required this.key, required this.value, + this.type = 'string', this.description = '', }); @@ -13,6 +15,7 @@ class ServiceSettingDto { return ServiceSettingDto( key: json['key']?.toString() ?? '', value: json['value']?.toString() ?? '', + type: json['dataType']?.toString() ?? json['type']?.toString() ?? 'string', description: json['description']?.toString() ?? '', ); } @@ -21,6 +24,7 @@ class ServiceSettingDto { return { 'key': key, 'value': value, + 'type': type, 'description': description, }; } diff --git a/FinlyticApp/lib/features/admin/repositories/admin_repository.dart b/FinlyticApp/lib/features/admin/repositories/admin_repository.dart index e1d9f4f..18b875f 100644 --- a/FinlyticApp/lib/features/admin/repositories/admin_repository.dart +++ b/FinlyticApp/lib/features/admin/repositories/admin_repository.dart @@ -54,7 +54,7 @@ class AdminRepository { return {}; } - Future updateServiceSettings(String serviceName, Map settings) async { + Future updateServiceSettings(String serviceName, Map settings) async { final res = await apiClient.put('/api/v1/admin/settings/$serviceName', data: settings); if (res.statusCode != 200 && res.statusCode != 204) { throw Exception('Einstellungen konnten nicht gespeichert werden'); diff --git a/FinlyticApp/lib/features/admin/views/service_detail_screen.dart b/FinlyticApp/lib/features/admin/views/service_detail_screen.dart index c44225b..bdd4deb 100644 --- a/FinlyticApp/lib/features/admin/views/service_detail_screen.dart +++ b/FinlyticApp/lib/features/admin/views/service_detail_screen.dart @@ -6,6 +6,7 @@ import '../../../core/widgets/glass_container.dart'; import '../../../core/widgets/status_badge.dart'; import '../models/service_setting_dto.dart'; import '../repositories/admin_repository.dart'; +import '../widgets/live_log_console.dart'; class ServiceDetailScreen extends StatefulWidget { final String serviceName; @@ -75,9 +76,20 @@ class _ServiceDetailScreenState extends State { Future _saveSettings() async { setState(() => _isSaving = true); try { - final payload = {}; + final payload = {}; _controllers.forEach((k, v) { - payload[k] = v.text; + final text = v.text.trim(); + if (text.toLowerCase() == 'true') { + payload[k] = true; + } else if (text.toLowerCase() == 'false') { + payload[k] = false; + } else if (int.tryParse(text) != null) { + payload[k] = int.parse(text); + } else if (double.tryParse(text) != null) { + payload[k] = double.parse(text); + } else { + payload[k] = text; + } }); await _repository.updateServiceSettings(widget.serviceName, payload); @@ -170,10 +182,13 @@ class _ServiceDetailScreenState extends State { ..._settings.map((s) { final key = s.key; final desc = s.description; + final type = s.type.toLowerCase(); final controller = _controllers[key]; if (controller == null) return const SizedBox.shrink(); - final isBoolean = controller.text.toLowerCase() == 'true' || controller.text.toLowerCase() == 'false'; + final isBoolean = type == 'bool' || + controller.text.toLowerCase() == 'true' || + controller.text.toLowerCase() == 'false'; if (isBoolean) { final boolVal = controller.text.toLowerCase() == 'true'; @@ -183,7 +198,11 @@ class _ServiceDetailScreenState extends State { decoration: BoxDecoration( color: AppTheme.glassSurface, borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.glassBorder), + border: Border.all( + color: boolVal + ? AppTheme.primaryEmerald.withValues(alpha: 0.4) + : AppTheme.glassBorder, + ), ), child: SwitchListTile( contentPadding: EdgeInsets.zero, @@ -191,21 +210,31 @@ class _ServiceDetailScreenState extends State { subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null, value: boolVal, activeThumbColor: AppTheme.primaryEmerald, + activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3), onChanged: (val) => setState(() => controller.text = val.toString()), ), ); } + final isNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal'; + return Padding( padding: const EdgeInsets.only(bottom: 14), child: TextField( controller: controller, + keyboardType: isNumeric + ? const TextInputType.numberWithOptions(decimal: true) + : TextInputType.text, style: const TextStyle(color: Colors.white), decoration: InputDecoration( labelText: _formatLabel(key), helperText: desc.isNotEmpty ? desc : null, helperMaxLines: 2, - prefixIcon: Icon(Icons.tune_outlined, size: 18, color: AppTheme.primaryEmerald), + prefixIcon: Icon( + isNumeric ? Icons.numbers_outlined : Icons.tune_outlined, + size: 18, + color: AppTheme.primaryEmerald, + ), ), ), ); @@ -236,6 +265,11 @@ class _ServiceDetailScreenState extends State { ), ), const SizedBox(height: 24), + LiveLogConsole( + serviceName: widget.serviceName, + apiClient: widget.apiClient, + ), + const SizedBox(height: 24), GlassContainer( padding: const EdgeInsets.all(24), child: Column( diff --git a/FinlyticApp/lib/features/admin/widgets/live_log_console.dart b/FinlyticApp/lib/features/admin/widgets/live_log_console.dart new file mode 100644 index 0000000..18fef4a --- /dev/null +++ b/FinlyticApp/lib/features/admin/widgets/live_log_console.dart @@ -0,0 +1,383 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:signalr_core/signalr_core.dart'; + +import '../../../core/network/api_client.dart'; +import '../../../core/network/signalr_service.dart'; +import '../../../core/services/secure_storage_service.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../core/widgets/glass_container.dart'; +import '../models/log_message_dto.dart'; + +class LiveLogConsole extends StatefulWidget { + final String serviceName; + final ApiClient apiClient; + + const LiveLogConsole({ + super.key, + required this.serviceName, + required this.apiClient, + }); + + @override + State createState() => _LiveLogConsoleState(); +} + +class _LiveLogConsoleState extends State { + HubConnection? _hubConnection; + final List _logs = []; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + + bool _isConnected = false; + bool _isPaused = false; + bool _autoScroll = true; + String _selectedLevel = 'ALL'; + String _searchQuery = ''; + + @override + void initState() { + super.initState(); + _fetchInitialLogs(); + _connectSignalR(); + } + + @override + void dispose() { + _disconnectSignalR(); + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + Future _fetchInitialLogs() async { + try { + final res = await widget.apiClient.get('/api/v1/admin/settings/logs/${widget.serviceName}'); + if (res.data is List) { + final list = (res.data as List).map((item) => LogMessageDto.fromJson(Map.from(item as Map))).toList(); + if (mounted) { + setState(() { + _logs.addAll(list); + }); + _scrollToBottomIfNeeded(); + } + } + } catch (e) { + if (kDebugMode) debugPrint('[LiveLogConsole] Error fetching initial logs: $e'); + } + } + + Future _connectSignalR() async { + try { + final storage = SecureStorageService(); + final token = await storage.getToken(); + + _hubConnection = HubConnectionBuilder() + .withUrl( + '${SignalRService.baseUrl}/hubs/logs', + HttpConnectionOptions( + accessTokenFactory: () async => token, + transport: HttpTransportType.webSockets, + logging: (level, message) { + if (kDebugMode) debugPrint('[SignalR Logs WS] $message'); + }, + ), + ) + .withAutomaticReconnect() + .build(); + + _hubConnection!.on('ReceiveLogMessage', (arguments) { + if (arguments != null && arguments.isNotEmpty) { + try { + final map = Map.from(arguments.first as Map); + final log = LogMessageDto.fromJson(map); + + if (log.serviceName.isEmpty || log.serviceName.toLowerCase() == widget.serviceName.toLowerCase()) { + if (mounted && !_isPaused) { + setState(() { + _logs.add(log); + if (_logs.length > 500) { + _logs.removeAt(0); + } + }); + _scrollToBottomIfNeeded(); + } + } + } catch (e) { + if (kDebugMode) debugPrint('[SignalR Log Parse Error] $e'); + } + } + }); + + _hubConnection!.onclose((error) { + if (mounted) setState(() => _isConnected = false); + }); + + _hubConnection!.onreconnected((connectionId) { + if (mounted) { + setState(() => _isConnected = true); + _hubConnection?.invoke('JoinServiceLogs', args: [widget.serviceName]); + } + }); + + await _hubConnection!.start(); + await _hubConnection!.invoke('JoinServiceLogs', args: [widget.serviceName]); + + if (mounted) { + setState(() => _isConnected = true); + } + } catch (e) { + if (kDebugMode) debugPrint('[SignalR Log Connection Error] $e'); + if (mounted) setState(() => _isConnected = false); + } + } + + Future _disconnectSignalR() async { + try { + if (_hubConnection != null) { + await _hubConnection!.invoke('LeaveServiceLogs', args: [widget.serviceName]); + await _hubConnection!.stop(); + } + } catch (_) {} + _hubConnection = null; + } + + void _scrollToBottomIfNeeded() { + if (_autoScroll && _scrollController.hasClients) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + }); + } + } + + List get _filteredLogs { + return _logs.where((log) { + if (_selectedLevel != 'ALL' && log.level.toUpperCase() != _selectedLevel) { + return false; + } + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + final matchMsg = log.message.toLowerCase().contains(query); + final matchChannel = log.channel.toLowerCase().contains(query); + return matchMsg || matchChannel; + } + return true; + }).toList(); + } + + Color _getLevelColor(String level) { + switch (level.toUpperCase()) { + case 'ERROR': + case 'CRITICAL': + return Colors.redAccent; + case 'WARNING': + case 'WARN': + return Colors.amberAccent; + case 'DEBUG': + case 'TRACE': + return Colors.blueGrey.shade300; + case 'INFORMATION': + case 'INFO': + default: + return AppTheme.primaryEmerald; + } + } + + String _formatTime(DateTime time) { + final h = time.hour.toString().padLeft(2, '0'); + final m = time.minute.toString().padLeft(2, '0'); + final s = time.second.toString().padLeft(2, '0'); + final ms = time.millisecond.toString().padLeft(3, '0'); + return '$h:$m:$s.$ms'; + } + + @override + Widget build(BuildContext context) { + final filtered = _filteredLogs; + + return GlassContainer( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Bar + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(Icons.terminal_rounded, color: AppTheme.primaryEmerald, size: 22), + const SizedBox(width: 10), + Text( + 'Live Service-Logs (${widget.serviceName})', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _isConnected ? AppTheme.primaryEmerald : Colors.redAccent, + ), + ), + const SizedBox(width: 6), + Text( + _isConnected ? 'Live WebSocket' : 'Offline', + style: TextStyle( + fontSize: 12, + color: _isConnected ? AppTheme.primaryEmerald : Colors.redAccent, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 14), + + // Control Bar: Search + Level Filters + Actions + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + // Search Field + SizedBox( + width: 220, + height: 36, + child: TextField( + controller: _searchController, + onChanged: (val) => setState(() => _searchQuery = val.trim()), + style: const TextStyle(fontSize: 13, color: Colors.white), + decoration: InputDecoration( + hintText: 'Logs durchsuchen...', + hintStyle: const TextStyle(fontSize: 12, color: Colors.white38), + prefixIcon: const Icon(Icons.search, size: 16, color: Colors.white54), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 14, color: Colors.white54), + onPressed: () { + _searchController.clear(); + setState(() => _searchQuery = ''); + }, + ) + : null, + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0), + filled: true, + fillColor: Colors.black26, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), + ), + ), + ), + + // Filter Chips + for (final lvl in ['ALL', 'INFO', 'WARN', 'ERROR', 'DEBUG']) + ChoiceChip( + label: Text(lvl, style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: _selectedLevel == lvl ? Colors.black : Colors.white70)), + selected: _selectedLevel == lvl, + selectedColor: AppTheme.primaryEmerald, + backgroundColor: Colors.white10, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 4), + onSelected: (selected) { + if (selected) setState(() => _selectedLevel = lvl); + }, + ), + + const SizedBox(width: 8), + + // Action buttons + IconButton( + tooltip: _isPaused ? 'Stream Fortsetzen' : 'Stream Pausieren', + icon: Icon(_isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 20, color: _isPaused ? Colors.amberAccent : Colors.white70), + onPressed: () => setState(() => _isPaused = !_isPaused), + ), + IconButton( + tooltip: _autoScroll ? 'Auto-Scroll an' : 'Auto-Scroll aus', + icon: Icon(Icons.vertical_align_bottom_rounded, size: 20, color: _autoScroll ? AppTheme.primaryEmerald : Colors.white38), + onPressed: () => setState(() => _autoScroll = !_autoScroll), + ), + IconButton( + tooltip: 'Konsole leeren', + icon: const Icon(Icons.delete_outline_rounded, size: 20, color: Colors.white54), + onPressed: () => setState(() => _logs.clear()), + ), + ], + ), + const SizedBox(height: 12), + + // Terminal Box + Container( + height: 380, + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0D1117), // Deep dark console + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white12), + ), + child: filtered.isEmpty + ? Center( + child: Text( + _logs.isEmpty ? 'Warte auf Log-Nachrichten von ${widget.serviceName}...' : 'Keine Logs passend zum Filter.', + style: const TextStyle(fontSize: 12, color: Colors.white38, fontStyle: FontStyle.italic), + ), + ) + : ListView.builder( + controller: _scrollController, + itemCount: filtered.length, + itemBuilder: (context, index) { + final item = filtered[index]; + final color = _getLevelColor(item.level); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.5), + child: SelectableText.rich( + TextSpan( + style: const TextStyle(fontFamily: 'monospace', fontSize: 11.5, height: 1.4), + children: [ + TextSpan( + text: '${_formatTime(item.timestamp)} ', + style: const TextStyle(color: Colors.white38), + ), + TextSpan( + text: '[${item.level.toUpperCase().padRight(5)}] ', + style: TextStyle(color: color, fontWeight: FontWeight.bold), + ), + if (item.channel.isNotEmpty) + TextSpan( + text: '{${item.channel}} ', + style: TextStyle(color: Colors.cyanAccent.withValues(alpha: 0.8)), + ), + TextSpan( + text: item.message, + style: const TextStyle(color: Colors.white), + ), + if (item.exception != null && item.exception!.isNotEmpty) + TextSpan( + text: '\n ${item.exception}', + style: const TextStyle(color: Colors.redAccent, fontSize: 10.5), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +}