Skip to content

Commit 4704bd6

Browse files
feat(settings): add settings management endpoints for saving and clearing data
- Implemented a POST endpoint to save settings, allowing users to store configuration values. - Added a DELETE endpoint to clear specific data types from the database. - Introduced a manual cleanup endpoint to remove old records based on retention policies. - Enhanced the database size retrieval functionality for better monitoring. - Updated the settings page to display database size and provide a manual cleanup option.
1 parent beebbeb commit 4704bd6

6 files changed

Lines changed: 443 additions & 3 deletions

File tree

schema-monitor/api/routes.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,130 @@ async def test_alert_channel(request: Request, channel_name: str) -> dict[str, A
10801080
}
10811081

10821082

1083+
# === Settings Yönetimi ===
1084+
1085+
@router.post("/api/settings")
1086+
async def save_settings(request: Request) -> dict[str, Any]:
1087+
"""Ayarları kaydet.
1088+
1089+
Args:
1090+
request: HTTP isteği
1091+
1092+
Returns:
1093+
Kayıt sonucu
1094+
"""
1095+
storage = request.app.state.storage
1096+
if storage is None:
1097+
raise HTTPException(status_code=503, detail="Storage backend hazır değil")
1098+
1099+
body = await request.json()
1100+
saved_keys: list[str] = []
1101+
1102+
for key, value in body.items():
1103+
await storage.save_setting(key, str(value))
1104+
saved_keys.append(key)
1105+
1106+
logger.info(f"Ayarlar kaydedildi: {saved_keys}")
1107+
1108+
return {"saved_keys": saved_keys, "message": "Ayarlar başarıyla kaydedildi"}
1109+
1110+
1111+
@router.delete("/api/settings/clear/{data_type}")
1112+
async def clear_data(request: Request, data_type: str) -> dict[str, Any]:
1113+
"""Veritabanı verilerini temizle.
1114+
1115+
Args:
1116+
request: HTTP isteği
1117+
data_type: Silinecek veri tipi (changes, snapshots, all)
1118+
1119+
Returns:
1120+
Silme sonucu
1121+
"""
1122+
storage = request.app.state.storage
1123+
if storage is None:
1124+
raise HTTPException(status_code=503, detail="Storage backend hazır değil")
1125+
1126+
valid_types = {"changes", "snapshots", "all"}
1127+
if data_type not in valid_types:
1128+
raise HTTPException(status_code=400, detail=f"Geçersiz veri tipi: {data_type}")
1129+
1130+
deleted: dict[str, int] = {}
1131+
1132+
if data_type == "changes":
1133+
deleted["changes"] = await storage.clear_table("changes")
1134+
elif data_type == "snapshots":
1135+
deleted["snapshots"] = await storage.clear_table("snapshots")
1136+
elif data_type == "all":
1137+
deleted["changes"] = await storage.clear_table("changes")
1138+
deleted["snapshots"] = await storage.clear_table("snapshots")
1139+
deleted["endpoints"] = await storage.clear_table("endpoints")
1140+
deleted["providers"] = await storage.clear_table("providers")
1141+
deleted["credentials"] = await storage.clear_table("credentials")
1142+
1143+
# Boş alanları geri kazan
1144+
await storage.vacuum()
1145+
1146+
total = sum(deleted.values())
1147+
logger.info(f"Veri temizliği ({data_type}): toplam {total} kayıt silindi")
1148+
1149+
return {"data_type": data_type, "deleted": deleted, "message": f"{total} kayıt silindi"}
1150+
1151+
1152+
@router.post("/api/settings/cleanup")
1153+
async def run_cleanup(request: Request) -> dict[str, Any]:
1154+
"""Manuel retention temizliği çalıştır.
1155+
1156+
Settings'teki max_changes_retained ve max_snapshots_per_endpoint
1157+
değerlerine göre eski verileri temizler.
1158+
1159+
Args:
1160+
request: HTTP isteği
1161+
1162+
Returns:
1163+
Temizlik sonucu
1164+
"""
1165+
storage = request.app.state.storage
1166+
if storage is None:
1167+
raise HTTPException(status_code=503, detail="Storage backend hazır değil")
1168+
1169+
# Retention ayarlarını oku
1170+
max_changes = int(await storage.get_setting("max_changes_retained", "1000"))
1171+
max_snapshots = int(await storage.get_setting("max_snapshots_per_endpoint", "50"))
1172+
1173+
deleted_changes = await storage.cleanup_old_changes(max_changes)
1174+
deleted_snapshots = await storage.cleanup_old_snapshots(max_snapshots)
1175+
1176+
# Silinen varsa VACUUM yap
1177+
if deleted_changes > 0 or deleted_snapshots > 0:
1178+
await storage.vacuum()
1179+
1180+
db_size = await storage.get_db_size_bytes()
1181+
1182+
return {
1183+
"deleted_changes": deleted_changes,
1184+
"deleted_snapshots": deleted_snapshots,
1185+
"db_size_bytes": db_size,
1186+
"db_size_human": _format_bytes(db_size),
1187+
"message": f"Temizlik tamamlandı: {deleted_changes} change, {deleted_snapshots} snapshot silindi",
1188+
}
1189+
1190+
1191+
def _format_bytes(size: int) -> str:
1192+
"""Byte değerini okunabilir formata dönüştür.
1193+
1194+
Args:
1195+
size: Byte cinsinden boyut
1196+
1197+
Returns:
1198+
Okunabilir boyut string'i (örn: '2.4 MB')
1199+
"""
1200+
for unit in ("B", "KB", "MB", "GB"):
1201+
if size < 1024:
1202+
return f"{size:.1f} {unit}" if size >= 10 else f"{size:.2f} {unit}"
1203+
size /= 1024
1204+
return f"{size:.1f} TB"
1205+
1206+
10831207
# === Metrics ===
10841208

