diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py index 2c94cba68b..7cf28bd9cb 100644 --- a/backend/open_webui/models/automations.py +++ b/backend/open_webui/models/automations.py @@ -108,6 +108,13 @@ class AutomationForm(BaseModel): is_active: Optional[bool] = True +class AutomationBulkToggleForm(BaseModel): + is_active: bool + query: Optional[str] = None + status: Optional[str] = None + folder_id: Optional[str] = None + + class AutomationResponse(AutomationModel): last_run: Optional[AutomationRunModel] = None next_runs: Optional[list[int]] = None @@ -167,6 +174,36 @@ class AutomationTable: ) return [AutomationModel.model_validate(r) for r in result.scalars().all()] + def _search_stmt( + self, + user_id: str, + query: Optional[str] = None, + status: Optional[str] = None, + folder_id: Optional[str] = None, + ): + """Build the filter clause shared by list and bulk operations.""" + stmt = select(Automation).filter_by(user_id=user_id) + + if folder_id: + stmt = stmt.filter(Automation.folder_id == folder_id) + + if query: + search = f'%{query}%' + # Search in name and prompt inside JSON data + stmt = stmt.filter( + or_( + Automation.name.ilike(search), + cast(Automation.data, String).ilike(search), + ) + ) + + if status == 'active': + stmt = stmt.filter(Automation.is_active == True) + elif status == 'paused': + stmt = stmt.filter(Automation.is_active == False) + + return stmt + async def search_automations( self, user_id: str, @@ -178,27 +215,7 @@ class AutomationTable: db: Optional[AsyncSession] = None, ) -> 'AutomationListResponse': async with get_async_db_context(db) as db: - stmt = select(Automation).filter_by(user_id=user_id) - - if folder_id: - stmt = stmt.filter(Automation.folder_id == folder_id) - - if query: - search = f'%{query}%' - # Search in name and prompt inside JSON data - stmt = stmt.filter( - or_( - Automation.name.ilike(search), - cast(Automation.data, String).ilike(search), - ) - ) - - if status == 'active': - stmt = stmt.filter(Automation.is_active == True) - elif status == 'paused': - stmt = stmt.filter(Automation.is_active == False) - - stmt = stmt.order_by(Automation.created_at.desc()) + stmt = self._search_stmt(user_id, query, status, folder_id).order_by(Automation.created_at.desc()) # Get total count count_result = await db.execute(select(func.count()).select_from(stmt.subquery())) @@ -272,6 +289,46 @@ class AutomationTable: await db.commit() return AutomationModel.model_validate(row) + async def set_active_by_search( + self, + user_id: str, + is_active: bool, + query: Optional[str] = None, + status: Optional[str] = None, + folder_id: Optional[str] = None, + tz: Optional[str] = None, + db: Optional[AsyncSession] = None, + ) -> int: + """Set is_active on every automation matching the search filters. + + Uses the same filters as search_automations so bulk actions cover the + whole result set rather than a single page of it. Returns the number of + automations actually changed. + """ + async with get_async_db_context(db) as db: + stmt = self._search_stmt(user_id, query, status, folder_id).filter(Automation.is_active != is_active) + result = await db.execute(stmt) + rows = result.scalars().all() + if not rows: + return 0 + + from open_webui.utils.automations import next_run_ns + + now = int(time.time_ns()) + for row in rows: + row.is_active = is_active + # Re-arm the schedule on enable; a paused automation has no next run. + try: + row.next_run_at = await next_run_ns(row.data.get('rrule', ''), tz=tz) if is_active else None + except ValueError as e: + # Look again in five minutes rather than strand it active but unscheduled + log.warning('Deferring automation %s: %s', row.id, e) + row.next_run_at = now + 300 * 1_000_000_000 + row.updated_at = now + + await db.commit() + return len(rows) + async def delete(self, id: str, db: Optional[AsyncSession] = None) -> bool: async with get_async_db_context(db) as db: row = await db.get(Automation, id) diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py index c8021c9d70..6a851a09b6 100644 --- a/backend/open_webui/routers/automations.py +++ b/backend/open_webui/routers/automations.py @@ -7,6 +7,7 @@ from open_webui.constants import ERROR_MESSAGES from open_webui.events import EVENTS, publish_event from open_webui.internal.db import get_async_session from open_webui.models.automations import ( + AutomationBulkToggleForm, AutomationForm, AutomationListResponse, AutomationModel, @@ -204,6 +205,47 @@ async def create_new_automation( return response +############################ +# ToggleAllAutomations +############################ + + +@router.post('/toggle/all') +async def toggle_all_automations( + request: Request, + form_data: AutomationBulkToggleForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + """Enable/disable every automation matching the given filters. + + The filters mirror /list, so the action covers the entire filtered set + instead of only the page the client currently has loaded. + """ + await check_automations_permission(request, user) + + count = await Automations.set_active_by_search( + user_id=user.id, + is_active=form_data.is_active, + query=form_data.query, + status=form_data.status, + folder_id=form_data.folder_id, + tz=user.timezone, + db=db, + ) + + if count: + await publish_event( + request, + EVENTS.AUTOMATION_ENABLED if form_data.is_active else EVENTS.AUTOMATION_DISABLED, + actor=user, + subject_type='automation', + data={'count': count}, + ) + + return {'count': count} + + ############################ # GetAutomationById ############################ diff --git a/src/lib/apis/automations/index.ts b/src/lib/apis/automations/index.ts index 593a56cab1..65e967195e 100644 --- a/src/lib/apis/automations/index.ts +++ b/src/lib/apis/automations/index.ts @@ -213,6 +213,46 @@ export const toggleAutomationById = async (token: string, id: string) => { return res; }; +export const toggleAllAutomations = async ( + token: string, + is_active: boolean, + query: string | null = null, + status: string | null = null, + folder_id: string | null = null +): Promise<{ count: number }> => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/automations/toggle/all`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + is_active, + query: query || null, + status: status && status !== 'all' ? status : null, + folder_id: folder_id ?? null + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const runAutomationById = async (token: string, id: string) => { let error = null; diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte index 74e31daac8..88338ee044 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -12,6 +12,7 @@ createAutomation, getAutomationItems, toggleAutomationById, + toggleAllAutomations, runAutomationById, deleteAutomationById, type AutomationForm, @@ -51,6 +52,7 @@ let automations: AutomationResponse[] | null = null; let total: number | null = null; let loading = false; + let bulkToggling = false; let showCreateModal = false; let cloneFrom: AutomationResponse | null = null; @@ -162,21 +164,27 @@ }; const bulkToggleHandler = async (enable: boolean) => { - const targets = (automations ?? []).filter((a) => a.is_active !== enable); - if (targets.length === 0) return; + if (bulkToggling) return; + bulkToggling = true; - // Optimistic UI update via map for proper Svelte reactivity - automations = (automations ?? []).map((a) => - targets.some((t) => t.id === a.id) ? { ...a, is_active: enable } : a + // Applies server-side to every automation matching the current search and + // status filter, not just the page that happens to be rendered. + const res = await toggleAllAutomations(localStorage.token, enable, query, statusFilter).catch( + (err) => { + toast.error(`${err}`); + return null; + } ); - try { - await Promise.all(targets.map((a) => toggleAutomationById(localStorage.token, a.id))); - } catch (err) { - toast.error(`${err}`); - // Refresh from server to restore consistent state - await getAutomationList(); + bulkToggling = false; + if (!res) return; + + // The filtered set can shrink (e.g. disabling while filtering by Active), + // which would leave the current page out of range. + if (statusFilter !== 'all') { + page = 1; } + await getAutomationList(); }; const runNowHandler = async (automation: AutomationResponse) => { @@ -494,16 +502,18 @@