This commit is contained in:
Timothy Jaeryang Baek
2026-06-25 14:19:30 +01:00
parent 47a1bfdd15
commit 7453968678
5 changed files with 812 additions and 59 deletions

View File

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

View File

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

View File

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

View 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>

View File

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