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
@@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { commandConfigsCache, commandTemplateConfigsCache } from '$lib/stores/caches.svelte';
import { commandConfigsCache, commandTemplateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
@@ -37,22 +37,23 @@
let submitting = $state(false);
let confirmDelete = $state<any>(null);
const allCommands = [
{ key: 'help', icon: 'mdiHelpCircle' },
{ key: 'status', icon: 'mdiChartBox' },
{ key: 'albums', icon: 'mdiImageMultiple' },
{ key: 'events', icon: 'mdiPulse' },
{ key: 'summary', icon: 'mdiFileDocumentEdit' },
{ key: 'latest', icon: 'mdiImagePlus' },
{ key: 'memory', icon: 'mdiHistory' },
{ key: 'random', icon: 'mdiDice3' },
{ key: 'search', icon: 'mdiMagnify' },
{ key: 'find', icon: 'mdiFileSearch' },
{ key: 'person', icon: 'mdiAccount' },
{ key: 'place', icon: 'mdiMapMarker' },
{ key: 'favorites', icon: 'mdiStar' },
{ key: 'people', icon: 'mdiAccountGroup' },
];
// Immich command icons — used as fallback when capabilities don't specify icons
const commandIcons: Record<string, string> = {
help: 'mdiHelpCircle', status: 'mdiChartBox', albums: 'mdiImageMultiple',
events: 'mdiPulse', summary: 'mdiFileDocumentEdit', latest: 'mdiImagePlus',
memory: 'mdiHistory', random: 'mdiDice3', search: 'mdiMagnify',
find: 'mdiFileSearch', person: 'mdiAccount', place: 'mdiMapMarker',
favorites: 'mdiStar', people: 'mdiAccountGroup',
};
let allCapabilities = $derived(capabilitiesCache.items);
let providerCommands = $derived<{key: string, icon: string}[]>(
(allCapabilities[form.provider_type]?.commands || []).map((c: any) => ({
key: c.name,
icon: commandIcons[c.name] || 'mdiConsole',
}))
);
let hasCommands = $derived(providerCommands.length > 0);
const defaultForm = () => ({
name: '',
@@ -72,6 +73,7 @@
await Promise.all([
commandConfigsCache.fetch(true),
commandTemplateConfigsCache.fetch(),
capabilitiesCache.fetch(),
]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); }
@@ -170,11 +172,12 @@
{/if}
</div>
{#if hasCommands}
<!-- Enabled commands -->
<div>
<p class="text-sm font-medium mb-2">{t('commandConfig.enabledCommands')}</p>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1">
{#each allCommands as cmd}
{#each providerCommands as cmd}
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-[var(--color-muted)] px-2 py-1 rounded">
<input type="checkbox" checked={form.enabled_commands.includes(cmd.key)}
onchange={() => toggleCmd(cmd.key)} />
@@ -212,6 +215,11 @@
<input type="number" bind:value={form.rate_limits.default} min="0" max="300"
class="w-full px-2 py-1.5 text-sm border border-[var(--color-border)] rounded-md bg-[var(--color-background)]" />
</div>
{:else}
<div class="text-sm text-[var(--color-muted-foreground)] p-3 border border-[var(--color-border)] rounded-md">
{t('commandConfig.noCommandsForProvider')}
</div>
{/if}
<button type="submit" disabled={submitting}
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">