|
@@ -4,12 +4,19 @@ from datetime import date, datetime, time, timedelta, timezone
|
|
|
from sqlalchemy import and_, func, select
|
|
from sqlalchemy import and_, func, select
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
-from backend.app.models.archive import PrintArchive
|
|
|
|
|
|
|
+from backend.app.models.print_log import PrintLogEntry
|
|
|
from backend.app.models.printer import Printer
|
|
from backend.app.models.printer import Printer
|
|
|
|
|
|
|
|
|
|
|
|
|
class FailureAnalysisService:
|
|
class FailureAnalysisService:
|
|
|
- """Service for analyzing print failure patterns."""
|
|
|
|
|
|
|
+ """Service for analyzing print failure patterns.
|
|
|
|
|
+
|
|
|
|
|
+ Reads from print_log_entries (per-event data) rather than print_archives
|
|
|
|
|
+ so reprints contribute each run and orphan events (archive deleted, log
|
|
|
|
|
+ row survived via ON DELETE SET NULL) still count consistently with
|
|
|
|
|
+ Quick Stats. The archive-based predecessor diverged from Quick Stats
|
|
|
|
|
+ after #1378 moved the rest of the page to per-event aggregation.
|
|
|
|
|
+ """
|
|
|
|
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
def __init__(self, db: AsyncSession):
|
|
|
self.db = db
|
|
self.db = db
|
|
@@ -23,54 +30,54 @@ class FailureAnalysisService:
|
|
|
project_id: int | None = None,
|
|
project_id: int | None = None,
|
|
|
created_by_id: int | None = None,
|
|
created_by_id: int | None = None,
|
|
|
) -> dict:
|
|
) -> dict:
|
|
|
- """Analyze failure patterns across archives.
|
|
|
|
|
-
|
|
|
|
|
- Args:
|
|
|
|
|
- days: Number of days to analyze (fallback when no date range)
|
|
|
|
|
- date_from: Start date filter (inclusive)
|
|
|
|
|
- date_to: End date filter (inclusive)
|
|
|
|
|
- printer_id: Optional filter by printer
|
|
|
|
|
- project_id: Optional filter by project
|
|
|
|
|
-
|
|
|
|
|
- Returns:
|
|
|
|
|
- Dictionary with failure analysis results
|
|
|
|
|
- """
|
|
|
|
|
|
|
+ """Analyze failure patterns across logged print events."""
|
|
|
# Build base query — separate date vs non-date filters for trend reuse
|
|
# Build base query — separate date vs non-date filters for trend reuse
|
|
|
base_filter = []
|
|
base_filter = []
|
|
|
non_date_filter = []
|
|
non_date_filter = []
|
|
|
if date_from or date_to:
|
|
if date_from or date_to:
|
|
|
if date_from:
|
|
if date_from:
|
|
|
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
|
dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
|
|
|
- base_filter.append(PrintArchive.created_at >= dt_from)
|
|
|
|
|
|
|
+ base_filter.append(PrintLogEntry.created_at >= dt_from)
|
|
|
if date_to:
|
|
if date_to:
|
|
|
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
|
|
dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
|
|
|
- base_filter.append(PrintArchive.created_at <= dt_to)
|
|
|
|
|
- # Compute effective span for trend
|
|
|
|
|
|
|
+ base_filter.append(PrintLogEntry.created_at <= dt_to)
|
|
|
range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
|
|
range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
|
|
|
range_end = dt_to if date_to else datetime.now(timezone.utc)
|
|
range_end = dt_to if date_to else datetime.now(timezone.utc)
|
|
|
effective_days = max((range_end - range_start).days, 1)
|
|
effective_days = max((range_end - range_start).days, 1)
|
|
|
else:
|
|
else:
|
|
|
effective_days = days if days is not None else 30
|
|
effective_days = days if days is not None else 30
|
|
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
|
|
cutoff_date = datetime.now(timezone.utc) - timedelta(days=effective_days)
|
|
|
- base_filter.append(PrintArchive.created_at >= cutoff_date)
|
|
|
|
|
|
|
+ base_filter.append(PrintLogEntry.created_at >= cutoff_date)
|
|
|
if printer_id:
|
|
if printer_id:
|
|
|
- non_date_filter.append(PrintArchive.printer_id == printer_id)
|
|
|
|
|
|
|
+ non_date_filter.append(PrintLogEntry.printer_id == printer_id)
|
|
|
|
|
+ # project_id is an archive-level concept; PrintLogEntry has no project
|
|
|
|
|
+ # link, so we resolve it by archive_id where present.
|
|
|
if project_id:
|
|
if project_id:
|
|
|
- non_date_filter.append(PrintArchive.project_id == project_id)
|
|
|
|
|
|
|
+ from backend.app.models.archive import PrintArchive
|
|
|
|
|
+
|
|
|
|
|
+ project_archive_ids = await self.db.execute(
|
|
|
|
|
+ select(PrintArchive.id).where(PrintArchive.project_id == project_id)
|
|
|
|
|
+ )
|
|
|
|
|
+ archive_ids = [row[0] for row in project_archive_ids.fetchall()]
|
|
|
|
|
+ if archive_ids:
|
|
|
|
|
+ non_date_filter.append(PrintLogEntry.archive_id.in_(archive_ids))
|
|
|
|
|
+ else:
|
|
|
|
|
+ # No archives in this project → nothing to count
|
|
|
|
|
+ non_date_filter.append(PrintLogEntry.id.is_(None))
|
|
|
if created_by_id is not None:
|
|
if created_by_id is not None:
|
|
|
if created_by_id == -1:
|
|
if created_by_id == -1:
|
|
|
- non_date_filter.append(PrintArchive.created_by_id.is_(None))
|
|
|
|
|
|
|
+ non_date_filter.append(PrintLogEntry.created_by_id.is_(None))
|
|
|
else:
|
|
else:
|
|
|
- non_date_filter.append(PrintArchive.created_by_id == created_by_id)
|
|
|
|
|
|
|
+ non_date_filter.append(PrintLogEntry.created_by_id == created_by_id)
|
|
|
base_filter.extend(non_date_filter)
|
|
base_filter.extend(non_date_filter)
|
|
|
|
|
|
|
|
# Total counts
|
|
# Total counts
|
|
|
- total_result = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*base_filter)))
|
|
|
|
|
|
|
+ total_result = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*base_filter)))
|
|
|
total_prints = total_result.scalar() or 0
|
|
total_prints = total_result.scalar() or 0
|
|
|
|
|
|
|
|
failed_result = await self.db.execute(
|
|
failed_result = await self.db.execute(
|
|
|
- select(func.count(PrintArchive.id)).where(
|
|
|
|
|
- and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]))
|
|
|
|
|
|
|
+ select(func.count(PrintLogEntry.id)).where(
|
|
|
|
|
+ and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
failed_prints = failed_result.scalar() or 0
|
|
failed_prints = failed_result.scalar() or 0
|
|
@@ -80,38 +87,42 @@ class FailureAnalysisService:
|
|
|
# Failures by reason
|
|
# Failures by reason
|
|
|
reason_result = await self.db.execute(
|
|
reason_result = await self.db.execute(
|
|
|
select(
|
|
select(
|
|
|
- PrintArchive.failure_reason,
|
|
|
|
|
- func.count(PrintArchive.id).label("count"),
|
|
|
|
|
|
|
+ PrintLogEntry.failure_reason,
|
|
|
|
|
+ func.count(PrintLogEntry.id).label("count"),
|
|
|
)
|
|
)
|
|
|
- .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
|
|
|
|
|
- .group_by(PrintArchive.failure_reason)
|
|
|
|
|
- .order_by(func.count(PrintArchive.id).desc())
|
|
|
|
|
|
|
+ .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
|
|
|
|
|
+ .group_by(PrintLogEntry.failure_reason)
|
|
|
|
|
+ .order_by(func.count(PrintLogEntry.id).desc())
|
|
|
)
|
|
)
|
|
|
failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
|
|
failures_by_reason = {(row[0] or "Unknown"): row[1] for row in reason_result.fetchall()}
|
|
|
|
|
|
|
|
# Failures by filament type
|
|
# Failures by filament type
|
|
|
filament_result = await self.db.execute(
|
|
filament_result = await self.db.execute(
|
|
|
select(
|
|
select(
|
|
|
- PrintArchive.filament_type,
|
|
|
|
|
- func.count(PrintArchive.id).label("count"),
|
|
|
|
|
|
|
+ PrintLogEntry.filament_type,
|
|
|
|
|
+ func.count(PrintLogEntry.id).label("count"),
|
|
|
)
|
|
)
|
|
|
- .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
|
|
|
|
|
- .group_by(PrintArchive.filament_type)
|
|
|
|
|
- .order_by(func.count(PrintArchive.id).desc())
|
|
|
|
|
|
|
+ .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
|
|
|
|
|
+ .group_by(PrintLogEntry.filament_type)
|
|
|
|
|
+ .order_by(func.count(PrintLogEntry.id).desc())
|
|
|
)
|
|
)
|
|
|
failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
|
|
failures_by_filament = {(row[0] or "Unknown"): row[1] for row in filament_result.fetchall()}
|
|
|
|
|
|
|
|
# Failures by printer
|
|
# Failures by printer
|
|
|
printer_result = await self.db.execute(
|
|
printer_result = await self.db.execute(
|
|
|
select(
|
|
select(
|
|
|
- PrintArchive.printer_id,
|
|
|
|
|
- func.count(PrintArchive.id).label("count"),
|
|
|
|
|
|
|
+ PrintLogEntry.printer_id,
|
|
|
|
|
+ func.count(PrintLogEntry.id).label("count"),
|
|
|
)
|
|
)
|
|
|
.where(
|
|
.where(
|
|
|
- and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"]), PrintArchive.printer_id.isnot(None))
|
|
|
|
|
|
|
+ and_(
|
|
|
|
|
+ *base_filter,
|
|
|
|
|
+ PrintLogEntry.status.in_(["failed", "aborted"]),
|
|
|
|
|
+ PrintLogEntry.printer_id.isnot(None),
|
|
|
|
|
+ )
|
|
|
)
|
|
)
|
|
|
- .group_by(PrintArchive.printer_id)
|
|
|
|
|
- .order_by(func.count(PrintArchive.id).desc())
|
|
|
|
|
|
|
+ .group_by(PrintLogEntry.printer_id)
|
|
|
|
|
+ .order_by(func.count(PrintLogEntry.id).desc())
|
|
|
)
|
|
)
|
|
|
failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
|
|
failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
|
|
|
|
|
|
|
@@ -128,40 +139,39 @@ class FailureAnalysisService:
|
|
|
failures_by_printer = {}
|
|
failures_by_printer = {}
|
|
|
|
|
|
|
|
# Failures by hour of day
|
|
# Failures by hour of day
|
|
|
- failed_archives_result = await self.db.execute(
|
|
|
|
|
- select(PrintArchive.started_at).where(
|
|
|
|
|
|
|
+ failed_events_result = await self.db.execute(
|
|
|
|
|
+ select(PrintLogEntry.started_at).where(
|
|
|
and_(
|
|
and_(
|
|
|
*base_filter,
|
|
*base_filter,
|
|
|
- PrintArchive.status.in_(["failed", "aborted"]),
|
|
|
|
|
- PrintArchive.started_at.isnot(None),
|
|
|
|
|
|
|
+ PrintLogEntry.status.in_(["failed", "aborted"]),
|
|
|
|
|
+ PrintLogEntry.started_at.isnot(None),
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
failures_by_hour = defaultdict(int)
|
|
failures_by_hour = defaultdict(int)
|
|
|
- for (started_at,) in failed_archives_result.fetchall():
|
|
|
|
|
|
|
+ for (started_at,) in failed_events_result.fetchall():
|
|
|
if started_at:
|
|
if started_at:
|
|
|
hour = started_at.hour
|
|
hour = started_at.hour
|
|
|
failures_by_hour[hour] += 1
|
|
failures_by_hour[hour] += 1
|
|
|
- # Convert to dict with all 24 hours
|
|
|
|
|
failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
|
|
failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
|
|
|
|
|
|
|
|
# Recent failures
|
|
# Recent failures
|
|
|
recent_result = await self.db.execute(
|
|
recent_result = await self.db.execute(
|
|
|
- select(PrintArchive)
|
|
|
|
|
- .where(and_(*base_filter, PrintArchive.status.in_(["failed", "aborted"])))
|
|
|
|
|
- .order_by(PrintArchive.created_at.desc())
|
|
|
|
|
|
|
+ select(PrintLogEntry)
|
|
|
|
|
+ .where(and_(*base_filter, PrintLogEntry.status.in_(["failed", "aborted"])))
|
|
|
|
|
+ .order_by(PrintLogEntry.created_at.desc())
|
|
|
.limit(10)
|
|
.limit(10)
|
|
|
)
|
|
)
|
|
|
recent_failures = [
|
|
recent_failures = [
|
|
|
{
|
|
{
|
|
|
- "id": a.id,
|
|
|
|
|
- "print_name": a.print_name or a.filename,
|
|
|
|
|
- "failure_reason": a.failure_reason,
|
|
|
|
|
- "filament_type": a.filament_type,
|
|
|
|
|
- "printer_id": a.printer_id,
|
|
|
|
|
- "created_at": a.created_at.isoformat() if a.created_at else None,
|
|
|
|
|
|
|
+ "id": e.archive_id,
|
|
|
|
|
+ "print_name": e.print_name,
|
|
|
|
|
+ "failure_reason": e.failure_reason,
|
|
|
|
|
+ "filament_type": e.filament_type,
|
|
|
|
|
+ "printer_id": e.printer_id,
|
|
|
|
|
+ "created_at": e.created_at.isoformat() if e.created_at else None,
|
|
|
}
|
|
}
|
|
|
- for a in recent_result.scalars().all()
|
|
|
|
|
|
|
+ for e in recent_result.scalars().all()
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
# Failure rate trend (by week)
|
|
# Failure rate trend (by week)
|
|
@@ -172,15 +182,15 @@ class FailureAnalysisService:
|
|
|
week_start = week_end - timedelta(weeks=1)
|
|
week_start = week_end - timedelta(weeks=1)
|
|
|
|
|
|
|
|
week_filter = [
|
|
week_filter = [
|
|
|
- PrintArchive.created_at >= week_start,
|
|
|
|
|
- PrintArchive.created_at < week_end,
|
|
|
|
|
|
|
+ PrintLogEntry.created_at >= week_start,
|
|
|
|
|
+ PrintLogEntry.created_at < week_end,
|
|
|
*non_date_filter,
|
|
*non_date_filter,
|
|
|
]
|
|
]
|
|
|
|
|
|
|
|
- week_total = await self.db.execute(select(func.count(PrintArchive.id)).where(and_(*week_filter)))
|
|
|
|
|
|
|
+ week_total = await self.db.execute(select(func.count(PrintLogEntry.id)).where(and_(*week_filter)))
|
|
|
week_failed = await self.db.execute(
|
|
week_failed = await self.db.execute(
|
|
|
- select(func.count(PrintArchive.id)).where(
|
|
|
|
|
- and_(*week_filter, PrintArchive.status.in_(["failed", "aborted"]))
|
|
|
|
|
|
|
+ select(func.count(PrintLogEntry.id)).where(
|
|
|
|
|
+ and_(*week_filter, PrintLogEntry.status.in_(["failed", "aborted"]))
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
|