feat: default tracker configs, email validation, expandable target links
- Tracker now has default_tracking_config_id and default_template_config_id that apply to all linked targets unless overridden per-target - Dispatch falls back to tracker defaults when per-link configs are null - Email bot creation validates SMTP connection before saving - Email notifications sent as HTML (links render properly) - Linked target items are expandable: collapsed shows config CrossLinks, expanded shows config selectors; action buttons always visible - Fix email bot test button icon (mdiEmailSend → mdiSend) - Fix target type icons in LinkedTargetsSection for all types - Provider filter moved above search in sidebar
This commit is contained in:
@@ -35,10 +35,12 @@
|
||||
...allProviders.map(p => ({ value: p.id, icon: providerDefaultIcon(p), label: p.name, desc: p.type })),
|
||||
]);
|
||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||
let _syncingFilter = false;
|
||||
|
||||
// Sync filter value → store
|
||||
$effect(() => {
|
||||
const v = providerFilterValue;
|
||||
if (_syncingFilter) return;
|
||||
globalProviderFilter.set(v === 0 ? null : v);
|
||||
});
|
||||
|
||||
@@ -46,7 +48,9 @@
|
||||
$effect(() => {
|
||||
const storeId = globalProviderFilter.id;
|
||||
if (storeId === null && providerFilterValue !== 0) {
|
||||
_syncingFilter = true;
|
||||
providerFilterValue = 0;
|
||||
_syncingFilter = false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -85,6 +89,11 @@
|
||||
ptype ? items.filter(i => i.provider_type === ptype) : items;
|
||||
|
||||
const targets = targetsCache.items;
|
||||
// Single pass to count targets by type
|
||||
const targetsByType = new Map<string, number>();
|
||||
for (const t of targets) {
|
||||
targetsByType.set(t.type, (targetsByType.get(t.type) || 0) + 1);
|
||||
}
|
||||
return {
|
||||
providers: pid ? 1 : providersCache.items.length,
|
||||
notification_trackers: filterById(notificationTrackersCache.items as any[]).length,
|
||||
@@ -97,14 +106,14 @@
|
||||
telegram_bots: telegramBotsCache.items.length,
|
||||
email_bots: emailBotsCache.items.length,
|
||||
matrix_bots: matrixBotsCache.items.length,
|
||||
targets_telegram: targets.filter(t => t.type === 'telegram').length,
|
||||
targets_webhook: targets.filter(t => t.type === 'webhook').length,
|
||||
targets_email: targets.filter(t => t.type === 'email').length,
|
||||
targets_discord: targets.filter(t => t.type === 'discord').length,
|
||||
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,
|
||||
targets_telegram: targetsByType.get('telegram') || 0,
|
||||
targets_webhook: targetsByType.get('webhook') || 0,
|
||||
targets_email: targetsByType.get('email') || 0,
|
||||
targets_discord: targetsByType.get('discord') || 0,
|
||||
targets_slack: targetsByType.get('slack') || 0,
|
||||
targets_ntfy: targetsByType.get('ntfy') || 0,
|
||||
targets_matrix: targetsByType.get('matrix') || 0,
|
||||
targets_broadcast: targetsByType.get('broadcast') || 0,
|
||||
} as Record<string, number>;
|
||||
});
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
|
||||
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
|
||||
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
const defaultForm = () => ({
|
||||
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
||||
scan_interval: 60, batch_duration: 0,
|
||||
default_tracking_config_id: 0, default_template_config_id: 0,
|
||||
filters: {} as Record<string, any>,
|
||||
});
|
||||
let form = $state(defaultForm());
|
||||
@@ -143,6 +144,8 @@
|
||||
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
||||
collection_ids: [...(trk.collection_ids || [])],
|
||||
scan_interval: trk.scan_interval, batch_duration: trk.batch_duration ?? 0,
|
||||
default_tracking_config_id: (trk as any).default_tracking_config_id || 0,
|
||||
default_template_config_id: (trk as any).default_template_config_id || 0,
|
||||
filters: trk.filters || {},
|
||||
};
|
||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||
@@ -179,11 +182,16 @@
|
||||
async function doSave() {
|
||||
submitting = true;
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
default_tracking_config_id: form.default_tracking_config_id || null,
|
||||
default_template_config_id: form.default_template_config_id || null,
|
||||
};
|
||||
if (editing) {
|
||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
|
||||
await api(`/notification-trackers/${editing}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
snackSuccess(t('snack.trackerUpdated'));
|
||||
} else {
|
||||
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(form) });
|
||||
await api('/notification-trackers', { method: 'POST', body: JSON.stringify(payload) });
|
||||
snackSuccess(t('snack.trackerCreated'));
|
||||
}
|
||||
showForm = false; editing = null; linkWarning = null; await load();
|
||||
@@ -371,6 +379,8 @@
|
||||
{providerItems}
|
||||
{collections}
|
||||
bind:collectionFilter
|
||||
trackingConfigItems={trackingConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiCog' }))}
|
||||
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||
{editing}
|
||||
{submitting}
|
||||
{linkCheckLoading}
|
||||
@@ -406,7 +416,7 @@
|
||||
</Card>
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each notificationTrackers as tracker}
|
||||
{#each notificationTrackers as tracker (tracker.id)}
|
||||
<Card hover entityId={tracker.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -3,10 +3,16 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
||||
import IconButton from '$lib/components/IconButton.svelte';
|
||||
import CrossLink from '$lib/components/CrossLink.svelte';
|
||||
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
||||
import type { EntityItem } from '$lib/components/EntitySelect.svelte';
|
||||
import type { Tracker, NotificationTarget, TrackingConfig, TemplateConfig } from '$lib/types';
|
||||
|
||||
const TARGET_TYPE_ICONS: Record<string, string> = {
|
||||
telegram: 'mdiSend', webhook: 'mdiWebhook', email: 'mdiEmailOutline',
|
||||
discord: 'mdiChat', slack: 'mdiSlack', ntfy: 'mdiBell', matrix: 'mdiMatrix', broadcast: 'mdiBullhorn',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
tracker: Tracker;
|
||||
trackingConfigs: TrackingConfig[];
|
||||
@@ -47,6 +53,8 @@
|
||||
onchangeNewTemplateConfig,
|
||||
}: Props = $props();
|
||||
|
||||
let expandedTt = $state<number | null>(null);
|
||||
|
||||
function toItems(configs: any[]): EntityItem[] {
|
||||
return configsForTracker(configs).map(c => ({
|
||||
value: c.id,
|
||||
@@ -55,55 +63,86 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function configName(configs: any[], id: number | null): string {
|
||||
if (!id) return '';
|
||||
const c = configs.find((x: any) => x.id === id);
|
||||
return c?.name || '';
|
||||
}
|
||||
|
||||
const trackingConfigItems = $derived(toItems(trackingConfigs));
|
||||
const templateConfigItems = $derived(toItems(templateConfigs));
|
||||
const linkedTargetIds = $derived(new Set((tracker.tracker_targets || []).map((tt: any) => tt.target_id)));
|
||||
const targetItems = $derived<EntityItem[]>(unlinkedTargets.map(tgt => ({
|
||||
value: tgt.id,
|
||||
label: tgt.name,
|
||||
icon: tgt.icon || (tgt.type === 'telegram' ? 'mdiSend' : 'mdiWebhook'),
|
||||
icon: tgt.icon || TARGET_TYPE_ICONS[tgt.type] || 'mdiTarget',
|
||||
desc: tgt.type,
|
||||
disabled: linkedTargetIds.has(tgt.id),
|
||||
disabledHint: linkedTargetIds.has(tgt.id) ? t('notificationTracker.alreadyLinked') : undefined,
|
||||
})));
|
||||
</script>
|
||||
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-2" in:slide>
|
||||
<div class="mt-3 border-t border-[var(--color-border)] pt-3 space-y-1" in:slide>
|
||||
{#if (tracker.tracker_targets || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('notificationTracker.noLinkedTargets')}</p>
|
||||
{:else}
|
||||
{#each tracker.tracker_targets as tt}
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1.5 rounded bg-[var(--color-muted)]/30">
|
||||
<div class="flex items-center gap-2">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || (tt.target_type === 'telegram' ? 'mdiSend' : 'mdiWebhook')} size={16} /></span>
|
||||
<span class="font-medium">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
||||
<div class="min-w-[140px]">
|
||||
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
||||
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
||||
{#each tracker.tracker_targets as tt (tt.id)}
|
||||
{@const isExpanded = expandedTt === tt.id}
|
||||
<div class="rounded-md bg-[var(--color-muted)]/30 overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
<div class="flex items-center justify-between text-sm px-2.5 py-1.5">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span style="color: var(--color-primary);"><MdiIcon name={tt.target_icon || TARGET_TYPE_ICONS[tt.target_type ?? ''] || 'mdiTarget'} size={16} /></span>
|
||||
<button type="button" class="flex items-center gap-1 hover:text-[var(--color-primary)] transition-colors cursor-pointer"
|
||||
onclick={() => expandedTt = isExpanded ? null : tt.id}>
|
||||
<span class="font-medium truncate">{tt.target_name || `Target #${tt.target_id}`}</span>
|
||||
<MdiIcon name={isExpanded ? 'mdiChevronUp' : 'mdiChevronDown'} size={14} />
|
||||
</button>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{tt.target_type}</span>
|
||||
{#if !tt.enabled}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-warning-bg)] text-[var(--color-warning-fg)]">{t('notificationTracker.paused')}</span>
|
||||
{/if}
|
||||
<!-- Show overridden config badges when collapsed -->
|
||||
{#if !isExpanded}
|
||||
{#if tt.tracking_config_id}
|
||||
<CrossLink href="/tracking-configs" icon="mdiCog" label={configName(trackingConfigs, tt.tracking_config_id)} entityId={tt.tracking_config_id} />
|
||||
{/if}
|
||||
{#if tt.template_config_id}
|
||||
<CrossLink href="/template-configs" icon="mdiFileDocumentEdit" label={configName(templateConfigs, tt.template_config_id)} entityId={tt.template_config_id} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-[140px]">
|
||||
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
||||
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton icon="mdiDotsVertical" size={14} title={t('common.test')}
|
||||
onclick={(e: MouseEvent) => onopenTestMenu(tt.id, e)}
|
||||
disabled={Object.keys(ttTesting).some(k => k.startsWith(`${tt.id}_`) && ttTesting[k])} />
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => onremoveLink(tt.id)} variant="danger" />
|
||||
</div>
|
||||
<IconButton icon={tt.enabled ? 'mdiPause' : 'mdiPlay'} size={14}
|
||||
title={tt.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => onupdateLink(tt, 'enabled', !tt.enabled)} />
|
||||
<IconButton icon="mdiClose" size={14} title={t('common.delete')}
|
||||
onclick={() => onremoveLink(tt.id)} variant="danger" />
|
||||
</div>
|
||||
|
||||
<!-- Expanded config selectors -->
|
||||
{#if isExpanded}
|
||||
<div class="px-2.5 pb-2.5" in:slide={{ duration: 150 }}>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('trackingConfig.title')}</label>
|
||||
<EntitySelect items={trackingConfigItems} value={tt.tracking_config_id}
|
||||
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onupdateLink(tt, 'tracking_config_id', Number(v) || null)} />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs text-[var(--color-muted-foreground)] mb-1">{t('templateConfig.title')}</label>
|
||||
<EntitySelect items={templateConfigItems} value={tt.template_config_id}
|
||||
placeholder={t('common.noneDefault')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onupdateLink(tt, 'template_config_id', Number(v) || null)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
@@ -116,16 +155,6 @@
|
||||
placeholder={t('notificationTracker.addTarget')} size="sm"
|
||||
onselect={(v) => onchangeNewTarget(Number(v) || 0)} />
|
||||
</div>
|
||||
<div class="min-w-[140px]">
|
||||
<EntitySelect items={trackingConfigItems} value={newLinkTrackingConfigId || null}
|
||||
placeholder={t('trackingConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onchangeNewTrackingConfig(Number(v) || 0)} />
|
||||
</div>
|
||||
<div class="min-w-[140px]">
|
||||
<EntitySelect items={templateConfigItems} value={newLinkTemplateConfigId || null}
|
||||
placeholder={t('templateConfig.title')} size="sm" allowNone noneLabel={t('common.noneDefault')}
|
||||
onselect={(v) => onchangeNewTemplateConfig(Number(v) || 0)} />
|
||||
</div>
|
||||
<button onclick={onaddLink}
|
||||
disabled={!newLinkTargetId || addingTarget}
|
||||
class="text-xs px-3 py-1 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded hover:opacity-90 disabled:opacity-50">
|
||||
|
||||
@@ -16,11 +16,15 @@
|
||||
collection_ids: string[];
|
||||
scan_interval: number;
|
||||
batch_duration: number;
|
||||
default_tracking_config_id: number;
|
||||
default_template_config_id: number;
|
||||
filters: Record<string, any>;
|
||||
};
|
||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||
collections: any[];
|
||||
collectionFilter?: string;
|
||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||
editing: number | null;
|
||||
submitting: boolean;
|
||||
linkCheckLoading: boolean;
|
||||
@@ -36,6 +40,8 @@
|
||||
providerItems,
|
||||
collections,
|
||||
collectionFilter = $bindable(),
|
||||
trackingConfigItems = [],
|
||||
templateConfigItems = [],
|
||||
editing,
|
||||
submitting,
|
||||
linkCheckLoading,
|
||||
@@ -175,6 +181,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Default configs -->
|
||||
{#if trackingConfigItems.length > 0 || templateConfigItems.length > 0}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#if trackingConfigItems.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.defaultTrackingConfig')}<Hint text={t('hints.defaultTrackingConfig')} /></label>
|
||||
<EntitySelect items={[{value: 0, label: t('common.none'), icon: 'mdiMinus'}, ...trackingConfigItems]} bind:value={form.default_tracking_config_id} placeholder={t('common.none')} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if templateConfigItems.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.defaultTemplateConfig')}<Hint text={t('hints.defaultTemplateConfig')} /></label>
|
||||
<EntitySelect items={[{value: 0, label: t('common.none'), icon: 'mdiMinus'}, ...templateConfigItems]} bind:value={form.default_template_config_id} placeholder={t('common.none')} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting || linkCheckLoading} class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
|
||||
{#if linkCheckLoading}{t('notificationTracker.checkingLinks')}{:else}{editing ? t('common.save') : t('notificationTracker.createTracker')}{/if}
|
||||
</button>
|
||||
|
||||
@@ -475,7 +475,7 @@
|
||||
{#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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user