Skip to content

Commit fb798be

Browse files
committed
perf: optimize dashboard performance for large datasets
- Add database indexes on SensorData (Timestamp, SensorName+Timestamp) - Move statistics calculation from memory to database-level aggregation - Implement time-based sampling for chart data on large datasets - Fix MaintenanceService to delete old data instead of clearing all - Add VACUUM after cleanup to reclaim SQLite space
1 parent a8bc0c8 commit fb798be

File tree

3 files changed

+178
-66
lines changed

3 files changed

+178
-66
lines changed

FanX/Components/Pages/Dashboard.razor

Lines changed: 113 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -299,61 +299,79 @@
299299
var weekAgo = now.AddDays(-7);
300300
var monthAgo = now.AddDays(-30);
301301

302-
// Load statistics data with optimized queries
303-
var allDbData = await db.Queryable<SensorData>()
304-
.Where(s => s.Timestamp >= monthAgo)
305-
.OrderBy(s => s.Timestamp)
302+
// Use database-level aggregation instead of loading all data into memory
303+
// Get distinct sensor names first
304+
var tempSensorNames = await db.Queryable<SensorData>()
305+
.Where(s => s.SensorType == "Temperature" && s.Timestamp >= monthAgo)
306+
.Select(s => s.SensorName)
307+
.Distinct()
306308
.ToListAsync();
307-
308-
// Calculate averages
309-
var tempSensors = allDbData.Where(d => d.SensorType == "Temperature").GroupBy(d => d.SensorName);
310-
foreach (var group in tempSensors)
311-
{
312-
if (group.Key != null)
313-
_tempAverages[group.Key] = (
314-
group.Where(d => d.Timestamp >= hourAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
315-
group.Where(d => d.Timestamp >= dayAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
316-
group.Where(d => d.Timestamp >= weekAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
317-
group.DefaultIfEmpty().Average(d => d?.Reading ?? 0),
318-
group.DefaultIfEmpty().Min(d => d?.Reading ?? 0),
319-
group.DefaultIfEmpty().Max(d => d?.Reading ?? 0)
320-
);
321-
}
322309

323-
var powerData = allDbData.Where(d => d.SensorName == "Pwr Consumption").ToList();
324-
if (powerData.Any())
310+
foreach (var sensorName in tempSensorNames.Where(n => n != null))
325311
{
326-
_powerAverages = (
327-
powerData.Where(d => d.Timestamp >= hourAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
328-
powerData.Where(d => d.Timestamp >= dayAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
329-
powerData.Where(d => d.Timestamp >= weekAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
330-
powerData.DefaultIfEmpty().Average(d => d?.Reading ?? 0),
331-
powerData.DefaultIfEmpty().Min(d => d?.Reading ?? 0),
332-
powerData.DefaultIfEmpty().Max(d => d?.Reading ?? 0)
333-
);
312+
var stats = await CalculateSensorStats(db, sensorName!, hourAgo, dayAgo, weekAgo, monthAgo);
313+
_tempAverages[sensorName!] = stats;
334314
}
335-
336-
var fanSensors = allDbData.Where(d => d.SensorType == "Fan").GroupBy(d => d.SensorName);
337-
foreach (var group in fanSensors)
315+
316+
// Power statistics
317+
var powerStats = await CalculateSensorStats(db, "Pwr Consumption", hourAgo, dayAgo, weekAgo, monthAgo);
318+
_powerAverages = powerStats;
319+
320+
// Fan statistics
321+
var fanSensorNames = await db.Queryable<SensorData>()
322+
.Where(s => s.SensorType == "Fan" && s.Timestamp >= monthAgo)
323+
.Select(s => s.SensorName)
324+
.Distinct()
325+
.ToListAsync();
326+
327+
foreach (var sensorName in fanSensorNames.Where(n => n != null))
338328
{
339-
if (group.Key != null)
340-
_fanAverages[group.Key] = (
341-
group.Where(d => d.Timestamp >= hourAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
342-
group.Where(d => d.Timestamp >= dayAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
343-
group.Where(d => d.Timestamp >= weekAgo).DefaultIfEmpty().Average(d => d?.Reading ?? 0),
344-
group.DefaultIfEmpty().Average(d => d?.Reading ?? 0),
345-
group.DefaultIfEmpty().Min(d => d?.Reading ?? 0),
346-
group.DefaultIfEmpty().Max(d => d?.Reading ?? 0)
347-
);
329+
var stats = await CalculateSensorStats(db, sensorName!, hourAgo, dayAgo, weekAgo, monthAgo);
330+
_fanAverages[sensorName!] = stats;
348331
}
349332
}
350333

334+
private async Task<(double Hour, double Day, double Week, double Month, double Min, double Max)> CalculateSensorStats(
335+
SqlSugar.SqlSugarScope db, string sensorName, DateTime hourAgo, DateTime dayAgo, DateTime weekAgo, DateTime monthAgo)
336+
{
337+
// Use database aggregation - much faster than loading all data
338+
var monthStats = await db.Queryable<SensorData>()
339+
.Where(s => s.SensorName == sensorName && s.Timestamp >= monthAgo)
340+
.Select(s => new {
341+
Avg = SqlSugar.SqlFunc.AggregateAvg(s.Reading),
342+
Min = SqlSugar.SqlFunc.AggregateMin(s.Reading),
343+
Max = SqlSugar.SqlFunc.AggregateMax(s.Reading)
344+
})
345+
.FirstAsync();
346+
347+
var weekAvg = await db.Queryable<SensorData>()
348+
.Where(s => s.SensorName == sensorName && s.Timestamp >= weekAgo)
349+
.AvgAsync(s => s.Reading);
350+
351+
var dayAvg = await db.Queryable<SensorData>()
352+
.Where(s => s.SensorName == sensorName && s.Timestamp >= dayAgo)
353+
.AvgAsync(s => s.Reading);
354+
355+
var hourAvg = await db.Queryable<SensorData>()
356+
.Where(s => s.SensorName == sensorName && s.Timestamp >= hourAgo)
357+
.AvgAsync(s => s.Reading);
358+
359+
return (
360+
hourAvg,
361+
dayAvg,
362+
weekAvg,
363+
monthStats?.Avg ?? 0,
364+
monthStats?.Min ?? 0,
365+
monthStats?.Max ?? 0
366+
);
367+
}
368+
351369
private async Task LoadChartData()
352370
{
353371
// Check cache first
354-
if (_chartDataCache.ContainsKey(_selectedTimeRange))
372+
if (_chartDataCache.TryGetValue(_selectedTimeRange, out var cachedData))
355373
{
356-
_chartData = _chartDataCache[_selectedTimeRange];
374+
_chartData = cachedData;
357375
await UpdateCharts();
358376
return;
359377
}
@@ -364,14 +382,61 @@
364382
var now = DateTime.Now;
365383
var startTime = now.AddHours(-_selectedTimeRange);
366384

367-
// Load chart data with sampling for performance
368-
var chartDataQuery = db.Queryable<SensorData>()
385+
// Get total count first to determine sampling strategy
386+
var totalCount = await db.Queryable<SensorData>()
369387
.Where(s => s.Timestamp >= startTime)
370-
.OrderBy(s => s.Timestamp);
388+
.CountAsync();
371389

372-
var rawData = await chartDataQuery.ToListAsync();
390+
List<SensorData> rawData;
391+
392+
if (totalCount <= MaxDataPoints * 10) // If data is manageable, load directly
393+
{
394+
rawData = await db.Queryable<SensorData>()
395+
.Where(s => s.Timestamp >= startTime)
396+
.OrderBy(s => s.Timestamp)
397+
.ToListAsync();
398+
}
399+
else
400+
{
401+
// Use database-level sampling for large datasets
402+
// Calculate sampling interval based on time range
403+
var intervalMinutes = _selectedTimeRange switch
404+
{
405+
1 => 1, // 1 hour: every minute
406+
6 => 2, // 6 hours: every 2 minutes
407+
24 => 5, // 24 hours: every 5 minutes
408+
168 => 30, // 7 days: every 30 minutes
409+
720 => 120, // 30 days: every 2 hours
410+
_ => 5
411+
};
412+
413+
// Get distinct timestamps at intervals, then fetch data for those timestamps
414+
var sampledTimestamps = await db.Queryable<SensorData>()
415+
.Where(s => s.Timestamp >= startTime)
416+
.GroupBy(s => new {
417+
Year = s.Timestamp.Year,
418+
Month = s.Timestamp.Month,
419+
Day = s.Timestamp.Day,
420+
Hour = s.Timestamp.Hour,
421+
MinuteGroup = s.Timestamp.Minute / intervalMinutes
422+
})
423+
.Select(g => SqlSugar.SqlFunc.AggregateMin(g.Timestamp))
424+
.ToListAsync();
425+
426+
if (sampledTimestamps.Any())
427+
{
428+
rawData = await db.Queryable<SensorData>()
429+
.Where(s => sampledTimestamps.Contains(s.Timestamp))
430+
.OrderBy(s => s.Timestamp)
431+
.ToListAsync();
432+
}
433+
else
434+
{
435+
rawData = new List<SensorData>();
436+
}
437+
}
373438

374-
// Normalize timestamps and sample data properly
439+
// Normalize timestamps and sample data if still too large
375440
_chartData = NormalizeAndSampleData(rawData, MaxDataPoints);
376441

377442
// Cache the result

FanX/Models/SensorData.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
namespace FanX.Models
44
{
55
[SugarTable("SensorData")]
6+
[SugarIndex("idx_sensordata_timestamp", nameof(Timestamp), OrderByType.Desc)]
7+
[SugarIndex("idx_sensordata_sensorname_timestamp", nameof(SensorName), OrderByType.Asc, nameof(Timestamp), OrderByType.Desc)]
68
public class SensorData
79
{
810
[SugarColumn(IsPrimaryKey = true, IsIdentity = true)]

FanX/Services/MaintenanceService.cs

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ public class MaintenanceService : IHostedService, IDisposable
99
private Timer? _timer;
1010
private DateTime _lastLogCleanup = DateTime.MinValue;
1111
private DateTime _lastSensorCleanup = DateTime.MinValue;
12+
private const int DefaultSensorRetentionDays = 30; // Default: keep 30 days of data
1213

1314
public MaintenanceService(IServiceProvider serviceProvider)
1415
{
@@ -17,8 +18,8 @@ public MaintenanceService(IServiceProvider serviceProvider)
1718

1819
public Task StartAsync(CancellationToken cancellationToken)
1920
{
20-
// Run every second to support debug intervals
21-
_timer = new Timer(async void (_) => await DoWork(), null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
21+
// Run every hour for maintenance tasks (no need to run every second)
22+
_timer = new Timer(async void (_) => await DoWork(), null, TimeSpan.FromMinutes(1), TimeSpan.FromHours(1));
2223
return Task.CompletedTask;
2324
}
2425

@@ -28,54 +29,98 @@ private async Task DoWork()
2829
LoggerService.Debug($"MaintenanceService executing at {nowUtc} UTC.");
2930
if (!_initialized)
3031
{
31-
// Skip cleanup on initial startup
3232
_initialized = true;
3333
_lastLogCleanup = nowUtc;
3434
_lastSensorCleanup = nowUtc;
3535
LoggerService.Debug("MaintenanceService initialization complete, skipping first cleanup.");
3636
return;
3737
}
38+
3839
using var scope = _serviceProvider.CreateScope();
3940
var db = scope.ServiceProvider.GetRequiredService<DatabaseService>().Db;
4041
var settings = await db.Queryable<AppSetting>().ToListAsync();
4142

42-
// Periodic full log cleanup by days
43-
if (settings.Any(s => s.Key == "LogRetentionDays") && int.TryParse(settings.First(s => s.Key == "LogRetentionDays").Value, out var days) && days > 0)
43+
// Clean old logs daily
44+
if (nowUtc - _lastLogCleanup >= TimeSpan.FromDays(1))
4445
{
45-
if (nowUtc - _lastLogCleanup >= TimeSpan.FromDays(days))
46+
var logRetentionDays = 7; // Default 7 days
47+
if (settings.Any(s => s.Key == "LogRetentionDays") &&
48+
int.TryParse(settings.First(s => s.Key == "LogRetentionDays").Value, out var days) && days > 0)
4649
{
47-
LoggerService.Info($"Clearing all logs (daily interval: {days} days)");
48-
ClearAllLogs();
49-
_lastLogCleanup = nowUtc;
50+
logRetentionDays = days;
5051
}
52+
LoggerService.Info($"Cleaning logs older than {logRetentionDays} days");
53+
CleanOldLogs(logRetentionDays);
54+
_lastLogCleanup = nowUtc;
5155
}
5256

53-
// Periodic full sensor data cleanup by days
54-
if (settings.Any(s => s.Key == "SensorDataRetentionDays") && int.TryParse(settings.First(s => s.Key == "SensorDataRetentionDays").Value, out var sdDays) && sdDays > 0)
57+
// Clean old sensor data daily - DELETE data older than retention period
58+
if (nowUtc - _lastSensorCleanup >= TimeSpan.FromDays(1))
5559
{
56-
if (nowUtc - _lastSensorCleanup >= TimeSpan.FromDays(sdDays))
60+
var sensorRetentionDays = DefaultSensorRetentionDays;
61+
if (settings.Any(s => s.Key == "SensorDataRetentionDays") &&
62+
int.TryParse(settings.First(s => s.Key == "SensorDataRetentionDays").Value, out var sdDays) && sdDays > 0)
63+
{
64+
sensorRetentionDays = sdDays;
65+
}
66+
67+
var cutoffDate = DateTime.Now.AddDays(-sensorRetentionDays);
68+
LoggerService.Info($"Cleaning sensor data older than {sensorRetentionDays} days (before {cutoffDate})");
69+
70+
// Delete old data in batches to avoid locking the database
71+
var deletedCount = await db.Deleteable<SensorData>()
72+
.Where(s => s.Timestamp < cutoffDate)
73+
.ExecuteCommandAsync();
74+
75+
if (deletedCount > 0)
5776
{
58-
LoggerService.Info($"Clearing all sensor data (daily interval: {sdDays} days)");
59-
var deletedCount = await db.Deleteable<SensorData>().ExecuteCommandAsync();
60-
LoggerService.Info($"Deleted {deletedCount} sensor data entries (full clear).");
61-
_lastSensorCleanup = nowUtc;
77+
LoggerService.Info($"Deleted {deletedCount} old sensor data entries.");
78+
// Run VACUUM to reclaim space (SQLite specific)
79+
try
80+
{
81+
await db.Ado.ExecuteCommandAsync("VACUUM;");
82+
LoggerService.Info("Database vacuumed successfully.");
83+
}
84+
catch (Exception ex)
85+
{
86+
LoggerService.Warn($"Failed to vacuum database: {ex.Message}");
87+
}
6288
}
89+
90+
_lastSensorCleanup = nowUtc;
6391
}
6492
}
6593

66-
private void ClearAllLogs()
94+
private void CleanOldLogs(int retentionDays)
6795
{
6896
var logDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
6997
if (!Directory.Exists(logDir)) return;
98+
99+
var cutoffDate = DateTime.Now.AddDays(-retentionDays);
70100
var files = Directory.GetFiles(logDir, "*.log", SearchOption.AllDirectories);
101+
var deletedCount = 0;
102+
71103
foreach (var file in files)
72104
{
73-
try { File.Delete(file); }
105+
try
106+
{
107+
var fileInfo = new FileInfo(file);
108+
if (fileInfo.LastWriteTime < cutoffDate)
109+
{
110+
File.Delete(file);
111+
deletedCount++;
112+
}
113+
}
74114
catch
75115
{
76116
// ignored
77117
}
78118
}
119+
120+
if (deletedCount > 0)
121+
{
122+
LoggerService.Info($"Deleted {deletedCount} old log files.");
123+
}
79124
}
80125

81126
public Task StopAsync(CancellationToken cancellationToken)

0 commit comments

Comments
 (0)