feat: broadcast notification target + UX improvements

Add broadcast target type that fans out notifications to multiple
child targets. Dispatch expands broadcast into children in
load_link_data() — dispatcher stays unaware. Children can be
toggled on/off via disabled_child_ids in config.

Also: dashboard provider card smaller font for names, scroll-to-form
on target edit, broadcast nav tab with counter, flag_modified fix
for JSON column updates, CLAUDE.md nav tree docs.
This commit is contained in:
2026-03-24 15:15:41 +03:00
parent 8cb836e16c
commit d8ecb60073
13 changed files with 327 additions and 102 deletions
+8
View File
@@ -34,6 +34,7 @@
"targetSlack": "Slack",
"targetNtfy": "ntfy",
"targetMatrix": "Matrix",
"targetBroadcast": "Broadcast",
"automation": "Automation",
"actions": "Actions"
},
@@ -256,6 +257,11 @@
"descSlack": "Slack channel webhooks for notifications",
"descNtfy": "ntfy push notification topics",
"descMatrix": "Matrix room destinations for notifications",
"descBroadcast": "Send to multiple targets at once",
"childTargets": "target(s)",
"selectChildTargets": "Select child targets",
"noChildTargets": "No child targets configured.",
"noChildTargetsAvailable": "Create other targets first, then add them here.",
"addTarget": "Add Target",
"cancel": "Cancel",
"type": "Type",
@@ -814,6 +820,8 @@
"syntaxError": "Syntax error",
"undefinedVar": "Unknown variable",
"line": "line",
"enable": "Enable",
"disable": "Disable",
"add": "Add",
"filterByName": "Filter by name...",
"allTypes": "All types",
+8
View File
@@ -34,6 +34,7 @@
"targetSlack": "Slack",
"targetNtfy": "ntfy",
"targetMatrix": "Matrix",
"targetBroadcast": "Рассылка",
"automation": "Автоматизация",
"actions": "Действия"
},
@@ -256,6 +257,11 @@
"descSlack": "Вебхуки каналов Slack для уведомлений",
"descNtfy": "Топики ntfy для push-уведомлений",
"descMatrix": "Комнаты Matrix для доставки уведомлений",
"descBroadcast": "Отправка сразу в несколько целей",
"childTargets": "цель(ей)",
"selectChildTargets": "Выберите дочерние цели",
"noChildTargets": "Дочерние цели не настроены.",
"noChildTargetsAvailable": "Сначала создайте другие цели, затем добавьте их сюда.",
"addTarget": "Добавить получателя",
"cancel": "Отмена",
"type": "Тип",
@@ -814,6 +820,8 @@
"syntaxError": "Ошибка синтаксиса",
"undefinedVar": "Неизвестная переменная",
"line": "строка",
"enable": "Включить",
"disable": "Выключить",
"add": "Добавить",
"filterByName": "Фильтр по имени...",
"allTypes": "Все типы",
+2
View File
@@ -106,6 +106,8 @@ export interface NotificationTarget {
chat_name?: string;
receiver_count: number;
receivers: TargetReceiver[];
/** Broadcast targets only: resolved child target summaries. */
child_targets?: { id: number; name: string; type: string; icon: string }[];
created_at: string;
}
+2
View File
@@ -104,6 +104,7 @@
targets_slack: targets.filter(t => t.type === 'slack').length,
targets_ntfy: targets.filter(t => t.type === 'ntfy').length,
targets_matrix: targets.filter(t => t.type === 'matrix').length,
targets_broadcast: targets.filter(t => t.type === 'broadcast').length,
} as Record<string, number>;
});
@@ -169,6 +170,7 @@
{ href: '/targets?type=slack', key: 'nav.targetSlack', icon: 'mdiSlack', countKey: 'targets_slack' },
{ href: '/targets?type=ntfy', key: 'nav.targetNtfy', icon: 'mdiBell', countKey: 'targets_ntfy' },
{ href: '/targets?type=matrix', key: 'nav.targetMatrix', icon: 'mdiMatrix', countKey: 'targets_matrix' },
{ href: '/targets?type=broadcast', key: 'nav.targetBroadcast', icon: 'mdiBullhorn', countKey: 'targets_broadcast' },
],
},
];
+2 -1
View File
@@ -250,7 +250,7 @@
</div>
<div>
<p class="text-sm" style="color: var(--color-muted-foreground);">{card.literalLabel || t(card.label)}</p>
<p class="stat-value font-mono" style="animation-delay: {i * 80 + 200}ms;">
<p class="{card.literalValue ? 'stat-value-text' : 'stat-value'} font-mono" style="animation-delay: {i * 80 + 200}ms;">
{#if card.literalValue}{card.literalValue}{:else}{card.value}{/if}{#if card.suffix}<span class="stat-suffix">{card.suffix}</span>{/if}
</p>
</div>
@@ -362,6 +362,7 @@
.stat-card-inner { background: var(--color-card); border-radius: calc(0.75rem - 1px); padding: 1.25rem; }
.stat-icon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; border-radius: 0.75rem; flex-shrink: 0; }
.stat-value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; animation: countUp 0.5s ease-out both; }
.stat-value-text { font-size: 1rem; font-weight: 600; line-height: 1.3; animation: countUp 0.5s ease-out both; }
.stat-suffix { font-size: 1rem; font-weight: 400; color: var(--color-muted-foreground); }
.event-timeline { display: flex; flex-direction: column; }
.event-item { display: flex; align-items: flex-start; gap: 1rem; position: relative; padding-bottom: 0.75rem; }
+41 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { page } from '$app/state';
import { api } from '$lib/api';
import { t, getLocale } from '$lib/i18n';
@@ -70,15 +70,17 @@
// ── Constants ──
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix'] as const;
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 => ({
@@ -120,6 +122,8 @@
matrix_bot_id: 0,
// Email
email_bot_id: 0,
// Broadcast
child_target_ids: [] as number[],
});
let form = $state(defaultForm());
let error = $state('');
@@ -128,6 +132,12 @@
let loadError = $state('');
let showTelegramSettings = $state(false);
let confirmDelete = $state<NotificationTarget | null>(null);
let formEl: HTMLElement;
async function scrollToForm() {
await tick();
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// ── Receiver inline form state ──
@@ -185,6 +195,7 @@
editing = null;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function edit(tgt: NotificationTarget) {
@@ -207,10 +218,13 @@
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 || [],
};
editing = tgt.id;
showTelegramSettings = false;
showForm = true;
scrollToForm();
}
async function save(e: SubmitEvent) {
@@ -245,6 +259,8 @@
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 };
}
if (editing) {
@@ -367,6 +383,21 @@
} 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 {
@@ -392,6 +423,7 @@
{/if}
{#if showForm}
<div bind:this={formEl}></div>
<TargetForm
bind:form
bind:formType
@@ -404,6 +436,7 @@
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}
@@ -439,7 +472,11 @@
<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.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} receiver(s)</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} receiver(s)</span>
{/if}
{#if getBotName(target)}<CrossLink href={getBotHref(target)} icon="mdiRobot" label={getBotName(target) ?? ''} entityId={getBotEntityId(target)} />{/if}
</div>
</div>
@@ -469,6 +506,7 @@
ontestReceiver={testReceiver}
onloadBotChats={loadReceiverBotChats}
onchangeReceiverForm={(f) => receiverForm = f}
ontoggleBroadcastChild={toggleBroadcastChild}
/>
</Card>
{/each}
@@ -24,6 +24,7 @@
ontestReceiver: (targetId: number, receiverId: number) => void;
onloadBotChats: (botId: number) => void;
onchangeReceiverForm: (form: Record<string, any>) => void;
ontoggleBroadcastChild?: (targetId: number, childId: number) => void;
}
let {
@@ -44,10 +45,37 @@
ontestReceiver,
onloadBotChats,
onchangeReceiverForm,
ontoggleBroadcastChild,
}: Props = $props();
</script>
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
{#if target.type === 'broadcast'}
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.childTargets')}</p>
</div>
{@const disabledIds = new Set(target.config?.disabled_child_ids || [])}
{#if target.child_targets?.length}
{#each target.child_targets as child (child.id)}
{@const isDisabled = disabledIds.has(child.id)}
<div class="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-[var(--color-muted)]" class:opacity-50={isDisabled}>
<div class="flex items-center gap-2">
<MdiIcon name={child.icon || typeIcons[child.type] || 'mdiTarget'} size={14} />
<span class="text-sm">{child.name}</span>
<span class="text-xs px-1 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{child.type}</span>
</div>
<IconButton
icon={isDisabled ? 'mdiToggleSwitchOff' : 'mdiToggleSwitch'}
title={isDisabled ? t('common.enable') : t('common.disable')}
onclick={() => ontoggleBroadcastChild?.(target.id, child.id)}
size={16}
/>
</div>
{/each}
{:else}
<p class="text-xs text-[var(--color-muted-foreground)] italic">{t('targets.noChildTargets')}</p>
{/if}
{:else}
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-medium text-[var(--color-muted-foreground)] uppercase tracking-wide">{t('targets.receivers')}</p>
</div>
@@ -153,4 +181,5 @@
{t('targets.addReceiver')}
</button>
{/if}
{/if}
</div>
@@ -6,6 +6,7 @@
import Hint from '$lib/components/Hint.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import MultiEntitySelect from '$lib/components/MultiEntitySelect.svelte';
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
import type { GridItem } from '$lib/components/IconGridSelect.svelte';
@@ -28,6 +29,7 @@
auth_token: string;
matrix_bot_id: number;
email_bot_id: number;
child_target_ids: number[];
};
formType: string;
activeType: string | null;
@@ -36,6 +38,7 @@
emailBotItems: EntityItem[];
matrixBotItems: EntityItem[];
chatActionItems: GridItem[];
broadcastChildItems?: { value: number; label: string; icon: string; desc: string }[];
telegramBotCount: number;
emailBotCount: number;
matrixBotCount: number;
@@ -56,6 +59,7 @@
emailBotItems,
matrixBotItems,
chatActionItems,
broadcastChildItems = [],
telegramBotCount,
emailBotCount,
matrixBotCount,
@@ -160,6 +164,20 @@
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('matrixBot.noBots')} <a href="/bots?tab=matrix" class="underline"></a></p>
{/if}
</div>
{:else if formType === 'broadcast'}
{@const childIds = (form.child_target_ids || []).map(String)}
<div>
<label class="block text-sm font-medium mb-1">{t('targets.selectChildTargets')}</label>
<MultiEntitySelect
items={broadcastChildItems?.map(i => ({ value: String(i.value), label: i.label, icon: i.icon, desc: i.desc })) ?? []}
values={childIds}
onchange={(vals) => form.child_target_ids = vals.map(Number)}
placeholder={t('targets.selectChildTargets')}
/>
{#if broadcastChildItems?.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('targets.noChildTargetsAvailable')}</p>
{/if}
</div>
{/if}
{#if formType === 'telegram'}