feat: add Scheduler provider + multi-provider UX fixes

Scheduler provider:
- Virtual provider (no external service) that emits SCHEDULED_MESSAGE
  events on user-defined intervals or cron expressions
- Custom variables stored in tracker filters, flattened into template context
- fire_count persists across triggers via tracker state
- APScheduler CronTrigger support for cron-mode schedules
- Default templates (EN+RU), seeded on startup

Multi-provider UX fixes:
- Tracking config hides Immich-specific sections (periodic, scheduled,
  memory, asset display) for non-Immich providers
- Command config driven by provider capabilities — hides commands/settings
  for providers without bot commands
- Template config hides empty "Scheduled Messages" group
- Test menu on tracker targets is provider-aware (Immich shows all 4 test
  types, others show only basic)
- Removed redundant Test button from tracker card
- System-owned tracking configs (user_id=0) seeded for Gitea + Scheduler
- Fixed ownership checks to allow system configs in tracker-target links
- Capabilities cache shared across template-configs and command-configs
- Command tracker bot selector uses EntitySelect instead of raw select
- Sample context includes Gitea + Scheduler variables for template preview
This commit is contained in:
2026-03-22 15:50:51 +03:00
parent 6d28cfb8d8
commit 0562f78b35
30 changed files with 688 additions and 56 deletions
@@ -47,8 +47,12 @@
const defaultForm = () => ({
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
scan_interval: 60, batch_duration: 0,
filters: {} as Record<string, any>,
});
let form = $state(defaultForm());
let selectedProviderType = $derived(
providers.find(p => p.id === form.provider_id)?.type || ''
);
let error = $state('');
// Linked targets management
@@ -62,12 +66,25 @@
let testMenuOpen = $state<string | null>(null);
let testMenuStyle = $state('');
const testTypes = [
const immichTestTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
{ key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic' },
{ key: 'scheduled', icon: 'mdiImageMultiple', labelKey: 'notificationTracker.testScheduled' },
{ key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory' },
];
const defaultTestTypes = [
{ key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
];
let testMenuTrackerId = $state<number | null>(null);
let testTypes = $derived(() => {
if (!testMenuTrackerId) return defaultTestTypes;
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
if (!tracker) return defaultTestTypes;
const provider = providers.find(p => p.id === tracker.provider_id);
if (provider?.type === 'immich') return immichTestTypes;
return defaultTestTypes;
});
onMount(load);
@@ -105,6 +122,7 @@
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,
filters: trk.filters || {},
};
previousCollectionIds = [...(trk.collection_ids || [])];
editing = trk.id; showForm = true;
@@ -307,6 +325,7 @@
const btn = event.currentTarget as HTMLElement;
const rect = btn.getBoundingClientRect();
testMenuStyle = `position:fixed; z-index:9999; top:${rect.bottom + 4}px; right:${window.innerWidth - rect.right}px;`;
testMenuTrackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: any) => String(x.id) === String(ttId)))?.id ?? null;
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
}
@@ -339,6 +358,7 @@
{submitting}
{linkCheckLoading}
{error}
providerType={selectedProviderType}
onsave={save}
ontoggleCollection={toggleCollection}
{formatDate}
@@ -370,7 +390,6 @@
</div>
<div class="flex items-center gap-1 flex-wrap justify-end">
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
<IconButton icon="mdiPlay" title={t('common.test')} onclick={async () => { try { await api(`/notification-trackers/${tracker.id}/trigger`, { method: 'POST' }); snackSuccess(t('snack.targetTestSent')); } catch (err) { snackError((err as any).message); } }} />
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
<button onclick={() => toggleExpand(tracker.id)}
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
@@ -411,7 +430,7 @@
{testMenuOpen}
{testMenuStyle}
{ttTesting}
{testTypes}
testTypes={testTypes()}
ontest={handleTestFromMenu}
onclose={() => testMenuOpen = null}
/>