truthound-dashboard 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- truthound_dashboard/__init__.py +11 -0
- truthound_dashboard/__main__.py +6 -0
- truthound_dashboard/api/__init__.py +15 -0
- truthound_dashboard/api/deps.py +153 -0
- truthound_dashboard/api/drift.py +179 -0
- truthound_dashboard/api/error_handlers.py +287 -0
- truthound_dashboard/api/health.py +78 -0
- truthound_dashboard/api/history.py +62 -0
- truthound_dashboard/api/middleware.py +626 -0
- truthound_dashboard/api/notifications.py +561 -0
- truthound_dashboard/api/profile.py +52 -0
- truthound_dashboard/api/router.py +83 -0
- truthound_dashboard/api/rules.py +277 -0
- truthound_dashboard/api/schedules.py +329 -0
- truthound_dashboard/api/schemas.py +136 -0
- truthound_dashboard/api/sources.py +229 -0
- truthound_dashboard/api/validations.py +125 -0
- truthound_dashboard/cli.py +226 -0
- truthound_dashboard/config.py +132 -0
- truthound_dashboard/core/__init__.py +264 -0
- truthound_dashboard/core/base.py +185 -0
- truthound_dashboard/core/cache.py +479 -0
- truthound_dashboard/core/connections.py +331 -0
- truthound_dashboard/core/encryption.py +409 -0
- truthound_dashboard/core/exceptions.py +627 -0
- truthound_dashboard/core/logging.py +488 -0
- truthound_dashboard/core/maintenance.py +542 -0
- truthound_dashboard/core/notifications/__init__.py +56 -0
- truthound_dashboard/core/notifications/base.py +390 -0
- truthound_dashboard/core/notifications/channels.py +557 -0
- truthound_dashboard/core/notifications/dispatcher.py +453 -0
- truthound_dashboard/core/notifications/events.py +155 -0
- truthound_dashboard/core/notifications/service.py +744 -0
- truthound_dashboard/core/sampling.py +626 -0
- truthound_dashboard/core/scheduler.py +311 -0
- truthound_dashboard/core/services.py +1531 -0
- truthound_dashboard/core/truthound_adapter.py +659 -0
- truthound_dashboard/db/__init__.py +67 -0
- truthound_dashboard/db/base.py +108 -0
- truthound_dashboard/db/database.py +196 -0
- truthound_dashboard/db/models.py +732 -0
- truthound_dashboard/db/repository.py +237 -0
- truthound_dashboard/main.py +309 -0
- truthound_dashboard/schemas/__init__.py +150 -0
- truthound_dashboard/schemas/base.py +96 -0
- truthound_dashboard/schemas/drift.py +118 -0
- truthound_dashboard/schemas/history.py +74 -0
- truthound_dashboard/schemas/profile.py +91 -0
- truthound_dashboard/schemas/rule.py +199 -0
- truthound_dashboard/schemas/schedule.py +88 -0
- truthound_dashboard/schemas/schema.py +121 -0
- truthound_dashboard/schemas/source.py +138 -0
- truthound_dashboard/schemas/validation.py +192 -0
- truthound_dashboard/static/assets/index-BqJMyAHX.js +110 -0
- truthound_dashboard/static/assets/index-DMDxHCTs.js +465 -0
- truthound_dashboard/static/assets/index-Dm2D11TK.css +1 -0
- truthound_dashboard/static/index.html +15 -0
- truthound_dashboard/static/mockServiceWorker.js +349 -0
- truthound_dashboard-1.0.0.dist-info/METADATA +218 -0
- truthound_dashboard-1.0.0.dist-info/RECORD +62 -0
- truthound_dashboard-1.0.0.dist-info/WHEEL +4 -0
- truthound_dashboard-1.0.0.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
"""Database maintenance and cleanup tasks.
|
|
2
|
+
|
|
3
|
+
This module provides automated database maintenance operations including:
|
|
4
|
+
- Cleanup of old validation records
|
|
5
|
+
- Removal of stale profile data
|
|
6
|
+
- Notification log cleanup
|
|
7
|
+
- Database optimization (VACUUM)
|
|
8
|
+
|
|
9
|
+
The maintenance system uses a configurable strategy pattern allowing
|
|
10
|
+
custom cleanup policies.
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
manager = get_maintenance_manager()
|
|
14
|
+
await manager.run_cleanup()
|
|
15
|
+
await manager.vacuum()
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from sqlalchemy import delete, func, select, text
|
|
28
|
+
|
|
29
|
+
from truthound_dashboard.config import get_settings
|
|
30
|
+
from truthound_dashboard.db import get_session
|
|
31
|
+
from truthound_dashboard.db.models import (
|
|
32
|
+
NotificationLog,
|
|
33
|
+
Profile,
|
|
34
|
+
Source,
|
|
35
|
+
Validation,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class CleanupResult:
|
|
43
|
+
"""Result of a cleanup operation.
|
|
44
|
+
|
|
45
|
+
Attributes:
|
|
46
|
+
task_name: Name of the cleanup task.
|
|
47
|
+
records_deleted: Number of records deleted.
|
|
48
|
+
duration_ms: Duration in milliseconds.
|
|
49
|
+
success: Whether the operation succeeded.
|
|
50
|
+
error: Error message if failed.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
task_name: str
|
|
54
|
+
records_deleted: int = 0
|
|
55
|
+
duration_ms: int = 0
|
|
56
|
+
success: bool = True
|
|
57
|
+
error: str | None = None
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict[str, Any]:
|
|
60
|
+
"""Convert to dictionary."""
|
|
61
|
+
return {
|
|
62
|
+
"task_name": self.task_name,
|
|
63
|
+
"records_deleted": self.records_deleted,
|
|
64
|
+
"duration_ms": self.duration_ms,
|
|
65
|
+
"success": self.success,
|
|
66
|
+
"error": self.error,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class MaintenanceConfig:
|
|
72
|
+
"""Configuration for maintenance tasks.
|
|
73
|
+
|
|
74
|
+
Attributes:
|
|
75
|
+
validation_retention_days: Days to keep validation records.
|
|
76
|
+
profile_keep_per_source: Number of profiles to keep per source.
|
|
77
|
+
notification_log_retention_days: Days to keep notification logs.
|
|
78
|
+
run_vacuum: Whether to run VACUUM after cleanup.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
validation_retention_days: int = 90
|
|
82
|
+
profile_keep_per_source: int = 5
|
|
83
|
+
notification_log_retention_days: int = 30
|
|
84
|
+
run_vacuum: bool = True
|
|
85
|
+
enabled: bool = True
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CleanupStrategy(ABC):
|
|
89
|
+
"""Abstract base class for cleanup strategies.
|
|
90
|
+
|
|
91
|
+
Subclass this to implement custom cleanup logic.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
@abstractmethod
|
|
96
|
+
def name(self) -> str:
|
|
97
|
+
"""Get strategy name."""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
@abstractmethod
|
|
101
|
+
async def execute(self, config: MaintenanceConfig) -> CleanupResult:
|
|
102
|
+
"""Execute cleanup strategy.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
config: Maintenance configuration.
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
CleanupResult with operation details.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ValidationCleanupStrategy(CleanupStrategy):
|
|
114
|
+
"""Cleanup strategy for old validation records."""
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
return "validation_cleanup"
|
|
119
|
+
|
|
120
|
+
async def execute(self, config: MaintenanceConfig) -> CleanupResult:
|
|
121
|
+
"""Remove validation records older than retention period."""
|
|
122
|
+
start_time = datetime.utcnow()
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
cutoff = datetime.utcnow() - timedelta(
|
|
126
|
+
days=config.validation_retention_days
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async with get_session() as session:
|
|
130
|
+
# Count records to delete
|
|
131
|
+
count_result = await session.execute(
|
|
132
|
+
select(func.count(Validation.id)).where(
|
|
133
|
+
Validation.created_at < cutoff
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
count = count_result.scalar() or 0
|
|
137
|
+
|
|
138
|
+
# Delete old records
|
|
139
|
+
if count > 0:
|
|
140
|
+
await session.execute(
|
|
141
|
+
delete(Validation).where(Validation.created_at < cutoff)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
duration = int(
|
|
145
|
+
(datetime.utcnow() - start_time).total_seconds() * 1000
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
logger.info(
|
|
149
|
+
f"Validation cleanup: deleted {count} records "
|
|
150
|
+
f"older than {config.validation_retention_days} days"
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return CleanupResult(
|
|
154
|
+
task_name=self.name,
|
|
155
|
+
records_deleted=count,
|
|
156
|
+
duration_ms=duration,
|
|
157
|
+
success=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
|
|
162
|
+
logger.error(f"Validation cleanup failed: {e}")
|
|
163
|
+
return CleanupResult(
|
|
164
|
+
task_name=self.name,
|
|
165
|
+
duration_ms=duration,
|
|
166
|
+
success=False,
|
|
167
|
+
error=str(e),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class ProfileCleanupStrategy(CleanupStrategy):
|
|
172
|
+
"""Cleanup strategy for keeping only recent profiles per source."""
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def name(self) -> str:
|
|
176
|
+
return "profile_cleanup"
|
|
177
|
+
|
|
178
|
+
async def execute(self, config: MaintenanceConfig) -> CleanupResult:
|
|
179
|
+
"""Keep only the most recent N profiles per source."""
|
|
180
|
+
start_time = datetime.utcnow()
|
|
181
|
+
total_deleted = 0
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
async with get_session() as session:
|
|
185
|
+
# Get all source IDs
|
|
186
|
+
sources_result = await session.execute(select(Source.id))
|
|
187
|
+
source_ids = [row[0] for row in sources_result]
|
|
188
|
+
|
|
189
|
+
for source_id in source_ids:
|
|
190
|
+
# Get IDs of profiles to keep
|
|
191
|
+
keep_result = await session.execute(
|
|
192
|
+
select(Profile.id)
|
|
193
|
+
.where(Profile.source_id == source_id)
|
|
194
|
+
.order_by(Profile.created_at.desc())
|
|
195
|
+
.limit(config.profile_keep_per_source)
|
|
196
|
+
)
|
|
197
|
+
keep_ids = [row[0] for row in keep_result]
|
|
198
|
+
|
|
199
|
+
if not keep_ids:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
# Count and delete excess profiles
|
|
203
|
+
count_result = await session.execute(
|
|
204
|
+
select(func.count(Profile.id))
|
|
205
|
+
.where(Profile.source_id == source_id)
|
|
206
|
+
.where(Profile.id.not_in(keep_ids))
|
|
207
|
+
)
|
|
208
|
+
count = count_result.scalar() or 0
|
|
209
|
+
|
|
210
|
+
if count > 0:
|
|
211
|
+
await session.execute(
|
|
212
|
+
delete(Profile)
|
|
213
|
+
.where(Profile.source_id == source_id)
|
|
214
|
+
.where(Profile.id.not_in(keep_ids))
|
|
215
|
+
)
|
|
216
|
+
total_deleted += count
|
|
217
|
+
|
|
218
|
+
duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
|
|
219
|
+
|
|
220
|
+
logger.info(
|
|
221
|
+
f"Profile cleanup: deleted {total_deleted} records, "
|
|
222
|
+
f"keeping {config.profile_keep_per_source} per source"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return CleanupResult(
|
|
226
|
+
task_name=self.name,
|
|
227
|
+
records_deleted=total_deleted,
|
|
228
|
+
duration_ms=duration,
|
|
229
|
+
success=True,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
except Exception as e:
|
|
233
|
+
duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
|
|
234
|
+
logger.error(f"Profile cleanup failed: {e}")
|
|
235
|
+
return CleanupResult(
|
|
236
|
+
task_name=self.name,
|
|
237
|
+
duration_ms=duration,
|
|
238
|
+
success=False,
|
|
239
|
+
error=str(e),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class NotificationLogCleanupStrategy(CleanupStrategy):
|
|
244
|
+
"""Cleanup strategy for old notification logs."""
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def name(self) -> str:
|
|
248
|
+
return "notification_log_cleanup"
|
|
249
|
+
|
|
250
|
+
async def execute(self, config: MaintenanceConfig) -> CleanupResult:
|
|
251
|
+
"""Remove notification logs older than retention period."""
|
|
252
|
+
start_time = datetime.utcnow()
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
cutoff = datetime.utcnow() - timedelta(
|
|
256
|
+
days=config.notification_log_retention_days
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async with get_session() as session:
|
|
260
|
+
# Count records to delete
|
|
261
|
+
count_result = await session.execute(
|
|
262
|
+
select(func.count(NotificationLog.id)).where(
|
|
263
|
+
NotificationLog.created_at < cutoff
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
count = count_result.scalar() or 0
|
|
267
|
+
|
|
268
|
+
# Delete old records
|
|
269
|
+
if count > 0:
|
|
270
|
+
await session.execute(
|
|
271
|
+
delete(NotificationLog).where(
|
|
272
|
+
NotificationLog.created_at < cutoff
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
duration = int(
|
|
277
|
+
(datetime.utcnow() - start_time).total_seconds() * 1000
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
logger.info(
|
|
281
|
+
f"Notification log cleanup: deleted {count} records "
|
|
282
|
+
f"older than {config.notification_log_retention_days} days"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
return CleanupResult(
|
|
286
|
+
task_name=self.name,
|
|
287
|
+
records_deleted=count,
|
|
288
|
+
duration_ms=duration,
|
|
289
|
+
success=True,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
except Exception as e:
|
|
293
|
+
duration = int((datetime.utcnow() - start_time).total_seconds() * 1000)
|
|
294
|
+
logger.error(f"Notification log cleanup failed: {e}")
|
|
295
|
+
return CleanupResult(
|
|
296
|
+
task_name=self.name,
|
|
297
|
+
duration_ms=duration,
|
|
298
|
+
success=False,
|
|
299
|
+
error=str(e),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass
|
|
304
|
+
class MaintenanceReport:
|
|
305
|
+
"""Report of a complete maintenance run.
|
|
306
|
+
|
|
307
|
+
Attributes:
|
|
308
|
+
started_at: When maintenance started.
|
|
309
|
+
completed_at: When maintenance completed.
|
|
310
|
+
results: List of cleanup results.
|
|
311
|
+
vacuum_performed: Whether VACUUM was run.
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
started_at: datetime
|
|
315
|
+
completed_at: datetime | None = None
|
|
316
|
+
results: list[CleanupResult] = field(default_factory=list)
|
|
317
|
+
vacuum_performed: bool = False
|
|
318
|
+
vacuum_error: str | None = None
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def total_deleted(self) -> int:
|
|
322
|
+
"""Get total records deleted across all tasks."""
|
|
323
|
+
return sum(r.records_deleted for r in self.results)
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def total_duration_ms(self) -> int:
|
|
327
|
+
"""Get total duration in milliseconds."""
|
|
328
|
+
if self.completed_at:
|
|
329
|
+
return int((self.completed_at - self.started_at).total_seconds() * 1000)
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
@property
|
|
333
|
+
def success(self) -> bool:
|
|
334
|
+
"""Check if all tasks succeeded."""
|
|
335
|
+
return all(r.success for r in self.results) and self.vacuum_error is None
|
|
336
|
+
|
|
337
|
+
def to_dict(self) -> dict[str, Any]:
|
|
338
|
+
"""Convert to dictionary."""
|
|
339
|
+
return {
|
|
340
|
+
"started_at": self.started_at.isoformat(),
|
|
341
|
+
"completed_at": (
|
|
342
|
+
self.completed_at.isoformat() if self.completed_at else None
|
|
343
|
+
),
|
|
344
|
+
"results": [r.to_dict() for r in self.results],
|
|
345
|
+
"total_deleted": self.total_deleted,
|
|
346
|
+
"total_duration_ms": self.total_duration_ms,
|
|
347
|
+
"vacuum_performed": self.vacuum_performed,
|
|
348
|
+
"vacuum_error": self.vacuum_error,
|
|
349
|
+
"success": self.success,
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class MaintenanceManager:
|
|
354
|
+
"""Manager for database maintenance operations.
|
|
355
|
+
|
|
356
|
+
Coordinates cleanup strategies and provides a unified interface
|
|
357
|
+
for maintenance tasks.
|
|
358
|
+
|
|
359
|
+
Usage:
|
|
360
|
+
manager = MaintenanceManager()
|
|
361
|
+
manager.register_strategy(ValidationCleanupStrategy())
|
|
362
|
+
report = await manager.run_cleanup()
|
|
363
|
+
"""
|
|
364
|
+
|
|
365
|
+
def __init__(self, config: MaintenanceConfig | None = None) -> None:
|
|
366
|
+
"""Initialize maintenance manager.
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
config: Maintenance configuration. Uses defaults if not provided.
|
|
370
|
+
"""
|
|
371
|
+
self._config = config or MaintenanceConfig()
|
|
372
|
+
self._strategies: list[CleanupStrategy] = []
|
|
373
|
+
|
|
374
|
+
@property
|
|
375
|
+
def config(self) -> MaintenanceConfig:
|
|
376
|
+
"""Get maintenance configuration."""
|
|
377
|
+
return self._config
|
|
378
|
+
|
|
379
|
+
def register_strategy(self, strategy: CleanupStrategy) -> None:
|
|
380
|
+
"""Register a cleanup strategy.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
strategy: Cleanup strategy to register.
|
|
384
|
+
"""
|
|
385
|
+
self._strategies.append(strategy)
|
|
386
|
+
|
|
387
|
+
def register_default_strategies(self) -> None:
|
|
388
|
+
"""Register all default cleanup strategies."""
|
|
389
|
+
self._strategies = [
|
|
390
|
+
ValidationCleanupStrategy(),
|
|
391
|
+
ProfileCleanupStrategy(),
|
|
392
|
+
NotificationLogCleanupStrategy(),
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
async def run_cleanup(self) -> MaintenanceReport:
|
|
396
|
+
"""Run all registered cleanup strategies.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
MaintenanceReport with all results.
|
|
400
|
+
"""
|
|
401
|
+
if not self._config.enabled:
|
|
402
|
+
logger.info("Maintenance is disabled")
|
|
403
|
+
return MaintenanceReport(
|
|
404
|
+
started_at=datetime.utcnow(),
|
|
405
|
+
completed_at=datetime.utcnow(),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
report = MaintenanceReport(started_at=datetime.utcnow())
|
|
409
|
+
|
|
410
|
+
for strategy in self._strategies:
|
|
411
|
+
try:
|
|
412
|
+
result = await strategy.execute(self._config)
|
|
413
|
+
report.results.append(result)
|
|
414
|
+
except Exception as e:
|
|
415
|
+
logger.error(f"Strategy {strategy.name} failed: {e}")
|
|
416
|
+
report.results.append(
|
|
417
|
+
CleanupResult(
|
|
418
|
+
task_name=strategy.name,
|
|
419
|
+
success=False,
|
|
420
|
+
error=str(e),
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
# Run VACUUM if configured
|
|
425
|
+
if self._config.run_vacuum:
|
|
426
|
+
try:
|
|
427
|
+
await self.vacuum()
|
|
428
|
+
report.vacuum_performed = True
|
|
429
|
+
except Exception as e:
|
|
430
|
+
report.vacuum_error = str(e)
|
|
431
|
+
logger.error(f"VACUUM failed: {e}")
|
|
432
|
+
|
|
433
|
+
report.completed_at = datetime.utcnow()
|
|
434
|
+
|
|
435
|
+
logger.info(
|
|
436
|
+
f"Maintenance completed: {report.total_deleted} records deleted "
|
|
437
|
+
f"in {report.total_duration_ms}ms"
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return report
|
|
441
|
+
|
|
442
|
+
async def vacuum(self) -> None:
|
|
443
|
+
"""Run SQLite VACUUM to reclaim space."""
|
|
444
|
+
from truthound_dashboard.db.database import get_engine
|
|
445
|
+
|
|
446
|
+
logger.info("Running database VACUUM")
|
|
447
|
+
|
|
448
|
+
engine = get_engine()
|
|
449
|
+
async with engine.begin() as conn:
|
|
450
|
+
# VACUUM cannot run in a transaction, so we need raw connection
|
|
451
|
+
await conn.execute(text("VACUUM"))
|
|
452
|
+
|
|
453
|
+
logger.info("Database VACUUM completed")
|
|
454
|
+
|
|
455
|
+
async def run_task(self, task_name: str) -> CleanupResult | None:
|
|
456
|
+
"""Run a specific cleanup task by name.
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
task_name: Name of the task to run.
|
|
460
|
+
|
|
461
|
+
Returns:
|
|
462
|
+
CleanupResult or None if task not found.
|
|
463
|
+
"""
|
|
464
|
+
for strategy in self._strategies:
|
|
465
|
+
if strategy.name == task_name:
|
|
466
|
+
return await strategy.execute(self._config)
|
|
467
|
+
return None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# Singleton instance
|
|
471
|
+
_manager: MaintenanceManager | None = None
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def get_maintenance_manager() -> MaintenanceManager:
|
|
475
|
+
"""Get maintenance manager singleton.
|
|
476
|
+
|
|
477
|
+
Returns:
|
|
478
|
+
MaintenanceManager with default strategies registered.
|
|
479
|
+
"""
|
|
480
|
+
global _manager
|
|
481
|
+
if _manager is None:
|
|
482
|
+
_manager = MaintenanceManager()
|
|
483
|
+
_manager.register_default_strategies()
|
|
484
|
+
return _manager
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def reset_maintenance_manager() -> None:
|
|
488
|
+
"""Reset maintenance manager singleton (for testing)."""
|
|
489
|
+
global _manager
|
|
490
|
+
_manager = None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# Convenience functions for scheduled tasks
|
|
494
|
+
async def cleanup_old_validations(days: int | None = None) -> CleanupResult:
|
|
495
|
+
"""Cleanup old validation records.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
days: Override retention days. Uses config default if not specified.
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
CleanupResult with operation details.
|
|
502
|
+
"""
|
|
503
|
+
manager = get_maintenance_manager()
|
|
504
|
+
if days is not None:
|
|
505
|
+
manager.config.validation_retention_days = days
|
|
506
|
+
return await ValidationCleanupStrategy().execute(manager.config)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
async def cleanup_old_profiles(keep_per_source: int | None = None) -> CleanupResult:
|
|
510
|
+
"""Cleanup old profile records.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
keep_per_source: Override profiles to keep. Uses config default if not specified.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
CleanupResult with operation details.
|
|
517
|
+
"""
|
|
518
|
+
manager = get_maintenance_manager()
|
|
519
|
+
if keep_per_source is not None:
|
|
520
|
+
manager.config.profile_keep_per_source = keep_per_source
|
|
521
|
+
return await ProfileCleanupStrategy().execute(manager.config)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
async def cleanup_notification_logs(days: int | None = None) -> CleanupResult:
|
|
525
|
+
"""Cleanup old notification logs.
|
|
526
|
+
|
|
527
|
+
Args:
|
|
528
|
+
days: Override retention days. Uses config default if not specified.
|
|
529
|
+
|
|
530
|
+
Returns:
|
|
531
|
+
CleanupResult with operation details.
|
|
532
|
+
"""
|
|
533
|
+
manager = get_maintenance_manager()
|
|
534
|
+
if days is not None:
|
|
535
|
+
manager.config.notification_log_retention_days = days
|
|
536
|
+
return await NotificationLogCleanupStrategy().execute(manager.config)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
async def vacuum_database() -> None:
|
|
540
|
+
"""Run database VACUUM."""
|
|
541
|
+
manager = get_maintenance_manager()
|
|
542
|
+
await manager.vacuum()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Notification system for truthound dashboard.
|
|
2
|
+
|
|
3
|
+
This package provides an extensible notification system with support for
|
|
4
|
+
multiple channels (Slack, Email, Webhook) and configurable rules.
|
|
5
|
+
|
|
6
|
+
Architecture:
|
|
7
|
+
- BaseNotificationChannel: Abstract base for channel implementations
|
|
8
|
+
- ChannelRegistry: Registry for channel type discovery
|
|
9
|
+
- NotificationDispatcher: Orchestrates notification delivery
|
|
10
|
+
- NotificationService: Business logic for notifications
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
# Register a custom channel
|
|
14
|
+
@ChannelRegistry.register("custom")
|
|
15
|
+
class CustomChannel(BaseNotificationChannel):
|
|
16
|
+
async def send(self, message: str, **kwargs) -> bool:
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
# Send notifications
|
|
20
|
+
dispatcher = get_dispatcher()
|
|
21
|
+
await dispatcher.notify_validation_failed(validation)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from .base import (
|
|
25
|
+
BaseNotificationChannel,
|
|
26
|
+
ChannelRegistry,
|
|
27
|
+
NotificationEvent,
|
|
28
|
+
NotificationResult,
|
|
29
|
+
)
|
|
30
|
+
from .channels import EmailChannel, SlackChannel, WebhookChannel
|
|
31
|
+
from .dispatcher import NotificationDispatcher, create_dispatcher, get_dispatcher
|
|
32
|
+
from .events import (
|
|
33
|
+
DriftDetectedEvent,
|
|
34
|
+
ScheduleFailedEvent,
|
|
35
|
+
ValidationFailedEvent,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Base classes
|
|
40
|
+
"BaseNotificationChannel",
|
|
41
|
+
"ChannelRegistry",
|
|
42
|
+
"NotificationEvent",
|
|
43
|
+
"NotificationResult",
|
|
44
|
+
# Channel implementations
|
|
45
|
+
"SlackChannel",
|
|
46
|
+
"EmailChannel",
|
|
47
|
+
"WebhookChannel",
|
|
48
|
+
# Dispatcher
|
|
49
|
+
"NotificationDispatcher",
|
|
50
|
+
"create_dispatcher",
|
|
51
|
+
"get_dispatcher",
|
|
52
|
+
# Events
|
|
53
|
+
"ValidationFailedEvent",
|
|
54
|
+
"ScheduleFailedEvent",
|
|
55
|
+
"DriftDetectedEvent",
|
|
56
|
+
]
|