Files
notify-bridge/frontend/src/routes/targets/+page.svelte
T
alexei.dolgolyov 5bd63a2191 feat(frontend): autogenerate entity names from type/provider
Mirror the providers form pattern (defaultName tied to type) across
bots, targets, trackers, actions, and configs. Each form now derives
form.name from the selected type or provider while the user hasn't
manually edited it; switching to edit-mode flips the manualEdited
flag so existing names are preserved.

Defaults: bots → "<Type> Bot"; targets → type label; notification
trackers → "<provider> Tracker"; command trackers → "<provider>
Commands"; actions → "<provider> <Action Type>"; tracking/template/
command/command-template configs → "<descriptor.defaultName>
<Suffix>". TargetForm and TrackerForm grew an optional onnameinput
prop so parents can flag manual edits in subform inputs.
2026-05-07 13:01:52 +03:00

581 lines
22 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t, getLocale } from '$lib/i18n';
import { targetsCache, telegramBotsCache, emailBotsCache, matrixBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Button from '$lib/components/Button.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { chatActionItems } from '$lib/grid-items';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import type { NotificationTarget, TargetReceiver, TelegramChat } from '$lib/types';
import TargetForm from './TargetForm.svelte';
import ReceiverSection from './ReceiverSection.svelte';
// ── Helpers ──
function getBotName(target: NotificationTarget): string | null {
if (target.type === 'telegram' && target.config?.bot_id) {
const bot = telegramBots.find(b => b.id === target.config.bot_id);
return bot?.name || null;
}
if (target.type === 'email' && target.config?.email_bot_id) {
const bot = emailBots.find(b => b.id === target.config.email_bot_id);
return bot?.name || null;
}
if (target.type === 'matrix' && target.config?.matrix_bot_id) {
const bot = matrixBots.find(b => b.id === target.config.matrix_bot_id);
return bot?.name || null;
}
return null;
}
function getBotHref(target: NotificationTarget): string {
if (target.type === 'telegram') return '/bots?tab=telegram';
if (target.type === 'email') return '/bots?tab=email';
if (target.type === 'matrix') return '/bots?tab=matrix';
return '/bots?tab=telegram';
}
function getBotEntityId(target: NotificationTarget): number | null {
if (target.type === 'telegram') return target.config?.bot_id || null;
if (target.type === 'email') return target.config?.email_bot_id || null;
if (target.type === 'matrix') return target.config?.matrix_bot_id || null;
return null;
}
function receiverLabel(target: NotificationTarget, recv: TargetReceiver): string {
const c = recv.config || {};
if (target.type === 'telegram') {
return recv.chat_name || c.chat_id || recv.receiver_key || '?';
}
if (target.type === 'email') return c.email || recv.receiver_key || '?';
if (target.type === 'webhook') return c.url || recv.receiver_key || '?';
if (target.type === 'discord' || target.type === 'slack') {
const url = c.webhook_url || recv.receiver_key || '';
return url.length > 50 ? url.substring(0, 50) + '...' : url || '?';
}
if (target.type === 'ntfy') return c.topic || recv.receiver_key || '?';
if (target.type === 'matrix') return c.room_id || recv.receiver_key || '?';
return recv.receiver_key || '?';
}
// ── Constants ──
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
type TargetType = typeof ALL_TYPES[number];
const TYPE_ICONS: Record<string, string> = {
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix',
broadcast: 'mdiBullhorn',
};
const TYPE_DESC_KEYS: Record<string, string> = {
telegram: 'targets.descTelegram', webhook: 'targets.descWebhook', email: 'targets.descEmail',
discord: 'targets.descDiscord', slack: 'targets.descSlack', ntfy: 'targets.descNtfy', matrix: 'targets.descMatrix',
broadcast: 'targets.descBroadcast',
};
const typeGridItems = $derived(ALL_TYPES.map(tt => ({
value: tt,
icon: TYPE_ICONS[tt] || 'mdiTarget',
label: tt.charAt(0).toUpperCase() + tt.slice(1),
})));
// ── Derived state ──
let allTargets = $derived(targetsCache.items);
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
let filterText = $state('');
let targets = $derived(allTargets.filter(t =>
(!activeType || t.type === activeType) &&
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase()))
));
let telegramBots = $derived(telegramBotsCache.items);
let emailBots = $derived(emailBotsCache.items);
let matrixBots = $derived(matrixBotsCache.items);
const telegramBotItems = $derived(telegramBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiRobot', desc: b.bot_username ? `@${b.bot_username}` : '' })));
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
// ── Target form state ──
let showForm = $state(false);
let editing = $state<number | null>(null);
let formType = $state<TargetType>('telegram');
const defaultForm = () => ({
name: '', icon: '', bot_id: 0, bot_token: '',
max_media_to_send: 50, max_media_per_group: 10, media_delay: 500, max_asset_size: 50,
disable_url_preview: true, send_large_photos_as_documents: false, ai_captions: false, chat_action: 'typing',
// Discord/Slack shared settings
username: '',
// ntfy shared settings
server_url: 'https://ntfy.sh', auth_token: '',
// Matrix
matrix_bot_id: 0,
// Email
email_bot_id: 0,
// Broadcast
child_target_ids: [] as number[],
});
let form = $state(defaultForm());
let nameManuallyEdited = $state(false);
let error = $state('');
let loaded = $state(false);
let submitting = $state(false);
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
let formEl = $state<HTMLElement | undefined>();
const TARGET_TYPE_DEFAULT_NAMES: Record<TargetType, string> = {
telegram: 'Telegram', webhook: 'Webhook', email: 'Email',
discord: 'Discord', slack: 'Slack', ntfy: 'ntfy', matrix: 'Matrix',
broadcast: 'Broadcast',
};
$effect(() => {
if (showForm && !nameManuallyEdited && !editing) {
form.name = TARGET_TYPE_DEFAULT_NAMES[formType] ?? '';
}
});
async function scrollToForm() {
await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Receiver inline form state ──
let addingReceiverForTarget = $state<number | null>(null);
let receiverForm = $state<Record<string, any>>({});
let receiverSubmitting = $state(false);
let receiverBotChats = $state<Record<number, TelegramChat[]>>({});
let receiverHeadersError = $state('');
let confirmDeleteReceiver = $state<{ targetId: number; receiver: TargetReceiver } | null>(null);
let receiverTesting = $state<Record<number, boolean>>({});
// ── Effects ──
// Reset form when switching target type tabs
$effect(() => {
activeType; // track
showForm = false;
editing = null;
error = '';
addingReceiverForTarget = null;
});
// ── Data loading ──
onMount(load);
const headerPills = $derived.by(() => {
const pills: Array<{ label: string; tone: 'mint' | 'sky' | 'orchid' }> = [];
if (activeType) {
// Tab-filtered: show count of receivers for the active type only.
const total = targets.reduce((acc, t) => acc + (t.receiver_count || 0), 0);
if (total > 0) pills.push({ label: `${total} ${total === 1 ? t('targets.receiver') : t('targets.receivers')}`, tone: 'mint' });
} else {
const types = new Set(targets.map(t => t.type)).size;
if (types > 0) pills.push({ label: `${types} ${t('targets.channelsCount')}`, tone: 'sky' });
}
return pills;
});
async function load() {
try {
await Promise.all([
targetsCache.fetch(true), telegramBotsCache.fetch(),
emailBotsCache.fetch(), matrixBotsCache.fetch(),
]);
loadError = '';
} catch (err: any) {
loadError = err.message || t('common.loadError');
snackError(loadError);
} finally {
loaded = true;
highlightFromUrl();
}
}
async function loadReceiverBotChats(botId: number) {
if (!botId) return;
try {
const data = await api<TelegramChat[]>(`/telegram-bots/${botId}/chats`);
receiverBotChats = { ...receiverBotChats, [botId]: data };
} catch (e) { console.warn('Failed to load bot chats:', e); }
}
// ── Target CRUD ──
function openNew() {
form = defaultForm();
formType = activeType || 'telegram';
// Auto-select first available bot of the chosen type
if (formType === 'telegram' && telegramBots.length > 0) form.bot_id = telegramBots[0].id;
if (formType === 'email' && emailBots.length > 0) form.email_bot_id = emailBots[0].id;
if (formType === 'matrix' && matrixBots.length > 0) form.matrix_bot_id = matrixBots[0].id;
nameManuallyEdited = false;
editing = null;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function edit(tgt: NotificationTarget) {
formType = tgt.type as TargetType;
const c = tgt.config || {};
form = {
name: tgt.name, icon: tgt.icon || '',
// telegram
bot_id: c.bot_id || 0, bot_token: '',
max_media_to_send: c.max_media_to_send ?? 50, max_media_per_group: c.max_media_per_group ?? 10,
media_delay: c.media_delay ?? 500, max_asset_size: c.max_asset_size ?? 50,
disable_url_preview: c.disable_url_preview ?? false, send_large_photos_as_documents: c.send_large_photos_as_documents ?? false,
ai_captions: c.ai_captions ?? false, chat_action: tgt.chat_action ?? c.chat_action ?? 'typing',
// discord/slack
username: c.username || '',
// ntfy
server_url: c.server_url || 'https://ntfy.sh',
auth_token: c.auth_token || '',
// email
email_bot_id: c.email_bot_id || 0,
// matrix
matrix_bot_id: c.matrix_bot_id || 0,
// broadcast
child_target_ids: c.child_target_ids || [],
};
nameManuallyEdited = true;
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function save(e: SubmitEvent) {
e.preventDefault();
error = '';
if (submitting) return;
submitting = true;
try {
let config: Record<string, any> = {};
if (formType === 'telegram') {
let botToken = form.bot_token;
if (form.bot_id && !botToken) {
const tokenRes = await api<{ token: string }>(`/telegram-bots/${form.bot_id}/token`);
botToken = tokenRes.token;
}
config = {
...(botToken ? { bot_token: botToken } : {}),
bot_id: form.bot_id || undefined,
max_media_to_send: form.max_media_to_send, max_media_per_group: form.max_media_per_group,
media_delay: form.media_delay, max_asset_size: form.max_asset_size,
disable_url_preview: form.disable_url_preview, send_large_photos_as_documents: form.send_large_photos_as_documents,
ai_captions: form.ai_captions,
};
} else if (formType === 'webhook') {
config = { ai_captions: form.ai_captions };
} else if (formType === 'discord' || formType === 'slack') {
config = { username: form.username || undefined };
} else if (formType === 'ntfy') {
config = { server_url: form.server_url, auth_token: form.auth_token || undefined };
} else if (formType === 'email') {
config = { email_bot_id: form.email_bot_id };
} else if (formType === 'matrix') {
config = { matrix_bot_id: form.matrix_bot_id };
} else if (formType === 'broadcast') {
config = { child_target_ids: form.child_target_ids };
}
const body: Record<string, any> = { name: form.name, icon: form.icon, config };
if (formType === 'telegram') body.chat_action = form.chat_action || null;
if (editing) {
await api(`/targets/${editing}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/targets', { method: 'POST', body: JSON.stringify({ type: formType, ...body }) });
}
showForm = false;
editing = null;
await load();
snackSuccess(t('snack.targetSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
} finally {
submitting = false;
}
}
async function test(id: number) {
try {
const res = await api<{ success: boolean; error?: string }>(`/targets/${id}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
}
let blockedBy = $state<BlockedByDetail | null>(null);
async function remove(id: number) {
try {
await api(`/targets/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.targetDeleted'));
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
}
}
// ── Receiver CRUD ──
function openReceiverForm(targetId: number, targetType: string) {
addingReceiverForTarget = targetId;
receiverHeadersError = '';
if (targetType === 'telegram') {
receiverForm = { chat_id: '' };
// Load bot chats for the target's bot
const tgt = allTargets.find(t => t.id === targetId);
const botId = tgt?.config?.bot_id;
if (botId && !receiverBotChats[botId]) loadReceiverBotChats(botId);
} else if (targetType === 'email') {
receiverForm = { email: '' };
} else if (targetType === 'webhook') {
receiverForm = { url: '', headers: '' };
} else if (targetType === 'discord' || targetType === 'slack') {
receiverForm = { webhook_url: '' };
} else if (targetType === 'ntfy') {
receiverForm = { topic: '' };
} else if (targetType === 'matrix') {
receiverForm = { room_id: '' };
}
}
async function saveReceiver(targetId: number) {
if (receiverSubmitting) return;
receiverSubmitting = true;
receiverHeadersError = '';
try {
const config: Record<string, any> = { ...receiverForm };
// Enrich Telegram receiver with chat metadata
if (config.chat_id && addingReceiverForTarget) {
const target = allTargets.find(t => t.id === addingReceiverForTarget);
const botId = target?.config?.bot_id || target?.config?.telegram_bot_id;
if (botId && receiverBotChats[botId]) {
const chat = receiverBotChats[botId].find((c: TelegramChat) => String(c.chat_id) === String(config.chat_id));
if (chat) {
config.chat_name = chat.title || chat.username || '';
if (chat.language_code) config.language_code = chat.language_code;
}
}
}
// Parse headers JSON for webhook
if ('headers' in config && typeof config.headers === 'string') {
if (config.headers) {
try { config.headers = JSON.parse(config.headers); }
catch { receiverHeadersError = t('common.headersInvalid'); return; }
} else {
delete config.headers;
}
}
await api(`/targets/${targetId}/receivers`, {
method: 'POST',
body: JSON.stringify({ name: '', config }),
});
addingReceiverForTarget = null;
await load();
snackSuccess(t('targets.receiverAdded'));
} catch (err: any) {
snackError(err.message);
} finally {
receiverSubmitting = false;
}
}
async function toggleReceiver(targetId: number, receiver: TargetReceiver) {
try {
await api(`/targets/${targetId}/receivers/${receiver.id}`, {
method: 'PUT',
body: JSON.stringify({ enabled: !receiver.enabled }),
});
await load();
snackSuccess(receiver.enabled ? t('targets.receiverDisabled') : t('targets.receiverEnabled'));
} catch (err: any) { snackError(err.message); }
}
async function removeReceiver(targetId: number, receiverId: number) {
try {
await api(`/targets/${targetId}/receivers/${receiverId}`, { method: 'DELETE' });
await load();
snackSuccess(t('targets.receiverDeleted'));
} catch (err: any) { snackError(err.message); }
}
async function toggleBroadcastChild(targetId: number, childId: number) {
const tgt = allTargets.find(t => t.id === targetId);
if (!tgt) return;
const disabled = new Set<number>(tgt.config?.disabled_child_ids || []);
if (disabled.has(childId)) disabled.delete(childId);
else disabled.add(childId);
try {
await api(`/targets/${targetId}`, {
method: 'PUT',
body: JSON.stringify({ config: { ...tgt.config, disabled_child_ids: [...disabled] } }),
});
await load();
} catch (err: any) { snackError(err.message); }
}
async function testReceiver(targetId: number, receiverId: number) {
receiverTesting = { ...receiverTesting, [receiverId]: true };
try {
const res = await api<{ success: boolean; error?: string }>(`/targets/${targetId}/receivers/${receiverId}/test?locale=${getLocale()}`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.targetTestSent'));
else snackError(`Failed: ${res.error}`);
} catch (err: any) { snackError(err.message); }
finally { receiverTesting = { ...receiverTesting, [receiverId]: false }; }
}
</script>
<PageHeader
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets"
count={targets.length}
countLabel={t('dashboard.targetsShort')}
pills={headerPills}
>
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
{showForm ? t('targets.cancel') : t('targets.addTarget')}
</Button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if loadError}
<ErrorBanner message={loadError} />
{/if}
{#if showForm}
<div bind:this={formEl}></div>
<TargetForm
bind:form
bind:formType
{activeType}
{typeGridItems}
{telegramBotItems}
{emailBotItems}
{matrixBotItems}
chatActionItems={chatActionItems()}
telegramBotCount={telegramBots.length}
emailBotCount={emailBots.length}
matrixBotCount={matrixBots.length}
broadcastChildItems={allTargets.filter(t => t.type !== 'broadcast' && t.id !== editing).map(t => ({ value: t.id, label: t.name, icon: t.icon || TYPE_ICONS[t.type] || 'mdiTarget', desc: t.type }))}
{editing}
{submitting}
{error}
bind:showTelegramSettings
onsave={save}
ontoggleTelegramSettings={() => showTelegramSettings = !showTelegramSettings}
onnameinput={() => nameManuallyEdited = true}
/>
{/if}
{#if !showForm && allTargets.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
{#if allTargets.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiTarget" message={t('targets.noTargets')} />
</Card>
{:else if targets.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each targets as target (target.id)}
<Card hover entityId={target.id}>
<!-- Target header -->
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={target.icon || TYPE_ICONS[target.type] || 'mdiTarget'} size={20} /></span>
<p class="font-medium">{target.name}</p>
{#if !activeType}<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.type}</span>{/if}
{#if target.type === 'broadcast' && target.child_targets?.length}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{target.child_targets.length} {t('targets.childTargets')}</span>
{:else if target.type !== 'broadcast' && (target.receivers || []).length > 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{(target.receivers || []).length} {t('targets.receivers')}</span>
{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(target)} />
<IconButton icon="mdiSend" title={t('targets.test')} onclick={() => test(target.id)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => confirmDelete = target} variant="danger" />
</div>
</div>
<!-- Receivers list -->
<ReceiverSection
{target}
typeIcons={TYPE_ICONS}
{addingReceiverForTarget}
bind:receiverForm
{receiverSubmitting}
{receiverHeadersError}
{receiverBotChats}
{receiverTesting}
{receiverLabel}
onopenReceiverForm={openReceiverForm}
onsaveReceiver={saveReceiver}
oncancelReceiver={() => addingReceiverForTarget = null}
ontoggleReceiver={toggleReceiver}
onremoveReceiver={(targetId, recv) => confirmDeleteReceiver = { targetId, receiver: recv }}
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal
open={!!confirmDelete}
message={t('targets.confirmDelete')}
onconfirm={() => { if (confirmDelete) { remove(confirmDelete.id); confirmDelete = null; } }}
oncancel={() => confirmDelete = null}
/>
<ConfirmModal
open={!!confirmDeleteReceiver}
message={t('targets.confirmDeleteReceiver')}
onconfirm={() => { if (confirmDeleteReceiver) { removeReceiver(confirmDeleteReceiver.targetId, confirmDeleteReceiver.receiver.id); confirmDeleteReceiver = null; } }}
oncancel={() => confirmDeleteReceiver = null}
/>
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />