diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 485f097d5f..e6b8421539 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -45,7 +45,10 @@ class AutomationRun(Base): error = Column(Text, nullable=True) created_at = Column(BigInteger, nullable=False) - __table_args__ = (Index('ix_automation_run_automation_id', 'automation_id'),) + __table_args__ = ( + Index('ix_automation_run_automation_id', 'automation_id'), + Index('ix_automation_run_aid_created', 'automation_id', 'created_at'), + ) #################### @@ -308,6 +311,37 @@ class AutomationRunTable: ) return AutomationRunModel.model_validate(row) if row else None + def get_latest_batch( + self, automation_ids: list[str], db: Optional[Session] = None + ) -> dict[str, AutomationRunModel]: + """Fetch the latest run for each automation in a single query.""" + if not automation_ids: + return {} + with get_db_context(db) as db: + # Subquery: max created_at per automation_id + subq = ( + db.query( + AutomationRun.automation_id, + func.max(AutomationRun.created_at).label('max_created'), + ) + .filter(AutomationRun.automation_id.in_(automation_ids)) + .group_by(AutomationRun.automation_id) + .subquery() + ) + rows = ( + db.query(AutomationRun) + .join( + subq, + (AutomationRun.automation_id == subq.c.automation_id) + & (AutomationRun.created_at == subq.c.max_created), + ) + .all() + ) + return { + row.automation_id: AutomationRunModel.model_validate(row) + for row in rows + } + def get_by_automation( self, automation_id: str, diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index 803f59a6f2..f4608d0770 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -61,6 +61,7 @@ def check_automation_access(automation, user): def enrich_automation(automation: AutomationModel, db: Session, tz: str = None) -> AutomationResponse: + """Full enrichment for single-item views (includes next_runs computation).""" last_run = AutomationRuns.get_latest(automation.id, db=db) return AutomationResponse( **automation.model_dump(), @@ -97,8 +98,18 @@ async def get_automation_items( db=db, ) + # Batch-fetch latest runs in a single query instead of N+1 + ids = [item.id for item in result.items] + latest_runs = AutomationRuns.get_latest_batch(ids, db=db) if ids else {} + return { - 'items': [enrich_automation(item, db, tz=user.timezone) for item in result.items], + 'items': [ + AutomationResponse( + **item.model_dump(), + last_run=latest_runs.get(item.id), + ) + for item in result.items + ], 'total': result.total, } diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte index 7919ec43ee..e48dd3be37 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -47,8 +47,8 @@ let page = 1; - // Debounce only query changes - $: if (query !== undefined) { + // Debounce only query changes (gate behind loaded to prevent double-fetch on mount) + $: if (loaded && query !== undefined) { loading = true; clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { @@ -57,8 +57,8 @@ }, 300); } - // Immediate response to page/filter changes - $: if (page && statusFilter !== undefined) { + // Immediate response to page/filter changes (gate behind loaded) + $: if (loaded && page && statusFilter !== undefined) { getAutomationList(); } @@ -171,6 +171,8 @@ } loaded = true; + // Explicit initial fetch — reactive blocks will handle subsequent changes + await getAutomationList(); return () => { clearTimeout(searchDebounceTimer);