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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
collection_ids: string[];
|
||||
scan_interval: number;
|
||||
batch_duration: number;
|
||||
filters: Record<string, any>;
|
||||
};
|
||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||
collections: any[];
|
||||
@@ -22,6 +23,7 @@
|
||||
submitting: boolean;
|
||||
linkCheckLoading: boolean;
|
||||
error: string;
|
||||
providerType: string;
|
||||
onsave: (e: SubmitEvent) => void;
|
||||
ontoggleCollection: (collectionId: string) => void;
|
||||
formatDate: (dateStr: string) => string;
|
||||
@@ -36,10 +38,39 @@
|
||||
submitting,
|
||||
linkCheckLoading,
|
||||
error,
|
||||
providerType = '',
|
||||
onsave,
|
||||
ontoggleCollection,
|
||||
formatDate,
|
||||
}: Props = $props();
|
||||
|
||||
let isScheduler = $derived(providerType === 'scheduler');
|
||||
|
||||
// Custom variable management for scheduler
|
||||
function addVariable() {
|
||||
const vars = { ...(form.filters.custom_variables || {}) };
|
||||
const key = `var_${Object.keys(vars).length + 1}`;
|
||||
vars[key] = '';
|
||||
form.filters = { ...form.filters, custom_variables: vars };
|
||||
}
|
||||
function removeVariable(key: string) {
|
||||
const vars = { ...(form.filters.custom_variables || {}) };
|
||||
delete vars[key];
|
||||
form.filters = { ...form.filters, custom_variables: vars };
|
||||
}
|
||||
function updateVariableKey(oldKey: string, newKey: string) {
|
||||
if (!newKey || newKey === oldKey) return;
|
||||
const vars = { ...(form.filters.custom_variables || {}) };
|
||||
const val = vars[oldKey] ?? '';
|
||||
delete vars[oldKey];
|
||||
vars[newKey] = val;
|
||||
form.filters = { ...form.filters, custom_variables: vars };
|
||||
}
|
||||
function updateVariableValue(key: string, value: string) {
|
||||
const vars = { ...(form.filters.custom_variables || {}) };
|
||||
vars[key] = value;
|
||||
form.filters = { ...form.filters, custom_variables: vars };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div in:slide={{ duration: 200 }}>
|
||||
@@ -57,7 +88,7 @@
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.server')}</label>
|
||||
<EntitySelect items={providerItems} bind:value={form.provider_id} placeholder={t('notificationTracker.selectServer')} />
|
||||
</div>
|
||||
{#if collections.length > 0}
|
||||
{#if !isScheduler && collections.length > 0}
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">{t('notificationTracker.albums')} ({collections.length})</label>
|
||||
<input type="text" bind:value={collectionFilter} placeholder="Filter..."
|
||||
@@ -77,6 +108,59 @@
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isScheduler}
|
||||
<!-- Schedule type -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('notificationTracker.scheduleType')}</legend>
|
||||
<div class="flex gap-4 mt-1">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="radio" name="schedule_type" value="interval"
|
||||
checked={!form.filters.schedule_type || form.filters.schedule_type === 'interval'}
|
||||
onchange={() => form.filters = { ...form.filters, schedule_type: 'interval' }} />
|
||||
{t('notificationTracker.intervalMode')}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="radio" name="schedule_type" value="cron"
|
||||
checked={form.filters.schedule_type === 'cron'}
|
||||
onchange={() => form.filters = { ...form.filters, schedule_type: 'cron' }} />
|
||||
{t('notificationTracker.cronMode')}
|
||||
</label>
|
||||
</div>
|
||||
{#if form.filters.schedule_type === 'cron'}
|
||||
<div class="mt-3">
|
||||
<label for="trk-cron" class="block text-xs mb-1">{t('notificationTracker.cronExpression')}</label>
|
||||
<input id="trk-cron" value={form.filters.cron_expression || ''}
|
||||
oninput={(e) => form.filters = { ...form.filters, cron_expression: (e.target as HTMLInputElement).value }}
|
||||
placeholder="0 9 * * 1-5" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mt-1">{t('notificationTracker.cronHint')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-3">
|
||||
<label for="trk-interval" class="block text-xs mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
<input id="trk-interval" type="number" bind:value={form.scan_interval} min="60" max="86400" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Custom variables -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('notificationTracker.customVariables')}</legend>
|
||||
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('notificationTracker.customVariablesHint')}</p>
|
||||
{#each Object.entries(form.filters.custom_variables || {}) as [key, value]}
|
||||
<div class="flex gap-2 mb-2 items-center">
|
||||
<input value={key} onblur={(e) => updateVariableKey(key, (e.target as HTMLInputElement).value)}
|
||||
placeholder="key" class="w-1/3 px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)] font-mono" />
|
||||
<input value={value} oninput={(e) => updateVariableValue(key, (e.target as HTMLInputElement).value)}
|
||||
placeholder="value" class="flex-1 px-2 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
<button type="button" onclick={() => removeVariable(key)}
|
||||
class="text-[var(--color-error-fg)] hover:opacity-70 text-sm px-1">✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
<button type="button" onclick={addVariable}
|
||||
class="text-xs text-[var(--color-primary)] hover:underline mt-1">+ {t('notificationTracker.addVariable')}</button>
|
||||
</fieldset>
|
||||
{:else}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="trk-interval" class="block text-sm font-medium mb-1">{t('notificationTracker.scanInterval')}<Hint text={t('hints.scanInterval')} /></label>
|
||||
@@ -87,6 +171,7 @@
|
||||
<input id="trk-batch" type="number" bind:value={form.batch_duration} min="0" max="3600" class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
||||
</div>
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user