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 {
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user