mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-11 04:50:11 +02:00
refac
This commit is contained in:
@@ -9,12 +9,15 @@ from typing import Any
|
||||
|
||||
from open_webui.env import VERSION
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.retrieval.web.utils import validate_url
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
EVENT_VERSION = 'event.v1'
|
||||
MAX_STRING_LENGTH = 1000
|
||||
EVENT_WEBHOOKS_CONFIG_KEY = 'events.webhooks'
|
||||
LEGACY_WEBHOOK_CONFIG_KEY = 'webhook_url'
|
||||
DEFAULT_WEBHOOK_ID = 'default'
|
||||
|
||||
|
||||
class EVENTS(StrEnum):
|
||||
@@ -192,6 +195,7 @@ class EVENTS(StrEnum):
|
||||
|
||||
|
||||
EVENT_CATALOG = tuple(event.value for event in EVENTS)
|
||||
EVENT_CATALOG_SET = set(EVENT_CATALOG)
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
'password',
|
||||
@@ -211,6 +215,126 @@ SENSITIVE_KEYS = {
|
||||
SAFE_ACTOR_FIELDS = ('id', 'name', 'email', 'role', 'created_at', 'updated_at')
|
||||
|
||||
|
||||
def normalize_event_webhook(webhook: dict[str, Any], *, create: bool = False) -> dict[str, Any]:
|
||||
now = int(time.time())
|
||||
webhook_id = str(webhook.get('id') or uuid.uuid4())
|
||||
url = str(webhook.get('url') or '').strip()
|
||||
|
||||
events = [str(event).strip() for event in (webhook.get('events') or ['*']) if str(event).strip()]
|
||||
events = events or ['*']
|
||||
for event_filter in events:
|
||||
if event_filter == '*':
|
||||
continue
|
||||
if event_filter.endswith('.*'):
|
||||
prefix = event_filter[:-2]
|
||||
if prefix and any(event.startswith(f'{prefix}.') for event in EVENT_CATALOG):
|
||||
continue
|
||||
raise ValueError(f'Invalid event pattern: {event_filter}')
|
||||
if event_filter not in EVENT_CATALOG_SET:
|
||||
raise ValueError(f'Invalid event: {event_filter}')
|
||||
|
||||
return {
|
||||
'id': webhook_id,
|
||||
'name': str(webhook.get('name') or ('Default webhook' if webhook_id == DEFAULT_WEBHOOK_ID else 'Webhook')),
|
||||
'url': url,
|
||||
'enabled': bool(webhook.get('enabled', True)),
|
||||
'events': events,
|
||||
'created_at': int(webhook.get('created_at') or now),
|
||||
'updated_at': now if create or webhook.get('updated_at') is None else int(webhook.get('updated_at') or now),
|
||||
}
|
||||
|
||||
|
||||
def event_webhook_matches(webhook: dict[str, Any], event_name: str) -> bool:
|
||||
if not webhook.get('enabled', True):
|
||||
return False
|
||||
|
||||
for event_filter in webhook.get('events') or ['*']:
|
||||
if event_filter == '*':
|
||||
return True
|
||||
if event_filter.endswith('.*') and event_name.startswith(f'{event_filter[:-2]}.'):
|
||||
return True
|
||||
if event_name == event_filter:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def get_event_webhooks() -> list[dict[str, Any]]:
|
||||
webhooks = await Config.get(EVENT_WEBHOOKS_CONFIG_KEY, []) or []
|
||||
if not isinstance(webhooks, list):
|
||||
return []
|
||||
return [normalize_event_webhook(webhook) for webhook in webhooks if isinstance(webhook, dict)]
|
||||
|
||||
|
||||
async def migrate_legacy_webhook_config() -> list[dict[str, Any]]:
|
||||
webhooks = await get_event_webhooks()
|
||||
if any(webhook.get('id') == DEFAULT_WEBHOOK_ID for webhook in webhooks):
|
||||
return webhooks
|
||||
|
||||
now = int(time.time())
|
||||
legacy_url = await Config.get(LEGACY_WEBHOOK_CONFIG_KEY) or ''
|
||||
if not legacy_url:
|
||||
return webhooks
|
||||
|
||||
webhooks = [
|
||||
{
|
||||
'id': DEFAULT_WEBHOOK_ID,
|
||||
'name': 'Default webhook',
|
||||
'url': legacy_url,
|
||||
'enabled': True,
|
||||
'events': ['*'],
|
||||
'created_at': now,
|
||||
'updated_at': now,
|
||||
},
|
||||
*webhooks,
|
||||
]
|
||||
await Config.upsert({EVENT_WEBHOOKS_CONFIG_KEY: webhooks})
|
||||
return webhooks
|
||||
|
||||
|
||||
async def upsert_event_webhook(webhook: dict[str, Any]) -> dict[str, Any]:
|
||||
webhooks = await get_event_webhooks()
|
||||
url = str(webhook.get('url') or '').strip()
|
||||
if url:
|
||||
validate_url(url)
|
||||
|
||||
normalized = normalize_event_webhook(webhook, create=True)
|
||||
replaced = False
|
||||
next_webhooks = []
|
||||
|
||||
for existing in webhooks:
|
||||
if existing.get('id') == normalized['id']:
|
||||
next_webhooks.append(
|
||||
{
|
||||
**existing,
|
||||
**normalized,
|
||||
'created_at': existing.get('created_at') or normalized['created_at'],
|
||||
}
|
||||
)
|
||||
replaced = True
|
||||
else:
|
||||
next_webhooks.append(existing)
|
||||
|
||||
if not replaced:
|
||||
next_webhooks.append(normalized)
|
||||
|
||||
await Config.upsert({EVENT_WEBHOOKS_CONFIG_KEY: next_webhooks})
|
||||
return next(webhook for webhook in next_webhooks if webhook.get('id') == normalized['id'])
|
||||
|
||||
|
||||
async def delete_event_webhook(webhook_id: str) -> bool:
|
||||
webhooks = await get_event_webhooks()
|
||||
next_webhooks = [webhook for webhook in webhooks if webhook.get('id') != webhook_id]
|
||||
if len(next_webhooks) == len(webhooks):
|
||||
return False
|
||||
|
||||
values = {EVENT_WEBHOOKS_CONFIG_KEY: next_webhooks}
|
||||
if webhook_id == DEFAULT_WEBHOOK_ID:
|
||||
values[LEGACY_WEBHOOK_CONFIG_KEY] = ''
|
||||
|
||||
await Config.upsert(values)
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
schema: str
|
||||
@@ -295,7 +419,7 @@ def build_event(
|
||||
)
|
||||
|
||||
return Event(
|
||||
schema=EVENT_VERSION,
|
||||
schema=VERSION,
|
||||
id=str(uuid.uuid4()),
|
||||
event=event_name,
|
||||
resource=resource,
|
||||
@@ -313,10 +437,6 @@ def build_event(
|
||||
|
||||
class WebhookEventSink:
|
||||
async def handle_event(self, app: Any, event: Event) -> None:
|
||||
url = await Config.get('webhook_url')
|
||||
if not url:
|
||||
return
|
||||
|
||||
name = getattr(getattr(app, 'state', None), 'WEBUI_NAME', 'Open WebUI')
|
||||
subject = event.subject or {}
|
||||
subject_id = subject.get('id')
|
||||
@@ -324,7 +444,12 @@ class WebhookEventSink:
|
||||
if subject_id:
|
||||
message = f'{message} ({subject_id})'
|
||||
|
||||
await post_webhook(name, url, message, event.model_dump())
|
||||
for webhook in await get_event_webhooks():
|
||||
if webhook.get('url') and event_webhook_matches(webhook, event.event):
|
||||
try:
|
||||
await post_webhook(name, webhook['url'], message, event.model_dump())
|
||||
except Exception:
|
||||
log.exception('Event webhook failed for %s', webhook.get('id'))
|
||||
|
||||
|
||||
EVENT_SINKS = [WebhookEventSink()]
|
||||
|
||||
@@ -116,12 +116,20 @@ from open_webui.env import (
|
||||
WEBUI_SESSION_COOKIE_SAME_SITE,
|
||||
WEBUI_SESSION_COOKIE_SECURE,
|
||||
)
|
||||
from open_webui.events import (
|
||||
EVENT_CATALOG,
|
||||
EVENTS,
|
||||
delete_event_webhook,
|
||||
get_event_webhooks,
|
||||
migrate_legacy_webhook_config,
|
||||
publish_event,
|
||||
upsert_event_webhook,
|
||||
)
|
||||
from open_webui.internal.db import engine, get_async_session
|
||||
from open_webui.models.access_grants import AccessGrants
|
||||
from open_webui.models.channels import Channels
|
||||
from open_webui.models.chats import ChatForm, Chats
|
||||
from open_webui.models.config import Config
|
||||
from open_webui.events import EVENT_CATALOG, EVENT_VERSION, EVENTS, publish_event
|
||||
from open_webui.models.functions import Functions
|
||||
from open_webui.models.messages import Messages
|
||||
from open_webui.models.models import Models
|
||||
@@ -298,6 +306,7 @@ async def lifespan(app: FastAPI):
|
||||
await import_legacy_config_json()
|
||||
await seed_registered_defaults()
|
||||
await initialize_runtime_config(app)
|
||||
await migrate_legacy_webhook_config()
|
||||
await publish_event(app, EVENTS.SYSTEM_STARTUP_STARTED, source='system')
|
||||
|
||||
if LICENSE_KEY:
|
||||
@@ -2005,37 +2014,102 @@ async def get_app_config(request: Request):
|
||||
}
|
||||
|
||||
|
||||
class UrlForm(BaseModel):
|
||||
class EventWebhookForm(BaseModel):
|
||||
name: str | None = None
|
||||
url: str
|
||||
enabled: bool = True
|
||||
events: list[str] | None = None
|
||||
|
||||
|
||||
@app.get('/api/webhook')
|
||||
async def get_webhook_url(user=Depends(get_admin_user)):
|
||||
return {
|
||||
'url': await Config.get('webhook_url'),
|
||||
}
|
||||
class EventWebhookUpdateForm(BaseModel):
|
||||
name: str | None = None
|
||||
url: str | None = None
|
||||
enabled: bool | None = None
|
||||
events: list[str] | None = None
|
||||
|
||||
|
||||
@app.get('/api/events')
|
||||
async def get_event_catalog(user=Depends(get_admin_user)):
|
||||
return {
|
||||
'schema': EVENT_VERSION,
|
||||
'schema': VERSION,
|
||||
'events': list(EVENT_CATALOG),
|
||||
}
|
||||
|
||||
|
||||
@app.post('/api/webhook')
|
||||
async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)):
|
||||
await Config.upsert({'webhook_url': form_data.url})
|
||||
app.state.WEBHOOK_URL = form_data.url
|
||||
@app.get('/api/events/webhooks')
|
||||
async def get_event_webhooks_api(user=Depends(get_admin_user)):
|
||||
return await get_event_webhooks()
|
||||
|
||||
|
||||
@app.post('/api/events/webhooks')
|
||||
async def create_event_webhook(form_data: EventWebhookForm, user=Depends(get_admin_user)):
|
||||
try:
|
||||
webhook = await upsert_event_webhook(
|
||||
{
|
||||
'name': form_data.name,
|
||||
'url': form_data.url,
|
||||
'enabled': form_data.enabled,
|
||||
'events': form_data.events,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.CONFIG_WEBHOOK_UPDATED,
|
||||
actor=user,
|
||||
subject_id='webhook_url', subject_type='config',
|
||||
data={'enabled': bool(form_data.url)},
|
||||
subject_id=webhook['id'],
|
||||
subject_type='config',
|
||||
data={'action': 'created', 'enabled': webhook.get('enabled'), 'events': webhook.get('events')},
|
||||
)
|
||||
return {'url': form_data.url}
|
||||
return webhook
|
||||
|
||||
|
||||
@app.put('/api/events/webhooks/{webhook_id}')
|
||||
async def update_event_webhook(webhook_id: str, form_data: EventWebhookUpdateForm, user=Depends(get_admin_user)):
|
||||
webhooks = await get_event_webhooks()
|
||||
existing = next((webhook for webhook in webhooks if webhook.get('id') == webhook_id), None)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Webhook not found')
|
||||
|
||||
try:
|
||||
webhook = await upsert_event_webhook(
|
||||
{
|
||||
**existing,
|
||||
**form_data.model_dump(exclude_unset=True),
|
||||
'id': webhook_id,
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.CONFIG_WEBHOOK_UPDATED,
|
||||
actor=user,
|
||||
subject_id=webhook_id,
|
||||
subject_type='config',
|
||||
data={'action': 'updated', 'enabled': webhook.get('enabled'), 'events': webhook.get('events')},
|
||||
)
|
||||
return webhook
|
||||
|
||||
|
||||
@app.delete('/api/events/webhooks/{webhook_id}')
|
||||
async def delete_event_webhook_api(webhook_id: str, user=Depends(get_admin_user)):
|
||||
deleted = await delete_event_webhook(webhook_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Webhook not found')
|
||||
|
||||
await publish_event(
|
||||
app,
|
||||
EVENTS.CONFIG_WEBHOOK_UPDATED,
|
||||
actor=user,
|
||||
subject_id=webhook_id,
|
||||
subject_type='config',
|
||||
data={'action': 'deleted'},
|
||||
)
|
||||
return {'status': True}
|
||||
|
||||
|
||||
@app.get('/api/version')
|
||||
|
||||
@@ -1572,10 +1572,20 @@ export const getVersionUpdates = async (token: string) => {
|
||||
return res;
|
||||
};
|
||||
|
||||
export const getWebhookUrl = async (token: string) => {
|
||||
export type EventWebhook = {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
events: string[];
|
||||
created_at?: number;
|
||||
updated_at?: number;
|
||||
};
|
||||
|
||||
export const getEvents = async (token: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/webhook`, {
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/events`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -1596,21 +1606,18 @@ export const getWebhookUrl = async (token: string) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.url;
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateWebhookUrl = async (token: string, url: string) => {
|
||||
export const getEventWebhooks = async (token: string): Promise<EventWebhook[]> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/webhook`, {
|
||||
method: 'POST',
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url
|
||||
})
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
@@ -1626,7 +1633,97 @@ export const updateWebhookUrl = async (token: string, url: string) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res.url;
|
||||
return res;
|
||||
};
|
||||
|
||||
export const createEventWebhook = async (
|
||||
token: string,
|
||||
webhook: Partial<EventWebhook>
|
||||
): Promise<EventWebhook> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(webhook)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const updateEventWebhook = async (
|
||||
token: string,
|
||||
id: string,
|
||||
webhook: Partial<EventWebhook>
|
||||
): Promise<EventWebhook> => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(webhook)
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export const deleteEventWebhook = async (token: string, id: string) => {
|
||||
let error = null;
|
||||
|
||||
const res = await fetch(`${WEBUI_BASE_URL}/api/events/webhooks/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw await res.json();
|
||||
return res.json();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
error = err;
|
||||
return null;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
export interface ModelConfig {
|
||||
|
||||
478
src/lib/components/admin/Settings/Events.svelte
Normal file
478
src/lib/components/admin/Settings/Events.svelte
Normal file
@@ -0,0 +1,478 @@
|
||||
<script lang="ts">
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import type { i18n as i18nType } from 'i18next';
|
||||
|
||||
import {
|
||||
createEventWebhook,
|
||||
deleteEventWebhook,
|
||||
getEventWebhooks,
|
||||
getEvents,
|
||||
updateEventWebhook,
|
||||
type EventWebhook
|
||||
} from '$lib/apis';
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
const i18n = getContext<Writable<i18nType>>('i18n');
|
||||
|
||||
let webhooks: EventWebhook[] = [];
|
||||
let events: string[] = [];
|
||||
let pattern = '';
|
||||
let showWebhookModal = false;
|
||||
let showDeleteConfirmDialog = false;
|
||||
let editing: EventWebhook | null = null;
|
||||
let form = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
events: ['*'] as string[]
|
||||
};
|
||||
|
||||
$: eventFilter = pattern.trim().toLowerCase().replace(/\*$/, '');
|
||||
$: filteredEvents = events.filter((event) => !eventFilter || event.includes(eventFilter));
|
||||
$: allEvents = form.events.includes('*');
|
||||
$: selectedExactEvents = form.events.filter((event) => event !== '*' && !event.endsWith('.*'));
|
||||
|
||||
const sortWebhooks = (items: EventWebhook[]) =>
|
||||
[...items].sort((a, b) => {
|
||||
if (a.id === 'default') {
|
||||
return -1;
|
||||
}
|
||||
if (b.id === 'default') {
|
||||
return 1;
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
const [catalog, webhookList] = await Promise.all([
|
||||
getEvents(localStorage.token),
|
||||
getEventWebhooks(localStorage.token)
|
||||
]);
|
||||
events = (catalog?.events ?? []).sort();
|
||||
webhooks = sortWebhooks(webhookList ?? []);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
editing = null;
|
||||
showWebhookModal = false;
|
||||
form = {
|
||||
id: '',
|
||||
name: '',
|
||||
url: '',
|
||||
enabled: true,
|
||||
events: ['*']
|
||||
};
|
||||
pattern = '';
|
||||
};
|
||||
|
||||
const newWebhook = () => {
|
||||
resetForm();
|
||||
showWebhookModal = true;
|
||||
};
|
||||
|
||||
const editWebhook = (webhook: EventWebhook) => {
|
||||
editing = webhook;
|
||||
showWebhookModal = true;
|
||||
form = {
|
||||
id: webhook.id,
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
enabled: webhook.enabled,
|
||||
events: webhook.events?.length ? [...webhook.events] : ['*']
|
||||
};
|
||||
pattern = '';
|
||||
};
|
||||
|
||||
const eventSummary = (webhook: EventWebhook) => {
|
||||
const filters = webhook.events?.length ? webhook.events : ['*'];
|
||||
if (filters.includes('*')) {
|
||||
return $i18n.t('All events');
|
||||
}
|
||||
if (filters.length <= 2) {
|
||||
return filters.join(' + ');
|
||||
}
|
||||
return $i18n.t('{{count}} filters', { count: filters.length });
|
||||
};
|
||||
|
||||
const urlHost = (url: string) => {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return url || $i18n.t('Not configured');
|
||||
}
|
||||
};
|
||||
|
||||
const setAllEvents = (enabled: boolean) => {
|
||||
form.events = enabled ? ['*'] : [];
|
||||
};
|
||||
|
||||
const toggleEvent = (event: string) => {
|
||||
const selected = new Set(form.events.filter((value) => value !== '*'));
|
||||
if (selected.has(event)) {
|
||||
selected.delete(event);
|
||||
} else {
|
||||
selected.add(event);
|
||||
}
|
||||
form.events = [...selected].sort();
|
||||
};
|
||||
|
||||
const removeFilter = (event: string) => {
|
||||
form.events = form.events.filter((value) => value !== event);
|
||||
if (form.events.length === 0) {
|
||||
form.events = ['*'];
|
||||
}
|
||||
};
|
||||
|
||||
const isValidPattern = (value: string) => {
|
||||
if (value === '*') {
|
||||
return true;
|
||||
}
|
||||
if (events.includes(value)) {
|
||||
return true;
|
||||
}
|
||||
if (!value.endsWith('.*')) {
|
||||
return false;
|
||||
}
|
||||
return events.some((event) => event.startsWith(value.slice(0, -1)));
|
||||
};
|
||||
|
||||
const addPattern = () => {
|
||||
const value = pattern.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (!isValidPattern(value)) {
|
||||
toast.error($i18n.t('Use a valid event name or pattern like user.*'));
|
||||
return;
|
||||
}
|
||||
form.events =
|
||||
value === '*' ? ['*'] : [...new Set(form.events.filter((event) => event !== '*').concat(value))];
|
||||
pattern = '';
|
||||
};
|
||||
|
||||
const saveWebhook = async () => {
|
||||
const payload = {
|
||||
name: form.name || (form.id === 'default' ? 'Default webhook' : 'Webhook'),
|
||||
url: form.url,
|
||||
enabled: form.enabled,
|
||||
events: form.events.length ? form.events : ['*']
|
||||
};
|
||||
|
||||
try {
|
||||
if (editing) {
|
||||
await updateEventWebhook(localStorage.token, form.id, payload);
|
||||
} else {
|
||||
await createEventWebhook(localStorage.token, payload);
|
||||
}
|
||||
await load();
|
||||
resetForm();
|
||||
toast.success($i18n.t('Webhook saved'));
|
||||
} catch (error) {
|
||||
toast.error(typeof error === 'string' ? error : $i18n.t('Failed to save webhook'));
|
||||
}
|
||||
};
|
||||
|
||||
const deleteWebhook = async (webhook: EventWebhook | null = editing) => {
|
||||
if (!webhook) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteEventWebhook(localStorage.token, webhook.id);
|
||||
await load();
|
||||
resetForm();
|
||||
toast.success($i18n.t('Webhook deleted'));
|
||||
} catch (error) {
|
||||
toast.error(typeof error === 'string' ? error : $i18n.t('Failed to delete webhook'));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWebhook = async (webhook: EventWebhook, enabled: boolean) => {
|
||||
const previous = webhook.enabled;
|
||||
webhooks = webhooks.map((item) => (item.id === webhook.id ? { ...item, enabled } : item));
|
||||
|
||||
try {
|
||||
await updateEventWebhook(localStorage.token, webhook.id, { enabled });
|
||||
} catch (error) {
|
||||
webhooks = webhooks.map((item) =>
|
||||
item.id === webhook.id ? { ...item, enabled: previous } : item
|
||||
);
|
||||
toast.error(typeof error === 'string' ? error : $i18n.t('Failed to update webhook'));
|
||||
}
|
||||
};
|
||||
|
||||
onMount(load);
|
||||
</script>
|
||||
|
||||
<Modal bind:show={showWebhookModal} size="sm">
|
||||
<div>
|
||||
<div class="flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<h1 class="text-lg font-medium self-center font-primary">
|
||||
{editing ? $i18n.t('Edit event webhook') : $i18n.t('Add event webhook')}
|
||||
</h1>
|
||||
|
||||
<button
|
||||
class="self-center"
|
||||
aria-label={$i18n.t('Close')}
|
||||
type="button"
|
||||
on:click={resetForm}
|
||||
>
|
||||
<XMark className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-4 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<form class="flex flex-col w-full" on:submit|preventDefault={saveWebhook}>
|
||||
<div class="px-1">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="event-webhook-name"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Name')}</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="event-webhook-name"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
placeholder={$i18n.t('Identity audit')}
|
||||
autocomplete="off"
|
||||
bind:value={form.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex justify-between mb-0.5">
|
||||
<label
|
||||
for="event-webhook-url"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('URL')}</label
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center">
|
||||
<input
|
||||
id="event-webhook-url"
|
||||
class={`w-full flex-1 text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="url"
|
||||
placeholder="https://example.com/events"
|
||||
autocomplete="off"
|
||||
required
|
||||
bind:value={form.url}
|
||||
/>
|
||||
<Tooltip content={form.enabled ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch bind:state={form.enabled} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row justify-between items-center w-full mt-2">
|
||||
<label
|
||||
for="event-webhook-all-events"
|
||||
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
|
||||
>{$i18n.t('Events')}</label
|
||||
>
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-700 dark:text-gray-300">
|
||||
<input
|
||||
id="event-webhook-all-events"
|
||||
type="checkbox"
|
||||
checked={allEvents}
|
||||
on:change={(event) => setAllEvents(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>{$i18n.t('All events')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if !allEvents}
|
||||
<div class="flex flex-col gap-2 mt-2">
|
||||
{#if form.events.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each form.events as event}
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-full bg-gray-100 dark:bg-gray-850 px-2 py-1 text-xs"
|
||||
>
|
||||
<span class="font-mono break-all">{event}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={$i18n.t('Remove')}
|
||||
on:click={() => removeFilter(event)}
|
||||
>
|
||||
<XMark className="size-3" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
class={`w-full flex-1 text-sm bg-transparent font-mono ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
|
||||
type="text"
|
||||
placeholder={$i18n.t('Search or add pattern')}
|
||||
autocomplete="off"
|
||||
bind:value={pattern}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
addPattern();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-gray-700 dark:text-gray-300 hover:underline"
|
||||
on:click={addPattern}
|
||||
>
|
||||
{$i18n.t('Add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-40 overflow-y-auto pb-0.5">
|
||||
{#each filteredEvents as event}
|
||||
<label
|
||||
class="flex items-center gap-2 py-0.5 text-xs text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedExactEvents.includes(event)}
|
||||
on:change={() => toggleEvent(event)}
|
||||
/>
|
||||
<span class="font-mono break-all">{event}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t(
|
||||
'Event names may change as Open WebUI evolves. Use broad patterns like user.* for integrations that should continue across new related events.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-between items-center pt-3 text-sm font-medium">
|
||||
<div>
|
||||
{#if editing}
|
||||
<button
|
||||
class="px-1 py-1.5 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:underline transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showDeleteConfirmDialog = true;
|
||||
}}
|
||||
>
|
||||
{$i18n.t('Delete')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
|
||||
>
|
||||
{$i18n.t('Save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmDialog
|
||||
bind:show={showDeleteConfirmDialog}
|
||||
message={$i18n.t('Are you sure you want to delete this webhook? This action cannot be undone.')}
|
||||
confirmLabel={$i18n.t('Delete')}
|
||||
on:confirm={() => {
|
||||
deleteWebhook();
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('Events')}</div>
|
||||
<hr class="border-gray-100/30 dark:border-gray-850/30 my-2" />
|
||||
|
||||
<div class="mb-2.5 flex flex-col w-full justify-between">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="font-medium text-xs">{$i18n.t('Webhooks')}</div>
|
||||
|
||||
<Tooltip content={$i18n.t('Add event webhook')}>
|
||||
<button class="px-1" on:click={newWebhook} type="button">
|
||||
<Plus />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
{#each webhooks as webhook}
|
||||
<div class="flex w-full gap-2 items-center">
|
||||
<div
|
||||
class="flex-1 min-w-0 flex gap-1.5 items-center {webhook.enabled ? '' : 'opacity-50'}"
|
||||
>
|
||||
<div class="outline-hidden w-full bg-transparent text-sm truncate">
|
||||
<span class="font-medium">
|
||||
{webhook.id === 'default' ? $i18n.t('Default webhook') : webhook.name}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{#if webhook.id === 'default'}
|
||||
· {$i18n.t('Default')}
|
||||
{/if}
|
||||
· {urlHost(webhook.url)} · {eventSummary(webhook)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 items-center">
|
||||
<Tooltip content={$i18n.t('Configure')}>
|
||||
<button
|
||||
class="self-center p-1 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-850 rounded-lg transition"
|
||||
on:click={() => editWebhook(webhook)}
|
||||
type="button"
|
||||
>
|
||||
<Cog6 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={webhook.enabled ? $i18n.t('Enabled') : $i18n.t('Disabled')}>
|
||||
<Switch
|
||||
state={webhook.enabled}
|
||||
on:change={(event) => {
|
||||
toggleWebhook(webhook, event.detail);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if webhooks.length === 0}
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500">
|
||||
{$i18n.t('No event webhooks configured.')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-1.5">
|
||||
<div class="text-xs text-gray-500">
|
||||
{$i18n.t('Send product events as JSON to external services. Chat destinations receive readable messages.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
|
||||
import { getBackendConfig, getVersionUpdates } from '$lib/apis';
|
||||
import { getAdminConfig, updateAdminConfig } from '$lib/apis/auths';
|
||||
import { getBanners, setBanners } from '$lib/apis/configs';
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
@@ -15,6 +15,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Textarea from '$lib/components/common/Textarea.svelte';
|
||||
import Banners from './Interface/Banners.svelte';
|
||||
import Events from './Events.svelte';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
@@ -27,7 +28,6 @@
|
||||
};
|
||||
|
||||
let adminConfig = null;
|
||||
let webhookUrl = '';
|
||||
|
||||
let banners: Banner[] = [];
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
};
|
||||
|
||||
const updateHandler = async () => {
|
||||
webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
|
||||
const res = await updateAdminConfig(localStorage.token, adminConfig);
|
||||
|
||||
await updateBanners();
|
||||
@@ -66,15 +65,7 @@
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
adminConfig = await getAdminConfig(localStorage.token);
|
||||
})(),
|
||||
|
||||
(async () => {
|
||||
webhookUrl = await getWebhookUrl(localStorage.token);
|
||||
})()
|
||||
]);
|
||||
adminConfig = await getAdminConfig(localStorage.token);
|
||||
|
||||
banners = [...$_banners];
|
||||
});
|
||||
@@ -385,22 +376,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" w-full justify-between">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 space-x-2">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
|
||||
type="text"
|
||||
placeholder={`https://example.com/webhook`}
|
||||
bind:value={webhookUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Events />
|
||||
|
||||
<div class="mb-3.5">
|
||||
<div class=" mt-0.5 mb-2.5 text-base font-medium">{$i18n.t('UI')}</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user