feat(app): live terminal log console widget and service dynamic settings management

This commit is contained in:
2026-08-15 21:31:00 +02:00
parent f18f75c1ab
commit b0f8d4b78b
5 changed files with 471 additions and 6 deletions
@@ -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 {
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,
};
}
@@ -54,7 +54,7 @@ class AdminRepository {
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);
if (res.statusCode != 200 && res.statusCode != 204) {
throw Exception('Einstellungen konnten nicht gespeichert werden');
@@ -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<ServiceDetailScreen> {
Future<void> _saveSettings() async {
setState(() => _isSaving = true);
try {
final payload = <String, String>{};
final payload = <String, dynamic>{};
_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<ServiceDetailScreen> {
..._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<ServiceDetailScreen> {
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<ServiceDetailScreen> {
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<ServiceDetailScreen> {
),
),
const SizedBox(height: 24),
LiveLogConsole(
serviceName: widget.serviceName,
apiClient: widget.apiClient,
),
const SizedBox(height: 24),
GlassContainer(
padding: const EdgeInsets.all(24),
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),
),
],
),
),
);
},
),
),
],
),
);
}
}