ctls_logging 1.0.1
ctls_logging: ^1.0.1 copied to clipboard
High-performance columnar log storage system with adaptive tiering, compression, and intelligent query optimization for Dart/Flutter apps.
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:ctls_logging/ctls_logging.dart';
// Realistic log message templates
const _authMessages = [
'User authentication successful',
'Login attempt from IP %s',
'Token refresh completed',
'Password validation failed',
'Session expired for user %s',
'OAuth callback received',
'Biometric authentication enabled',
'Two-factor code sent',
'Permission denied for resource %s',
'Account locked due to suspicious activity',
];
const _networkMessages = [
'HTTP GET /api/v1/users/%d - 200 OK (%.2fms)',
'HTTP POST /api/v1/data - 201 Created',
'WebSocket connection established',
'Connection timeout after %.2fs',
'Retry attempt %d for endpoint %s',
'SSL handshake completed',
'Request queued: payload size %d bytes',
'Response cached for %d seconds',
'Network error: %s unreachable',
'Rate limit exceeded: retry after %ds',
];
const _databaseMessages = [
'Query executed in %.2fms: SELECT * FROM users',
'Transaction committed: %d rows affected',
'Database connection pool exhausted',
'Index rebuild started for table %s',
'Slow query detected: %.2fs',
'Cache hit ratio: %.1f%%',
'Migration applied: version %d',
'Deadlock detected, rolling back',
'Batch insert completed: %d records',
'Backup checkpoint created',
];
const _uiMessages = [
'Screen rendered: %s (%.2fms)',
'User tapped button: %s',
'Navigation pushed: %s',
'Form validation error: %s',
'Image loaded: %s (%.2fKB)',
'Animation completed: %s',
'Keyboard shown/hidden',
'Scroll position updated: %.1f',
'Widget rebuild: %s',
'Theme changed to %s mode',
];
const _systemMessages = [
'App launched in %.2fs',
'Memory usage: %.1fMB',
'Battery level: %d%%',
'Storage space: %.2fGB free',
'Background task scheduled: %s',
'Notification permission %s',
'Device orientation changed',
'App state: %s',
'Push notification received',
'Crash report sent',
];
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'CTLS Logging Example',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: Brightness.light,
),
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
home: const CtlsExampleHome(),
);
}
}
class CtlsExampleHome extends StatefulWidget {
const CtlsExampleHome({super.key});
@override
State<CtlsExampleHome> createState() => _CtlsExampleHomeState();
}
class _CtlsExampleHomeState extends State<CtlsExampleHome> {
CTLSConfig? _config;
CTLSWriter? _writer;
final CTLSReader _reader = CTLSReader();
final LogExporter _exporter = LogExporter();
List<File> _blockFiles = const [];
List<LogEntry> _allEntries = const [];
List<LogEntry> _searchResults = const [];
String _exportPreview = '';
final TextEditingController _messageContainsController =
TextEditingController();
final TextEditingController _tagFilterController = TextEditingController();
LogLevel? _levelFilter;
DateTime? _startDate;
DateTime? _endDate;
bool _busy = false;
String? _error;
// Performance metrics
int _totalLogsWritten = 0;
double _lastWriteSpeed = 0; // logs per second
double _lastSearchTime = 0; // milliseconds
int _rawStorageBytes = 0;
int _compressedStorageBytes = 0;
@override
void initState() {
super.initState();
_init();
}
@override
void dispose() {
_messageContainsController.dispose();
_tagFilterController.dispose();
super.dispose();
}
Future<void> _init() async {
try {
Directory baseDir;
try {
baseDir = await getApplicationDocumentsDirectory();
} catch (_) {
baseDir = await Directory.systemTemp.createTemp(
'ctls_logging_example_',
);
}
final config = CTLSConfig.defaultConfig(baseDir.path);
final writer = CTLSWriter(config: config);
setState(() {
_config = config;
_writer = writer;
_busy = false;
_error = null;
});
await _refresh();
} catch (e) {
setState(() {
_busy = false;
_error = e.toString();
});
}
}
Future<void> _withBusy(Future<void> Function() action) async {
setState(() {
_busy = true;
_error = null;
});
try {
await action();
} catch (e) {
setState(() {
_error = e.toString();
});
} finally {
if (mounted) {
setState(() {
_busy = false;
});
}
}
}
void _writeSampleLogs({required int count}) {
final writer = _writer;
if (writer == null) return;
final now = DateTime.now();
final startTime = DateTime.now();
for (var i = 0; i < count; i++) {
final level = LogLevel.values[i % LogLevel.values.length];
final tag = switch (i % 5) {
0 => 'Auth',
1 => 'Network',
2 => 'Database',
3 => 'UI',
_ => 'System',
};
writer.write(
LogEntry(
timestamp: now.add(Duration(milliseconds: i)),
level: level,
tag: tag,
message: 'Sample log #$i ($tag/${level.name})',
),
);
}
final elapsed = DateTime.now().difference(startTime);
_totalLogsWritten += count;
_lastWriteSpeed = count / elapsed.inMicroseconds * 1000000;
setState(() {});
}
Future<void> _dump30Days() async {
final writer = _writer;
if (writer == null) return;
final random = Random();
final startTime = DateTime.now();
final baseDate = DateTime.now().subtract(const Duration(days: 30));
int totalLogs = 0;
// Generate 10,000+ logs per day for 30 days
for (var day = 0; day < 30; day++) {
final dayStart = baseDate.add(Duration(days: day));
final logsPerDay = 10000 + random.nextInt(5000); // 10K-15K per day
// Calculate microseconds between logs to spread evenly across 24 hours
final microsecondsPerLog = (24 * 60 * 60 * 1000000) ~/ logsPerDay;
for (var i = 0; i < logsPerDay; i++) {
final tagIndex = random.nextInt(5);
final tag = switch (tagIndex) {
0 => 'Auth',
1 => 'Network',
2 => 'Database',
3 => 'UI',
_ => 'System',
};
// Realistic log level distribution
final levelRoll = random.nextInt(100);
final level = switch (levelRoll) {
< 50 => LogLevel.info, // 50% info
< 70 => LogLevel.debug, // 20% debug
< 85 => LogLevel.warning, // 15% warning
< 97 => LogLevel.error, // 12% error
_ => LogLevel.critical, // 3% critical
};
// Generate realistic messages
final messages = switch (tag) {
'Auth' => _authMessages,
'Network' => _networkMessages,
'Database' => _databaseMessages,
'UI' => _uiMessages,
_ => _systemMessages,
};
var message = messages[random.nextInt(messages.length)];
// Add realistic data to message templates
if (message.contains('%s')) {
message = message.replaceFirst(
'%s',
'resource_${random.nextInt(100)}',
);
}
if (message.contains('%d')) {
message = message.replaceFirst('%d', '${random.nextInt(1000)}');
}
if (message.contains('%.2f')) {
message = message.replaceFirst(
'%.2f',
(random.nextDouble() * 100).toStringAsFixed(2),
);
}
// Sequential timestamps - critical for delta encoding
final timestamp = dayStart.add(
Duration(microseconds: i * microsecondsPerLog),
);
writer.write(
LogEntry(
timestamp: timestamp,
level: level,
tag: tag,
message: message,
),
);
totalLogs++;
// Flush periodically to avoid memory issues
if (i > 0 && i % 5000 == 0) {
await writer.flush();
}
}
// Flush after each day
await writer.flush();
}
final elapsed = DateTime.now().difference(startTime);
_totalLogsWritten = totalLogs;
_lastWriteSpeed = totalLogs / elapsed.inMicroseconds * 1000000;
await _refresh();
await _calculateStorageStats();
}
Future<void> _calculateStorageStats() async {
final config = _config;
if (config == null) return;
int compressedSize = 0;
for (final file in _blockFiles) {
if (await file.exists()) {
compressedSize += await file.length();
}
}
// Estimate raw size (average 148 bytes per entry)
final rawSize = _totalLogsWritten * 148;
setState(() {
_rawStorageBytes = rawSize;
_compressedStorageBytes = compressedSize;
});
}
Future<void> _flush() async {
final writer = _writer;
if (writer == null) return;
await writer.flush();
await _refresh();
}
Future<void> _refresh() async {
final config = _config;
if (config == null) return;
final dir = Directory(config.logDirectory);
final exists = await dir.exists();
if (!exists) {
setState(() {
_blockFiles = const [];
_allEntries = const [];
_searchResults = const [];
_exportPreview = '';
_totalLogsWritten = 0;
_rawStorageBytes = 0;
_compressedStorageBytes = 0;
});
return;
}
final files =
dir
.listSync()
.whereType<File>()
.where((f) => f.path.toLowerCase().endsWith('.ctls'))
.toList()
..sort((a, b) => a.path.compareTo(b.path));
final entries = await _reader.readBlocks(files);
setState(() {
_blockFiles = files;
_allEntries = entries;
_totalLogsWritten = entries.length;
});
await _calculateStorageStats();
}
Future<void> _runSearch() async {
final startTime = DateTime.now();
final criteria = SearchCriteria(
levels: _levelFilter != null ? {_levelFilter!} : {},
tags: _tagFilterController.text.trim().isEmpty
? {}
: {_tagFilterController.text.trim()},
messageContains: _messageContainsController.text.trim().isEmpty
? null
: _messageContainsController.text.trim(),
);
var results = await _reader.search(_blockFiles, criteria);
// Apply date range filter if set
if (_startDate != null || _endDate != null) {
results = results.where((entry) {
final timestamp = entry.timestamp;
if (_startDate != null && timestamp.isBefore(_startDate!)) {
return false;
}
if (_endDate != null &&
timestamp.isAfter(_endDate!.add(const Duration(days: 1)))) {
return false;
}
return true;
}).toList();
}
final elapsed = DateTime.now().difference(startTime);
_lastSearchTime = elapsed.inMicroseconds / 1000;
setState(() {
_searchResults = results;
});
}
Future<void> _exportSearchResults() async {
if (_searchResults.isEmpty) return;
final startDate = _startDate?.toIso8601String().split('T')[0] ?? 'all';
final endDate = _endDate?.toIso8601String().split('T')[0] ?? 'all';
// Simple JSON export
final json = jsonEncode(
_searchResults
.map(
(e) => {
'timestamp': e.timestamp.toIso8601String(),
'level': e.level.name,
'tag': e.tag,
'message': e.message,
},
)
.toList(),
);
setState(() {
_exportPreview =
'Exported ${_searchResults.length} results ($startDate to $endDate)\n\n${json.substring(0, json.length > 500 ? 500 : json.length)}...';
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Exported ${_searchResults.length} results ($startDate to $endDate). Check Export tab to view.',
),
duration: const Duration(seconds: 3),
),
);
}
}
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
Future<void> _export(ExportFormat format) async {
final options = switch (format) {
ExportFormat.txt => ExportOptions.txt(),
ExportFormat.json => ExportOptions.json(pretty: true),
ExportFormat.csv => ExportOptions.csv(),
};
// For large exports, show a preview with first 10K logs only
final maxLogsForPreview = 10000;
final totalLogs = _allEntries.length;
setState(() {
_exportPreview =
'Exporting $totalLogs logs in ${format.name.toUpperCase()} format...\n\n'
'Please wait, this may take a moment for large datasets.\n'
'(Showing preview of first $maxLogsForPreview logs)';
});
// Process in smaller chunks to avoid UI freeze
try {
// For preview, only export first 10K logs
final previewFiles = _blockFiles.take(5).toList(); // First few files
final previewContent = await _exporter.exportToString(
previewFiles,
options,
);
final previewLimit = 5000; // Show only 5000 chars in preview
final truncated = previewContent.length > previewLimit
? '${previewContent.substring(0, previewLimit)}\n\n... (truncated for display, full export contains $totalLogs logs)'
: previewContent;
setState(() {
_exportPreview =
'✅ Export Preview (${format.name.toUpperCase()})\n'
'Total Logs: $totalLogs\n'
'Total Files: ${_blockFiles.length}\n\n'
'$truncated';
});
} catch (e) {
setState(() {
_exportPreview = 'Export error: $e';
});
}
}
Future<void> _clearAllLogs() async {
final config = _config;
if (config == null) return;
final dir = Directory(config.logDirectory);
if (await dir.exists()) {
await dir.delete(recursive: true);
}
setState(() {
_blockFiles = const [];
_allEntries = const [];
_searchResults = const [];
_exportPreview = '';
});
}
@override
Widget build(BuildContext context) {
final config = _config;
final writer = _writer;
return DefaultTabController(
length: 5,
child: Scaffold(
appBar: AppBar(
title: const Text('CTLS Logging Example'),
bottom: const TabBar(
isScrollable: true,
tabs: [
Tab(icon: Icon(Icons.dashboard), text: 'Dashboard'),
Tab(icon: Icon(Icons.list), text: 'Logs'),
Tab(icon: Icon(Icons.search), text: 'Search'),
Tab(icon: Icon(Icons.file_download), text: 'Export'),
Tab(icon: Icon(Icons.folder), text: 'Files'),
],
),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: _busy ? null : () => _withBusy(_refresh),
icon: const Icon(Icons.refresh),
),
],
),
body: _busy
? const Center(child: CircularProgressIndicator())
: Column(
children: [
if (_error != null)
MaterialBanner(
content: Text(_error!),
actions: [
TextButton(
onPressed: () => setState(() => _error = null),
child: const Text('Dismiss'),
),
],
),
Expanded(
child: TabBarView(
children: [
_DashboardTab(
config: config,
writer: writer,
totalLogsWritten: _totalLogsWritten,
lastWriteSpeed: _lastWriteSpeed,
lastSearchTime: _lastSearchTime,
rawStorageBytes: _rawStorageBytes,
compressedStorageBytes: _compressedStorageBytes,
blockFilesCount: _blockFiles.length,
allEntries: _allEntries,
onDump30Days: () => _withBusy(_dump30Days),
onWrite1K: () => _writeSampleLogs(count: 1000),
onFlush: () => _withBusy(_flush),
onClearLogs: () => _withBusy(_clearAllLogs),
onRefresh: () => _withBusy(_refresh),
formatBytes: _formatBytes,
),
_LogsTab(entries: _allEntries),
_SearchTab(
messageContainsController: _messageContainsController,
tagFilterController: _tagFilterController,
levelFilter: _levelFilter,
startDate: _startDate,
endDate: _endDate,
onLevelChanged: (v) =>
setState(() => _levelFilter = v),
onStartDateChanged: (d) =>
setState(() => _startDate = d),
onEndDateChanged: (d) => setState(() => _endDate = d),
onSearch: () => _withBusy(_runSearch),
onExport: () => _withBusy(_exportSearchResults),
results: _searchResults,
lastSearchTime: _lastSearchTime,
filesSearched: _blockFiles.length,
),
_ExportTab(
preview: _exportPreview,
onExportTxt: () =>
_withBusy(() => _export(ExportFormat.txt)),
onExportJson: () =>
_withBusy(() => _export(ExportFormat.json)),
onExportCsv: () =>
_withBusy(() => _export(ExportFormat.csv)),
),
_FilesTab(files: _blockFiles),
],
),
),
],
),
),
);
}
}
class _DashboardTab extends StatelessWidget {
final CTLSConfig? config;
final CTLSWriter? writer;
final int totalLogsWritten;
final double lastWriteSpeed;
final double lastSearchTime;
final int rawStorageBytes;
final int compressedStorageBytes;
final int blockFilesCount;
final List<LogEntry> allEntries;
final VoidCallback onDump30Days;
final VoidCallback onWrite1K;
final VoidCallback onFlush;
final VoidCallback onClearLogs;
final VoidCallback onRefresh;
final String Function(int) formatBytes;
const _DashboardTab({
required this.config,
required this.writer,
required this.totalLogsWritten,
required this.lastWriteSpeed,
required this.lastSearchTime,
required this.rawStorageBytes,
required this.compressedStorageBytes,
required this.blockFilesCount,
required this.allEntries,
required this.onDump30Days,
required this.onWrite1K,
required this.onFlush,
required this.onClearLogs,
required this.onRefresh,
required this.formatBytes,
});
int _calculateLogDuration() {
if (allEntries.isEmpty) return 0;
final first = allEntries.first.timestamp;
final last = allEntries.last.timestamp;
return last.difference(first).inDays + 1;
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Storage Efficiency Card
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.storage,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Storage Efficiency',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: onRefresh,
tooltip: 'Refresh storage metrics',
style: IconButton.styleFrom(
foregroundColor: Theme.of(
context,
).colorScheme.primary,
),
),
],
),
const SizedBox(height: 16),
if (totalLogsWritten == 0)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'Generate logs to see storage metrics',
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(context).colorScheme.outline,
),
),
),
)
else ...[
// Compact storage bars
_CompactStorageBar(
label: 'Raw Data Size (without CTLS)',
value: formatBytes(rawStorageBytes),
color: Colors.blue,
percentage: 100,
tooltip:
'Estimated size if logs were stored as plain text JSON (≈148 bytes per log entry)',
),
const SizedBox(height: 8),
_CompactStorageBar(
label: 'Compressed Size (with CTLS)',
value: formatBytes(compressedStorageBytes),
color: Colors.green,
percentage: rawStorageBytes > 0
? (compressedStorageBytes / rawStorageBytes * 100)
: 0,
tooltip:
'Actual disk usage with CTLS columnar compression (gzip + delta encoding)',
),
const SizedBox(height: 12),
// Compact stats grid
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_CompactStat(
label: 'Space Saved',
value: formatBytes(
rawStorageBytes - compressedStorageBytes,
),
color: Colors.green,
),
Container(
width: 1,
height: 30,
color: Colors.green.withValues(alpha: 0.3),
),
_CompactStat(
label: 'Efficiency',
value:
'${rawStorageBytes > 0 ? (100 - (compressedStorageBytes / rawStorageBytes * 100)).toStringAsFixed(1) : "0"}%',
color: Colors.green,
),
],
),
),
const SizedBox(height: 12),
// Quick metrics row
Row(
children: [
Expanded(
child: _QuickMetric(
icon: Icons.description,
value:
'${(totalLogsWritten / 1000).toStringAsFixed(1)}K',
label: 'Logs',
),
),
Expanded(
child: _QuickMetric(
icon: Icons.folder,
value: '$blockFilesCount',
label: 'Files',
),
),
Expanded(
child: _QuickMetric(
icon: Icons.calendar_today,
value: '${_calculateLogDuration()}',
label: 'Days',
),
),
],
),
],
],
),
),
),
const SizedBox(height: 16),
// Performance Card
if (totalLogsWritten > 0)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.speed,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Performance',
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 20),
_MetricRow(
icon: Icons.speed,
label: 'Write Speed',
value: '${(lastWriteSpeed / 1000).toStringAsFixed(1)}K/s',
color: Colors.orange,
),
const Divider(),
_MetricRow(
icon: Icons.search,
label: 'Search Time',
value: '${lastSearchTime.toStringAsFixed(0)}ms',
color: Colors.purple,
),
],
),
),
),
if (totalLogsWritten > 0) const SizedBox(height: 16),
// Actions Card
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.play_circle,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Actions',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: writer == null ? null : onDump30Days,
icon: const Icon(Icons.cloud_download),
label: const Text('Generate 30 Days (300K+ logs)'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: writer == null ? null : onWrite1K,
icon: const Icon(Icons.add),
label: Text(
'Write 1K\n(${writer?.entryCount ?? 0} buffered)',
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.icon(
onPressed: writer == null ? null : onFlush,
icon: const Icon(Icons.save),
label: const Text('Flush to Disk'),
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: config == null ? null : onClearLogs,
icon: const Icon(Icons.delete_sweep),
label: const Text('Clear All Logs'),
style: FilledButton.styleFrom(
backgroundColor: Colors.red.withValues(alpha: 0.1),
),
),
),
],
),
),
),
const SizedBox(height: 16),
// Info Card
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Text(
'Configuration',
style: Theme.of(context).textTheme.titleSmall,
),
],
),
const SizedBox(height: 8),
Text(
config == null
? 'Initializing…'
: 'Directory: ${config!.logDirectory}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
],
),
);
}
}
class _MetricRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final Color color;
const _MetricRow({
required this.icon,
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 16),
Expanded(
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
),
Text(
value,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}
class _CompactStorageBar extends StatelessWidget {
final String label;
final String value;
final Color color;
final double percentage;
final String tooltip;
const _CompactStorageBar({
required this.label,
required this.value,
required this.color,
required this.percentage,
required this.tooltip,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
Flexible(
child: Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(label),
content: Text(tooltip),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
},
child: Icon(
Icons.info_outline,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
),
Text(
value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
const SizedBox(height: 6),
Stack(
children: [
Container(
height: 8,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
FractionallySizedBox(
widthFactor: percentage / 100,
child: Container(
height: 8,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
],
);
}
}
class _CompactStat extends StatelessWidget {
final String label;
final String value;
final Color color;
const _CompactStat({
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
],
);
}
}
class _QuickMetric extends StatelessWidget {
final IconData icon;
final String value;
final String label;
const _QuickMetric({
required this.icon,
required this.value,
required this.label,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
),
);
}
}
class _LogsTab extends StatelessWidget {
final List<LogEntry> entries;
const _LogsTab({required this.entries});
@override
Widget build(BuildContext context) {
if (entries.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: Theme.of(context).colorScheme.outline,
),
const SizedBox(height: 16),
Text('No logs yet', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Text(
'Go to Dashboard to generate logs',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
return ListView.separated(
itemCount: entries.length,
padding: const EdgeInsets.all(8),
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final e = entries[index];
return ListTile(
title: Text(e.message),
subtitle: Text('${e.timestamp.toIso8601String()} ${e.tag}'),
trailing: Text(e.level.name.toUpperCase()),
dense: true,
);
},
);
}
}
class _SearchTab extends StatelessWidget {
final TextEditingController messageContainsController;
final TextEditingController tagFilterController;
final LogLevel? levelFilter;
final DateTime? startDate;
final DateTime? endDate;
final ValueChanged<LogLevel?> onLevelChanged;
final ValueChanged<DateTime?> onStartDateChanged;
final ValueChanged<DateTime?> onEndDateChanged;
final VoidCallback onSearch;
final VoidCallback onExport;
final List<LogEntry> results;
final double lastSearchTime;
final int filesSearched;
const _SearchTab({
required this.messageContainsController,
required this.tagFilterController,
required this.levelFilter,
required this.startDate,
required this.endDate,
required this.onLevelChanged,
required this.onStartDateChanged,
required this.onEndDateChanged,
required this.onSearch,
required this.onExport,
required this.results,
required this.lastSearchTime,
required this.filesSearched,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
// Compact Search Form
Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
controller: messageContainsController,
decoration: InputDecoration(
labelText: 'Search message',
hintText: 'e.g., "error", "timeout"',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onSubmitted: (_) => onSearch(),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: tagFilterController,
decoration: InputDecoration(
labelText: 'Tag',
prefixIcon: const Icon(Icons.label, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: DropdownButtonFormField<LogLevel?>(
initialValue: levelFilter,
items: [
const DropdownMenuItem<LogLevel?>(
value: null,
child: Text('All levels'),
),
...LogLevel.values.map(
(l) => DropdownMenuItem<LogLevel?>(
value: l,
child: Text(l.name.toUpperCase()),
),
),
],
onChanged: onLevelChanged,
decoration: InputDecoration(
labelText: 'Level',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: startDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (date != null) onStartDateChanged(date);
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'From Date',
prefixIcon: const Icon(
Icons.calendar_today,
size: 20,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
child: Text(
startDate != null
? startDate!.toString().split(' ')[0]
: 'Any',
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: InkWell(
onTap: () async {
final date = await showDatePicker(
context: context,
initialDate: endDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
);
if (date != null) onEndDateChanged(date);
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'To Date',
prefixIcon: const Icon(Icons.event, size: 20),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
child: Text(
endDate != null
? endDate!.toString().split(' ')[0]
: 'Any',
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
flex: 2,
child: FilledButton.icon(
onPressed: onSearch,
icon: const Icon(Icons.search, size: 20),
label: const Text('Search'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton.tonalIcon(
onPressed: results.isEmpty ? null : onExport,
icon: const Icon(Icons.file_download, size: 20),
label: const Text('Export'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
],
),
],
),
),
// Results
Expanded(
child: results.isEmpty
? const SizedBox.shrink()
: Column(
children: [
// Results Header with metrics
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
border: Border(
bottom: BorderSide(
color: Theme.of(context).colorScheme.outline,
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.check_circle,
color: Theme.of(
context,
).colorScheme.primary,
size: 24,
),
const SizedBox(width: 8),
Text(
'${results.length} results found',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
],
),
if (lastSearchTime > 0)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(
Icons.speed,
size: 16,
color: Colors.green,
),
const SizedBox(width: 4),
Text(
'${lastSearchTime.toStringAsFixed(0)}ms',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.green,
),
),
],
),
),
],
),
if (results.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.calendar_today,
size: 16,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
'${results.first.timestamp.toLocal().toString().split(' ')[0]} → ${results.last.timestamp.toLocal().toString().split(' ')[0]}',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
),
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.folder_open,
size: 16,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
'$filesSearched files searched',
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7),
),
),
],
),
],
),
],
],
),
),
// Results List
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: results.length,
itemBuilder: (context, index) {
final e = results[index];
final levelColor = switch (e.level) {
LogLevel.debug => Colors.grey,
LogLevel.info => Colors.blue,
LogLevel.warning => Colors.orange,
LogLevel.error => Colors.red,
LogLevel.critical => Colors.purple,
};
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: levelColor.withValues(
alpha: 0.2,
),
child: Icon(
Icons.circle,
color: levelColor,
size: 12,
),
),
title: Text(
e.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${e.timestamp.toIso8601String().split('T')[1].split('.')[0]} • ${e.tag}',
style: TextStyle(fontSize: 12),
),
trailing: Chip(
label: Text(
e.level.name.toUpperCase(),
style: TextStyle(
fontSize: 10,
color: levelColor,
),
),
backgroundColor: levelColor.withValues(
alpha: 0.1,
),
side: BorderSide.none,
),
),
);
},
),
),
],
),
),
],
);
}
}
class _ExportTab extends StatelessWidget {
final String preview;
final VoidCallback onExportTxt;
final VoidCallback onExportJson;
final VoidCallback onExportCsv;
const _ExportTab({
required this.preview,
required this.onExportTxt,
required this.onExportJson,
required this.onExportCsv,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: onExportTxt,
child: const Text('TXT'),
),
FilledButton.tonal(
onPressed: onExportJson,
child: const Text('JSON'),
),
FilledButton.tonal(
onPressed: onExportCsv,
child: const Text('CSV'),
),
],
),
),
const Divider(height: 1),
Expanded(
child: preview.isEmpty
? const Center(child: Text('Export preview will appear here.'))
: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: SelectableText(preview),
),
),
],
);
}
}
class _FilesTab extends StatelessWidget {
final List<File> files;
const _FilesTab({required this.files});
String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
}
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
String _getFileAge(File file) {
try {
if (!file.existsSync()) return 'N/A';
final stat = file.statSync();
final age = DateTime.now().difference(stat.modified);
if (age.inDays > 0) return '${age.inDays}d ago';
if (age.inHours > 0) return '${age.inHours}h ago';
return '${age.inMinutes}m ago';
} catch (_) {
return 'N/A';
}
}
@override
Widget build(BuildContext context) {
if (files.isEmpty) {
return const Center(child: Text('No block files yet.'));
}
return Column(
children: [
// Log Rotation Info Banner
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.auto_delete,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Log Rotation: Files older than 30 days are automatically deleted',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
),
),
],
),
),
// Files List
Expanded(
child: ListView.separated(
itemCount: files.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final f = files[index];
final name = f.uri.pathSegments.isEmpty
? f.path
: f.uri.pathSegments.last;
final size = f.existsSync() ? f.lengthSync() : 0;
final age = _getFileAge(f);
return ListTile(
leading: Icon(
Icons.insert_drive_file,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
title: Text(name, style: const TextStyle(fontSize: 13)),
subtitle: Text(
'Age: $age',
style: const TextStyle(fontSize: 11),
),
trailing: Text(
_formatBytes(size),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
dense: true,
);
},
),
),
],
);
}
}