feat(app): live terminal log console widget and service dynamic settings management
This commit is contained in:
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'timestamp': timestamp.toUtc().toIso8601String(),
|
||||||
|
'serviceName': serviceName,
|
||||||
|
'channel': channel,
|
||||||
|
'level': level,
|
||||||
|
'message': message,
|
||||||
|
'exception': exception,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
class ServiceSettingDto {
|
class ServiceSettingDto {
|
||||||
final String key;
|
final String key;
|
||||||
final String value;
|
final String value;
|
||||||
|
final String type; // 'bool', 'int', 'double', 'string'
|
||||||
final String description;
|
final String description;
|
||||||
|
|
||||||
const ServiceSettingDto({
|
const ServiceSettingDto({
|
||||||
required this.key,
|
required this.key,
|
||||||
required this.value,
|
required this.value,
|
||||||
|
this.type = 'string',
|
||||||
this.description = '',
|
this.description = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ class ServiceSettingDto {
|
|||||||
return ServiceSettingDto(
|
return ServiceSettingDto(
|
||||||
key: json['key']?.toString() ?? '',
|
key: json['key']?.toString() ?? '',
|
||||||
value: json['value']?.toString() ?? '',
|
value: json['value']?.toString() ?? '',
|
||||||
|
type: json['dataType']?.toString() ?? json['type']?.toString() ?? 'string',
|
||||||
description: json['description']?.toString() ?? '',
|
description: json['description']?.toString() ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -21,6 +24,7 @@ class ServiceSettingDto {
|
|||||||
return {
|
return {
|
||||||
'key': key,
|
'key': key,
|
||||||
'value': value,
|
'value': value,
|
||||||
|
'type': type,
|
||||||
'description': description,
|
'description': description,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class AdminRepository {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateServiceSettings(String serviceName, Map<String, String> settings) async {
|
Future<void> updateServiceSettings(String serviceName, Map<String, dynamic> settings) async {
|
||||||
final res = await apiClient.put('/api/v1/admin/settings/$serviceName', data: settings);
|
final res = await apiClient.put('/api/v1/admin/settings/$serviceName', data: settings);
|
||||||
if (res.statusCode != 200 && res.statusCode != 204) {
|
if (res.statusCode != 200 && res.statusCode != 204) {
|
||||||
throw Exception('Einstellungen konnten nicht gespeichert werden');
|
throw Exception('Einstellungen konnten nicht gespeichert werden');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/widgets/glass_container.dart';
|
|||||||
import '../../../core/widgets/status_badge.dart';
|
import '../../../core/widgets/status_badge.dart';
|
||||||
import '../models/service_setting_dto.dart';
|
import '../models/service_setting_dto.dart';
|
||||||
import '../repositories/admin_repository.dart';
|
import '../repositories/admin_repository.dart';
|
||||||
|
import '../widgets/live_log_console.dart';
|
||||||
|
|
||||||
class ServiceDetailScreen extends StatefulWidget {
|
class ServiceDetailScreen extends StatefulWidget {
|
||||||
final String serviceName;
|
final String serviceName;
|
||||||
@@ -75,9 +76,20 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
Future<void> _saveSettings() async {
|
Future<void> _saveSettings() async {
|
||||||
setState(() => _isSaving = true);
|
setState(() => _isSaving = true);
|
||||||
try {
|
try {
|
||||||
final payload = <String, String>{};
|
final payload = <String, dynamic>{};
|
||||||
_controllers.forEach((k, v) {
|
_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);
|
await _repository.updateServiceSettings(widget.serviceName, payload);
|
||||||
@@ -170,10 +182,13 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
..._settings.map((s) {
|
..._settings.map((s) {
|
||||||
final key = s.key;
|
final key = s.key;
|
||||||
final desc = s.description;
|
final desc = s.description;
|
||||||
|
final type = s.type.toLowerCase();
|
||||||
final controller = _controllers[key];
|
final controller = _controllers[key];
|
||||||
if (controller == null) return const SizedBox.shrink();
|
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) {
|
if (isBoolean) {
|
||||||
final boolVal = controller.text.toLowerCase() == 'true';
|
final boolVal = controller.text.toLowerCase() == 'true';
|
||||||
@@ -183,7 +198,11 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.glassSurface,
|
color: AppTheme.glassSurface,
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: SwitchListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
@@ -191,21 +210,31 @@ class _ServiceDetailScreenState extends State<ServiceDetailScreen> {
|
|||||||
subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
subtitle: desc.isNotEmpty ? Text(desc, style: TextStyle(fontSize: 11, color: AppTheme.textMuted)) : null,
|
||||||
value: boolVal,
|
value: boolVal,
|
||||||
activeThumbColor: AppTheme.primaryEmerald,
|
activeThumbColor: AppTheme.primaryEmerald,
|
||||||
|
activeTrackColor: AppTheme.primaryEmerald.withValues(alpha: 0.3),
|
||||||
onChanged: (val) => setState(() => controller.text = val.toString()),
|
onChanged: (val) => setState(() => controller.text = val.toString()),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final isNumeric = type == 'int' || type == 'double' || type == 'number' || type == 'decimal';
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 14),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
keyboardType: isNumeric
|
||||||
|
? const TextInputType.numberWithOptions(decimal: true)
|
||||||
|
: TextInputType.text,
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: _formatLabel(key),
|
labelText: _formatLabel(key),
|
||||||
helperText: desc.isNotEmpty ? desc : null,
|
helperText: desc.isNotEmpty ? desc : null,
|
||||||
helperMaxLines: 2,
|
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<ServiceDetailScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
LiveLogConsole(
|
||||||
|
serviceName: widget.serviceName,
|
||||||
|
apiClient: widget.apiClient,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
GlassContainer(
|
GlassContainer(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -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<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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<LogMessageDto> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user