This commit is contained in:
Timothy Jaeryang Baek
2026-06-25 15:22:20 +01:00
parent 7d9b249671
commit 7b55a63fc7
67 changed files with 258 additions and 300 deletions
@@ -241,8 +241,5 @@
'The evaluation leaderboard is based on the Elo rating system and is updated in real-time.'
)}
</div>
{$i18n.t(
'The leaderboard is currently in beta, and we may adjust the rating calculations as we refine the algorithm.'
)}
</div>
</div>
+254 -168
View File
@@ -10,12 +10,15 @@
getEventWebhooks,
getEvents,
updateEventWebhook,
type EventWebhook
type EventCatalogItem,
type EventWebhook,
type EventWebhookTarget
} 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 MemberSelector from '$lib/components/workspace/common/MemberSelector.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';
@@ -24,11 +27,14 @@
const i18n = getContext<Writable<i18nType>>('i18n');
let webhooks: EventWebhook[] = [];
let events: string[] = [];
let eventItems: EventCatalogItem[] = [];
let pattern = '';
let showWebhookModal = false;
let showDeleteConfirmDialog = false;
let editing: EventWebhook | null = null;
let targetMode: 'all' | 'selected' | 'none' = 'all';
let targetUserIds: string[] = [];
let targetGroupIds: string[] = [];
let form = {
id: '',
name: '',
@@ -37,6 +43,8 @@
events: ['*'] as string[]
};
$: events = eventItems.map((item) => item.event);
$: eventDetails = Object.fromEntries(eventItems.map((item) => [item.event, item]));
$: eventFilter = pattern.trim().toLowerCase().replace(/\*$/, '');
$: filteredEvents = events.filter((event) => !eventFilter || event.includes(eventFilter));
$: allEvents = form.events.includes('*');
@@ -58,10 +66,16 @@
getEvents(localStorage.token),
getEventWebhooks(localStorage.token)
]);
events = (catalog?.events ?? []).sort();
eventItems = [...(catalog?.events ?? [])].sort((a, b) => a.event.localeCompare(b.event));
webhooks = sortWebhooks(webhookList ?? []);
};
const resetTargets = () => {
targetMode = 'all';
targetUserIds = [];
targetGroupIds = [];
};
const resetForm = () => {
editing = null;
showWebhookModal = false;
@@ -73,6 +87,7 @@
events: ['*']
};
pattern = '';
resetTargets();
};
const newWebhook = () => {
@@ -91,6 +106,19 @@
events: webhook.events?.length ? [...webhook.events] : ['*']
};
pattern = '';
if (webhook.targets === null || webhook.targets === undefined) {
resetTargets();
return;
}
targetMode = webhook.targets.length > 0 ? 'selected' : 'none';
targetUserIds = webhook.targets
.filter((target) => target.type === 'user')
.map((target) => target.id);
targetGroupIds = webhook.targets
.filter((target) => target.type === 'group')
.map((target) => target.id);
};
const eventSummary = (webhook: EventWebhook) => {
@@ -104,6 +132,27 @@
return $i18n.t('{{count}} filters', { count: filters.length });
};
const targetSummary = (webhook: EventWebhook) => {
const targets = webhook.targets;
if (targets === null || targets === undefined) {
return $i18n.t('All users');
}
if (targets.length === 0) {
return $i18n.t('No targets');
}
const userCount = targets.filter((target) => target.type === 'user').length;
const groupCount = targets.filter((target) => target.type === 'group').length;
const parts = [];
if (userCount > 0) {
parts.push($i18n.t('{{count}} users', { count: userCount }));
}
if (groupCount > 0) {
parts.push($i18n.t('{{count}} groups', { count: groupCount }));
}
return parts.join(' + ');
};
const urlHost = (url: string) => {
try {
return new URL(url).host;
@@ -155,17 +204,33 @@
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 selectedTargets = (): EventWebhookTarget[] | null => {
if (targetMode === 'all') {
return null;
}
if (targetMode === 'none') {
return [];
}
return [
...targetUserIds.map((id) => ({ type: 'user' as const, id })),
...targetGroupIds.map((id) => ({ type: 'group' as const, id }))
];
};
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 : ['*']
events: form.events.length ? form.events : ['*'],
targets: selectedTargets()
};
try {
@@ -182,13 +247,13 @@
}
};
const deleteWebhook = async (webhook: EventWebhook | null = editing) => {
if (!webhook) {
return;
}
const deleteWebhook = async (webhook: EventWebhook | null = editing) => {
if (!webhook) {
return;
}
try {
await deleteEventWebhook(localStorage.token, webhook.id);
try {
await deleteEventWebhook(localStorage.token, webhook.id);
await load();
resetForm();
toast.success($i18n.t('Webhook deleted'));
@@ -214,11 +279,11 @@
onMount(load);
</script>
<Modal bind:show={showWebhookModal} size="sm">
<Modal bind:show={showWebhookModal} size="md">
<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')}
{editing ? $i18n.t('Edit webhook') : $i18n.t('Add webhook')}
</h1>
<button
@@ -231,167 +296,191 @@
</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 class="flex flex-col w-full px-5 pb-4 dark:text-gray-200">
<form class="flex flex-col w-full" on:submit|preventDefault={saveWebhook}>
<div class="flex gap-3">
<div class="flex flex-col w-full">
<label
for="event-webhook-name"
class={`text-xs mb-0.5 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('Name')}</label
>
<input
id="event-webhook-name"
class={`w-full 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 class="flex gap-3 mt-2">
<div class="flex flex-col w-full">
<label
for="event-webhook-url"
class={`text-xs mb-0.5 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>{$i18n.t('URL')}</label
>
<div class="flex items-center gap-2">
<input
id="event-webhook-url"
class={`w-full 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 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="mt-3">
<div class="flex justify-between items-center">
<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>
<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}
{#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"
>
{$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"
<span class="font-mono break-all">{event}</span>
<button
type="button"
aria-label={$i18n.t('Remove')}
on:click={() => removeFilter(event)}
>
<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>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/each}
</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>
<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>
<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 class="max-h-36 overflow-y-auto pb-0.5">
{#each filteredEvents as event}
<label class="flex items-start gap-2 py-0.5 text-xs text-gray-700 dark:text-gray-300">
<input
class="mt-0.5"
type="checkbox"
checked={selectedExactEvents.includes(event)}
on:change={() => toggleEvent(event)}
/>
<span class="min-w-0">
<span class="font-mono break-all">{event}</span>
<span class="ml-1 text-gray-500">{eventDetails[event]?.message}</span>
</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>
<div class="mt-3">
<div class="flex justify-between items-center gap-3">
<div
class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
>
{$i18n.t('Audience')}
</div>
<select
class="text-xs bg-transparent outline-hidden text-gray-700 dark:text-gray-300"
bind:value={targetMode}
>
<option value="all">{$i18n.t('All users')}</option>
<option value="selected">{$i18n.t('Selected users and groups')}</option>
<option value="none">{$i18n.t('No targets')}</option>
</select>
</div>
</form>
</div>
{#if targetMode === 'selected'}
<div class="mt-2 max-h-56 overflow-y-auto">
<MemberSelector
bind:userIds={targetUserIds}
bind:groupIds={targetGroupIds}
includeUsers={true}
includeGroups={true}
includeSessionUser={true}
/>
</div>
{/if}
</div>
<div class="flex justify-between items-center pt-4 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>
</form>
</div>
</div>
</Modal>
@@ -413,14 +502,14 @@
<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')}>
<Tooltip content={$i18n.t('Add webhook')}>
<button class="px-1" on:click={newWebhook} type="button">
<Plus />
</button>
</Tooltip>
</div>
<div class="flex flex-col gap-1.5">
<div class="flex flex-col gap-1">
{#each webhooks as webhook}
<div class="flex w-full gap-2 items-center">
<div
@@ -431,10 +520,7 @@
{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)}
- {urlHost(webhook.url)} - {eventSummary(webhook)} - {targetSummary(webhook)}
</span>
</div>
</div>
@@ -291,7 +291,7 @@
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Memories')} ({$i18n.t('Beta')})
{$i18n.t('Memories')}
</div>
<Switch bind:state={adminConfig.ENABLE_MEMORIES} />
@@ -299,7 +299,7 @@
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Notes')} ({$i18n.t('Beta')})
{$i18n.t('Notes')}
</div>
<Switch bind:state={adminConfig.ENABLE_NOTES} />
@@ -307,7 +307,7 @@
<div class="mb-2.5 flex w-full items-center justify-between pr-2">
<div class=" self-center text-xs font-medium">
{$i18n.t('Channels')} ({$i18n.t('Beta')})
{$i18n.t('Channels')}
</div>
<Switch bind:state={adminConfig.ENABLE_CHANNELS} />