408 lines
15 KiB
Dart
408 lines
15 KiB
Dart
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<LiveLogConsole> createState() => _LiveLogConsoleState();
|
|
}
|
|
|
|
class _LiveLogConsoleState extends State<LiveLogConsole> {
|
|
HubConnection? _hubConnection;
|
|
final List<LogMessageDto> _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<void> _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<String, dynamic>.from(item as Map))).toList();
|
|
if (mounted) {
|
|
setState(() {
|
|
_logs.addAll(list);
|
|
});
|
|
_scrollToBottomIfNeeded();
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (kDebugMode) debugPrint('[LiveLogConsole] Error fetching initial logs: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _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<String, dynamic>.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<void> _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<LogMessageDto> 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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|