10851209
@router.get("/metrics")

schema-monitor/api/views.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010

1111
logger = logging.getLogger(__name__)
1212

13+
14+
def _format_bytes(size: int) -> str:
15+
"""Byte değerini okunabilir formata dönüştür.
16+
17+
Args:
18+
size: Byte cinsinden boyut
19+
20+
Returns:
21+
Okunabilir boyut string'i (örn: '2.4 MB')
22+
"""
23+
for unit in ("B", "KB", "MB", "GB"):
24+
if size < 1024:
25+
return f"{size:.1f} {unit}" if size >= 10 else f"{size:.2f} {unit}"
26+
size /= 1024
27+
return f"{size:.1f} TB"
28+
1329
view_router = APIRouter()
1430

1531
# Templates dizini — app.py'de mount edilecek
@@ -299,6 +315,11 @@ async def settings_page(request: Request) -> HTMLResponse:
299315
for key, value in table_counts.items():
300316
settings_data[key] = str(value)
301317

318+
# DB dosya boyutunu ekle
319+
db_size_bytes = await storage.get_db_size_bytes()
320+
settings_data["db_size_bytes"] = str(db_size_bytes)
321+
settings_data["db_size_human"] = _format_bytes(db_size_bytes)
322+
302323
return templates.TemplateResponse(
303324
"settings.html",
304325
{

schema-monitor/core/store/base.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,58 @@ async def acknowledge_change(self, change_id: int) -> bool:
115115
Başarılıysa True
116116
"""
117117
...
118+
119+
@abstractmethod
120+
async def cleanup_old_snapshots(self, max_per_endpoint: int) -> int:
121+
"""Her endpoint için eski snapshot'ları temizle.
122+
123+
Her endpoint'te en fazla max_per_endpoint kadar snapshot bırakır,
124+
geri kalanını (en eski olanları) siler.
125+
126+
Args:
127+
max_per_endpoint: Endpoint başına saklanacak max snapshot sayısı
128+
129+
Returns:
130+
Silinen snapshot sayısı
131+
"""
132+
...
133+
134+
@abstractmethod
135+
async def cleanup_old_changes(self, max_total: int) -> int:
136+
"""Eski değişiklik kayıtlarını temizle.
137+
138+
Toplam max_total kadar kayıt bırakır, en eskileri siler.
139+
140+
Args:
141+
max_total: Saklanacak max toplam değişiklik sayısı
142+
143+
Returns:
144+
Silinen değişiklik sayısı
145+
"""
146+
...
147+
148+
@abstractmethod
149+
async def get_db_size_bytes(self) -> int:
150+
"""Veritabanı dosya boyutunu byte cinsinden döndür.
151+
152+
Returns:
153+
Dosya boyutu (byte)
154+
"""
155+
...
156+
157+
@abstractmethod
158+
async def clear_table(self, table_name: str) -> int:
159+
"""Belirtilen tablonun tüm kayıtlarını sil.
160+
161+
Args:
162+
table_name: Silinecek tablo adı
163+
164+
Returns:
165+
Silinen kayıt sayısı
166+
"""
167+
...
168+
169+
@abstractmethod
170+
async def vacuum(self) -> None:
171+
"""Veritabanı dosyasını sıkıştır (boş alanları geri kazan)."""
172+
...

schema-monitor/core/store/sqlite_store.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,153 @@ async def get_table_counts(self) -> dict[str, int]:
818818

819819
return counts
820820

821+
# === Retention / Cleanup ===
822+
823+
async def cleanup_old_snapshots(self, max_per_endpoint: int) -> int:
824+
"""Her endpoint için eski snapshot'ları temizle.
825+
826+
Her endpoint'te en fazla max_per_endpoint kadar snapshot bırakır,
827+
geri kalanını (en eski olanları) siler.
828+
829+
Args:
830+
max_per_endpoint: Endpoint başına saklanacak max snapshot sayısı
831+
832+
Returns:
833+
Silinen snapshot sayısı
834+
"""
835+
conn = self._get_connection()
836+
837+
# Her provider/endpoint çifti için en yeni max_per_endpoint kadarını bırak
838+
cursor = conn.execute(
839+
"SELECT DISTINCT provider, endpoint FROM snapshots"
840+
)
841+
pairs = [(row["provider"], row["endpoint"]) for row in cursor.fetchall()]
842+
843+
total_deleted = 0
844+
for provider, endpoint in pairs:
845+
# En yeni max_per_endpoint id'sini bul (eşik)
846+
threshold_cursor = conn.execute(
847+
"""SELECT id FROM snapshots
848+
WHERE provider = ? AND endpoint = ?
849+
ORDER BY version DESC
850+
LIMIT 1 OFFSET ?""",
851+
(provider, endpoint, max_per_endpoint - 1),
852+
)
853+
threshold_row = threshold_cursor.fetchone()
854+
855+
if threshold_row is None:
856+
# Bu endpoint'te max_per_endpoint'ten az snapshot var
857+
continue
858+
859+
# Eşiğin altındaki eski kayıtları sil
860+
delete_cursor = conn.execute(
861+
"""DELETE FROM snapshots
862+
WHERE provider = ? AND endpoint = ? AND id < ?""",
863+
(provider, endpoint, threshold_row["id"]),
864+
)
865+
total_deleted += delete_cursor.rowcount
866+
867+
if total_deleted > 0:
868+
conn.commit()
869+
logger.info(f"Eski snapshot temizliği: {total_deleted} kayıt silindi")
870+
871+
return total_deleted
872+
873+
async def cleanup_old_changes(self, max_total: int) -> int:
874+
"""Eski değişiklik kayıtlarını temizle.
875+
876+
Toplam max_total kadar kayıt bırakır, en eskileri siler.
877+
878+
Args:
879+
max_total: Saklanacak max toplam değişiklik sayısı
880+
881+
Returns:
882+
Silinen değişiklik sayısı
883+
"""
884+
conn = self._get_connection()
885+
886+
# Mevcut toplam sayı
887+
cursor = conn.execute("SELECT COUNT(*) as cnt FROM changes")
888+
total = cursor.fetchone()["cnt"]
889+
890+
if total <= max_total:
891+
return 0
892+
893+
# En yeni max_total kaydın id eşiğini bul
894+
threshold_cursor = conn.execute(
895+
"""SELECT id FROM changes
896+
ORDER BY detected_at DESC
897+
LIMIT 1 OFFSET ?""",
898+
(max_total - 1,),
899+
)
900+
threshold_row = threshold_cursor.fetchone()
901+
902+
if threshold_row is None:
903+
return 0
904+
905+
# Eşiğin altındaki eski kayıtları sil
906+
delete_cursor = conn.execute(
907+
"DELETE FROM changes WHERE id < ?",
908+
(threshold_row["id"],),
909+
)
910+
deleted = delete_cursor.rowcount
911+
conn.commit()
912+
913+
if deleted > 0:
914+
logger.info(f"Eski değişiklik temizliği: {deleted} kayıt silindi")
915+
916+
return deleted
917+
918+
async def get_db_size_bytes(self) -> int:
919+
"""Veritabanı dosya boyutunu byte cinsinden döndür.
920+
921+
Returns:
922+
Dosya boyutu (byte)
923+
"""
924+
db_path = Path(self._db_path)
925+
if not db_path.exists():
926+
return 0
927+
928+
total = db_path.stat().st_size
929+
930+
# WAL ve SHM dosyalarını da hesaba kat
931+
wal_path = db_path.with_suffix(".db-wal")
932+
shm_path = db_path.with_suffix(".db-shm")
933+
if wal_path.exists():
934+
total += wal_path.stat().st_size
935+
if shm_path.exists():
936+
total += shm_path.stat().st_size
937+
938+
return total
939+
940+
async def clear_table(self, table_name: str) -> int:
941+
"""Belirtilen tablonun tüm kayıtlarını sil.
942+
943+
Args:
944+
table_name: Silinecek tablo adı
945+
946+
Returns:
947+
Silinen kayıt sayısı
948+
"""
949+
# Güvenlik: sadece bilinen tabloları sil
950+
allowed_tables = {"snapshots", "changes", "providers", "endpoints", "credentials", "alert_configs"}
951+
if table_name not in allowed_tables:
952+
logger.warning(f"Bilinmeyen tablo temizleme isteği reddedildi: {table_name}")
953+
return 0
954+
955+
conn = self._get_connection()
956+
cursor = conn.execute(f"DELETE FROM {table_name}") # noqa: S608
957+
conn.commit()
958+
deleted = cursor.rowcount
959+
logger.info(f"Tablo temizlendi: {table_name} ({deleted} kayıt)")
960+
return deleted
961+
962+
async def vacuum(self) -> None:
963+
"""Veritabanı dosyasını sıkıştır (boş alanları geri kazan)."""
964+
conn = self._get_connection()
965+
conn.execute("VACUUM")
966+
logger.info("Veritabanı VACUUM tamamlandı")
967+
821968
# === İstatistikler ===
822969

823970
async def get_stats(self) -> dict[str, Any]:

0 commit comments

Comments
 (0)