This commit is contained in:
Timothy Jaeryang Baek
2026-04-01 00:55:52 -05:00
parent c6b1c56e9e
commit fe8a3d9f83
4 changed files with 202 additions and 42 deletions

View File

@@ -4,7 +4,7 @@ from typing import Optional
from uuid import uuid4
from pydantic import BaseModel, ConfigDict
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select
from sqlalchemy import Column, Text, JSON, Boolean, BigInteger, Index, select, or_, func, cast, String
from sqlalchemy.orm import Session
from open_webui.internal.db import Base, get_db, get_db_context
@@ -108,6 +108,11 @@ class AutomationResponse(AutomationModel):
next_runs: Optional[list[int]] = None
class AutomationListResponse(BaseModel):
items: list[AutomationModel]
total: int
####################
# AutomationTable
####################
@@ -159,6 +164,48 @@ class AutomationTable:
)
return [AutomationModel.model_validate(r) for r in rows]
def search_automations(
self,
user_id: str,
query: Optional[str] = None,
status: Optional[str] = None,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> 'AutomationListResponse':
with get_db_context(db) as db:
q = db.query(Automation).filter_by(user_id=user_id)
if query:
search = f'%{query}%'
# Search in name and prompt inside JSON data
q = q.filter(
or_(
Automation.name.ilike(search),
cast(Automation.data, String).ilike(search),
)
)
if status == 'active':
q = q.filter(Automation.is_active == True)
elif status == 'paused':
q = q.filter(Automation.is_active == False)
q = q.order_by(Automation.created_at.desc())
total = q.count()
if skip:
q = q.offset(skip)
if limit:
q = q.limit(limit)
rows = q.all()
return AutomationListResponse(
items=[AutomationModel.model_validate(r) for r in rows],
total=total,
)
def get_all(self, db: Optional[Session] = None) -> list[AutomationModel]:
with get_db_context(db) as db:
rows = (

View File

@@ -1,6 +1,7 @@
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
@@ -11,6 +12,7 @@ from open_webui.models.automations import (
AutomationModel,
AutomationResponse,
AutomationRunModel,
AutomationListResponse,
)
from open_webui.utils.automations import (
validate_rrule,
@@ -26,6 +28,8 @@ log = logging.getLogger(__name__)
router = APIRouter()
PAGE_ITEM_COUNT = 30
############################
# Helpers
@@ -71,6 +75,42 @@ async def get_automations(
return [enrich_automation(automation, db, tz=user.timezone) for automation in automations]
############################
# GetAutomationItems (paginated)
############################
@router.get('/list')
async def get_automation_items(
request: Request,
query: Optional[str] = None,
status: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
limit = PAGE_ITEM_COUNT
page = max(1, page)
skip = (page - 1) * limit
result = Automations.search_automations(
user_id=user.id,
query=query,
status=status,
skip=skip,
limit=limit,
db=db,
)
return {
'items': [
enrich_automation(item, db, tz=user.timezone)
for item in result.items
],
'total': result.total,
}
############################
# CreateNewAutomation
############################

View File

@@ -77,6 +77,50 @@ export const getAutomations = async (token: string) => {
return res;
};
export const getAutomationItems = async (
token: string,
query: string | null,
status: string | null,
page: number
): Promise<{ items: AutomationResponse[]; total: number }> => {
let error = null;
const searchParams = new URLSearchParams();
if (query) {
searchParams.append('query', query);
}
if (status && status !== 'all') {
searchParams.append('status', status);
}
if (page) {
searchParams.append('page', page.toString());
}
const res = await fetch(`${WEBUI_API_BASE_URL}/automations/list?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.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 createAutomation = async (token: string, form: AutomationForm) => {
let error = null;

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount, getContext } from 'svelte';
import { onMount, onDestroy, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import { WEBUI_NAME, mobile, showSidebar } from '$lib/stores';
import {
getAutomations,
getAutomationItems,
toggleAutomationById,
runAutomationById,
deleteAutomationById,
@@ -17,6 +17,7 @@
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Pagination from '$lib/components/common/Pagination.svelte';
import Plus from '$lib/components/icons/Plus.svelte';
import Switch from '$lib/components/common/Switch.svelte';
import SidebarIcon from '$lib/components/icons/Sidebar.svelte';
@@ -30,7 +31,9 @@
const i18n = getContext('i18n');
let loaded = false;
let automations: AutomationResponse[] = [];
let automations: AutomationResponse[] | null = null;
let total: number | null = null;
let loading = false;
let showEditor = false;
let editingAutomation: AutomationResponse | null = null;
@@ -40,31 +43,48 @@
let query = '';
let statusFilter = 'all';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
const getFilteredAutomations = (list, q, status) => {
let filtered = list;
if (status === 'active') {
filtered = filtered.filter((a) => a.is_active);
} else if (status === 'paused') {
filtered = filtered.filter((a) => !a.is_active);
}
if (q) {
const lower = q.toLowerCase();
filtered = filtered.filter((a) =>
a.name.toLowerCase().includes(lower) ||
a.data.prompt.toLowerCase().includes(lower)
);
}
return filtered;
};
let page = 1;
// Debounce only query changes
$: if (query !== undefined) {
loading = true;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
page = 1;
getAutomationList();
}, 300);
}
// Immediate response to page/filter changes
$: if (page && statusFilter !== undefined) {
getAutomationList();
}
const getAutomationList = async () => {
const res = await getAutomations(localStorage.token).catch((err) => {
toast.error(`${err}`);
return null;
});
if (res) {
automations = res;
if (!loaded) return;
loading = true;
try {
const res = await getAutomationItems(
localStorage.token,
query,
statusFilter,
page
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
automations = res.items;
total = res.total;
}
} catch (err) {
console.error(err);
} finally {
loading = false;
}
};
@@ -74,7 +94,7 @@
return null;
});
if (res) {
automations = automations.map((a) => (a.id === res.id ? res : a));
automations = (automations ?? []).map((a) => (a.id === res.id ? res : a));
}
};
@@ -95,8 +115,10 @@
});
if (res) {
toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name }));
automations = automations.filter((a) => a.id !== automation.id);
}
page = 1;
getAutomationList();
};
const formatRRule = (rrule: string): string => {
@@ -144,19 +166,16 @@
return 'th';
};
const relativeTime = (ns: number): string => {
const diff = Date.now() - ns / 1_000_000;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
};
onMount(async () => {
await getAutomationList();
loaded = true;
return () => {
clearTimeout(searchDebounceTimer);
};
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
@@ -215,7 +234,7 @@
{/if}
<div>{$i18n.t('Automations')}</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{automations.length}
{total ?? ''}
</div>
</div>
@@ -296,7 +315,11 @@
</div>
</div>
{#if getFilteredAutomations(automations, query, statusFilter).length === 0}
{#if automations === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (automations ?? []).length === 0}
<div class="w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class="text-3xl mb-3"></div>
@@ -312,7 +335,7 @@
</div>
{:else}
<div class="gap-2 grid my-2 px-3">
{#each getFilteredAutomations(automations, query, statusFilter) as automation (automation.id)}
{#each automations as automation (automation.id)}
<div
class="flex space-x-4 text-left w-full px-3 py-2.5 dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl"
>
@@ -369,6 +392,12 @@
</div>
{/each}
</div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{/if}
</div>
</div>