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, ); } }); } } /// Collapses the backend's full `Microsoft.Extensions.Logging.LogLevel` names ("Information", "Warning", /// "Trace", "Critical", ...) down to the 4 short codes the filter chips use ("INFO", "WARN", "DEBUG", /// "ERROR"). The filter previously compared `log.level.toUpperCase()` ("INFORMATION") directly against the /// chip value ("INFO") - which never matched anything but "ALL", so selecting any specific level silently /// hid every log line instead of actually filtering. String _normalizeLevel(String level) { switch (level.toUpperCase()) { case 'INFORMATION': case 'INFO': return 'INFO'; case 'WARNING': case 'WARN': return 'WARN'; case 'ERROR': case 'CRITICAL': return 'ERROR'; case 'DEBUG': case 'TRACE': return 'DEBUG'; default: return level.toUpperCase(); } } List get _filteredLogs { return _logs.where((log) { if (_selectedLevel != 'ALL' && _normalizeLevel(log.level) != _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), ), ], ), ), ); }, ), ), ], ), ); } }