180 lines
6.1 KiB
Python
180 lines
6.1 KiB
Python
"""Debug mode and log cleanup service for VoIdea."""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.agent import AgentConfig
|
|
from app.models.log import LogEntry
|
|
|
|
logger = logging.getLogger("voidea.debug")
|
|
|
|
_DEBUG_KEY = "debug_config"
|
|
|
|
DEFAULT_DEBUG_CONFIG: dict[str, Any] = {
|
|
"debug_mode": False,
|
|
"debug_mode_expires_at": None,
|
|
"cleanup_interval_hours": 24,
|
|
"log_retention_days": 14,
|
|
"log_max_bytes": 524288000,
|
|
"notify_at_percent": 80,
|
|
}
|
|
|
|
|
|
class DebugService:
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
|
|
async def _get_config_raw(self) -> dict[str, Any]:
|
|
result = await self.db.execute(
|
|
select(AgentConfig).where(AgentConfig.agent_name == "system")
|
|
)
|
|
cfg = result.scalar_one_or_none()
|
|
if not cfg or not cfg.config:
|
|
return dict(DEFAULT_DEBUG_CONFIG)
|
|
try:
|
|
data = json.loads(cfg.config)
|
|
return data.get(_DEBUG_KEY, dict(DEFAULT_DEBUG_CONFIG))
|
|
except (json.JSONDecodeError, TypeError):
|
|
return dict(DEFAULT_DEBUG_CONFIG)
|
|
|
|
async def _save_config_raw(self, data: dict[str, Any]) -> None:
|
|
result = await self.db.execute(
|
|
select(AgentConfig).where(AgentConfig.agent_name == "system")
|
|
)
|
|
cfg = result.scalar_one_or_none()
|
|
full = {}
|
|
if cfg and cfg.config:
|
|
try:
|
|
full = json.loads(cfg.config)
|
|
except (json.JSONDecodeError, TypeError):
|
|
full = {}
|
|
full[_DEBUG_KEY] = data
|
|
if not cfg:
|
|
cfg = AgentConfig(
|
|
agent_name="system",
|
|
description="System-wide configuration",
|
|
is_enabled=True,
|
|
version="1.0.0",
|
|
config=json.dumps(full, ensure_ascii=False),
|
|
)
|
|
self.db.add(cfg)
|
|
else:
|
|
cfg.config = json.dumps(full, ensure_ascii=False)
|
|
await self.db.commit()
|
|
|
|
async def get_config(self) -> dict[str, Any]:
|
|
return await self._get_config_raw()
|
|
|
|
async def update_config(self, updates: dict[str, Any]) -> dict[str, Any]:
|
|
current = await self._get_config_raw()
|
|
current.update(updates)
|
|
await self._save_config_raw(current)
|
|
return current
|
|
|
|
async def is_debug_mode(self) -> bool:
|
|
cfg = await self._get_config_raw()
|
|
if not cfg.get("debug_mode"):
|
|
return False
|
|
expires_at = cfg.get("debug_mode_expires_at")
|
|
if expires_at:
|
|
try:
|
|
exp = datetime.fromisoformat(expires_at)
|
|
if exp < datetime.now(timezone.utc):
|
|
return False
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return True
|
|
|
|
async def enable_debug_mode(self, ttl_hours: int = 48) -> dict[str, Any]:
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(hours=ttl_hours)).isoformat()
|
|
return await self.update_config({
|
|
"debug_mode": True,
|
|
"debug_mode_expires_at": expires_at,
|
|
})
|
|
|
|
async def disable_debug_mode(self) -> dict[str, Any]:
|
|
return await self.update_config({
|
|
"debug_mode": False,
|
|
"debug_mode_expires_at": None,
|
|
})
|
|
|
|
async def get_logs_size(self) -> int:
|
|
result = await self.db.execute(
|
|
select(func.sum(func.length(LogEntry.message)).cast(int))
|
|
)
|
|
size = result.scalar()
|
|
return size or 0
|
|
|
|
async def get_logs_count(self) -> int:
|
|
result = await self.db.execute(select(func.count(LogEntry.id)))
|
|
return result.scalar() or 0
|
|
|
|
async def get_status(self) -> dict[str, Any]:
|
|
cfg = await self._get_config_raw()
|
|
size = await self.get_logs_size()
|
|
count = await self.get_logs_count()
|
|
max_bytes = cfg.get("log_max_bytes", DEFAULT_DEBUG_CONFIG["log_max_bytes"])
|
|
percent = round((size / max_bytes) * 100, 1) if max_bytes > 0 else 0
|
|
debug_active = await self.is_debug_mode()
|
|
return {
|
|
"debug_mode": debug_active,
|
|
"debug_config": cfg,
|
|
"logs_size_bytes": size,
|
|
"logs_count": count,
|
|
"logs_max_bytes": max_bytes,
|
|
"usage_percent": min(percent, 100),
|
|
"needs_cleanup": percent >= cfg.get("notify_at_percent", 80),
|
|
}
|
|
|
|
async def cleanup_logs(self, force: bool = False) -> dict[str, Any]:
|
|
cfg = await self._get_config_raw()
|
|
retention_days = cfg.get("log_retention_days", 14)
|
|
min_retention = max(retention_days, 7)
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=min_retention)
|
|
|
|
if force:
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=min_retention)
|
|
|
|
stmt = select(func.count(LogEntry.id)).where(LogEntry.created_at < cutoff)
|
|
count_result = await self.db.execute(stmt)
|
|
to_delete = count_result.scalar() or 0
|
|
|
|
del_stmt = type(LogEntry).__table__.delete().where(LogEntry.created_at < cutoff)
|
|
await self.db.execute(del_stmt)
|
|
await self.db.commit()
|
|
|
|
return {
|
|
"deleted": to_delete,
|
|
"retention_days": min_retention,
|
|
"cutoff": cutoff.isoformat(),
|
|
}
|
|
|
|
async def auto_cleanup_if_needed(self) -> dict[str, Any] | None:
|
|
status = await self.get_status()
|
|
if status["needs_cleanup"]:
|
|
result = await self.cleanup_logs()
|
|
logger.info("Auto-cleanup performed: %d logs deleted", result["deleted"])
|
|
return result
|
|
return None
|
|
|
|
@staticmethod
|
|
async def check_last_deploy():
|
|
"""Enable debug mode for 48h if this is a fresh deploy."""
|
|
deploy_stamp_file = "/opt/voidea/.last_deploy"
|
|
if not os.path.exists(deploy_stamp_file):
|
|
return False
|
|
try:
|
|
with open(deploy_stamp_file) as f:
|
|
timestamp = int(f.read().strip())
|
|
elapsed = datetime.now().timestamp() - timestamp
|
|
if elapsed < 48 * 3600:
|
|
return True
|
|
except (ValueError, OSError):
|
|
pass
|
|
return False |