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:
@@ -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",
|
||||
|
||||
@@ -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": "Все типы",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'}
|
||||
|
||||
Reference in New Issue
Block a user