fix: make automation Enable/Disable All cover every page, not just the loaded one

The bulk actions iterated over the `automations` array, which only ever holds
the 30 items of the current page, so "Disable All" left every automation on
subsequent pages untouched.

Add a POST /automations/toggle/all endpoint that applies is_active server-side
to the whole filtered result set (same query/status/folder filters as /list),
re-arming next_run_at on enable and clearing it on disable. The frontend now
calls it once and refetches instead of firing one toggle request per visible
row.
This commit is contained in:
Classic298 2026-08-10 09:05:35 +00:00
parent 38d013d2e9
commit d54b32e27c
No known key found for this signature in database
4 changed files with 183 additions and 34 deletions

View file

@ -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)

View file

@ -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
############################

View file

@ -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;

View file

@ -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 @@
<div slot="content">
<DropdownMenu className="w-[10.625rem] shadow-sm">
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem]"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
disabled={bulkToggling}
on:click={() => bulkToggleHandler(true)}
>
<CheckCircle className="size-3.5" />
{$i18n.t('Enable All')}
</button>
<button
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem]"
class="select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] disabled:cursor-not-allowed disabled:opacity-50"
type="button"
disabled={bulkToggling}
on:click={() => bulkToggleHandler(false)}
>
<Minus className="size-3.5" />