mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-11 08:57:17 +00:00
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:
parent
38d013d2e9
commit
d54b32e27c
4 changed files with 183 additions and 34 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
############################
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue