ab621b6abc
Display filters (Immich tracking config): - favorites_only drops events with no favorited new assets, or filters added_assets to favorites only - assets_order_by/assets_order sort the rendered list (date / name / rating / random / none) - max_assets_to_show caps rendered+attached media (default 5 -> 10) - include_tags strips people from event extras and tags from each asset - include_asset_details strips city/country/state/lat/lon/is_favorite/ rating/description; load-bearing fields (thumbhash, file_size, playback_size, cache keys) preserved - New apply_tracking_display_filters helper in dispatch_helpers; wired into watcher, webhooks, scheduled/periodic/memory, and manual test-dispatch - Targets sharing a TrackingConfig dispatch together; targets with different TCs each see their own shaped event Adaptive polling: - Replace NotificationTracker.batch_duration with adaptive_max_skip - Per-tracker opt-in: NULL/0 disables back-off (every tick runs); positive N caps the skip factor at (N-1)-in-N after long idle - Scheduler caches the cap in module state for the tick fast-path - Migration adds the new column; API schemas/responses, frontend types, i18n, and the tracker form updated to match
560 lines
22 KiB
Svelte
560 lines
22 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { api, parseDate } from '$lib/api';
|
|
import { t, getLocale } from '$lib/i18n';
|
|
import { providersCache, targetsCache, trackingConfigsCache, templateConfigsCache, 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';
|
|
import MdiIcon from '$lib/components/MdiIcon.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
|
import IconButton from '$lib/components/IconButton.svelte';
|
|
import CrossLink from '$lib/components/CrossLink.svelte';
|
|
import EntitySelect from '$lib/components/EntitySelect.svelte';
|
|
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
|
|
import { highlightFromUrl } from '$lib/highlight';
|
|
import { providerDefaultIcon } from '$lib/grid-items';
|
|
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
|
|
import { getDescriptor } from '$lib/providers';
|
|
import Button from '$lib/components/Button.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import type { Tracker, TrackerTarget, TrackingConfig, TemplateConfig, NotificationTarget } from '$lib/types';
|
|
|
|
import TrackerForm from './TrackerForm.svelte';
|
|
import LinkedTargetsSection from './LinkedTargetsSection.svelte';
|
|
import SharedLinkModal from './SharedLinkModal.svelte';
|
|
import TestMenu from './TestMenu.svelte';
|
|
|
|
let loaded = $state(false);
|
|
let loadError = $state('');
|
|
let allNotificationTrackers = $state<Tracker[]>([]);
|
|
let filterText = $state('');
|
|
let filterProviderId = $state(0);
|
|
let effectiveProviderId = $derived(globalProviderFilter.id || filterProviderId);
|
|
let notificationTrackers = $derived(allNotificationTrackers.filter(t =>
|
|
(!filterText || t.name.toLowerCase().includes(filterText.toLowerCase())) &&
|
|
(!effectiveProviderId || t.provider_id === effectiveProviderId)
|
|
));
|
|
let providers = $derived(providersCache.items);
|
|
let allCapabilities: Record<string, any> = $derived(capabilitiesCache.items || {});
|
|
const providerItems = $derived(providers
|
|
.filter(p => !globalProviderFilter.providerType || p.type === globalProviderFilter.providerType)
|
|
.map(p => ({ value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type })));
|
|
let targets = $derived(targetsCache.items);
|
|
let trackingConfigs = $derived(trackingConfigsCache.items);
|
|
let templateConfigs = $derived(templateConfigsCache.items);
|
|
let collections = $state<Record<string, any>[]>([]);
|
|
let showForm = $state(false);
|
|
let editing = $state<number | null>(null);
|
|
let collectionFilter = $state('');
|
|
let submitting = $state(false);
|
|
let confirmDelete = $state<Tracker | null>(null);
|
|
let toggling = $state<Record<number, boolean>>({});
|
|
let ttTesting = $state<Record<string, string>>({});
|
|
|
|
// Shared link validation
|
|
let linkWarning = $state<{ albums: { id: string; name: string; issue: string }[], providerId: number } | null>(null);
|
|
let linkCheckLoading = $state(false);
|
|
let linkCreating = $state(false);
|
|
let previousCollectionIds = $state<string[]>([]);
|
|
|
|
// Tracker form
|
|
const defaultForm = () => ({
|
|
name: '', icon: '', provider_id: 0, collection_ids: [] as string[],
|
|
scan_interval: 60,
|
|
adaptive_max_skip: null as number | null,
|
|
default_tracking_config_id: 0, default_template_config_id: 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
|
|
let expandedTracker = $state<number | null>(null);
|
|
let addingTarget = $state<Record<number, boolean>>({});
|
|
let newLinkTargetId = $state<Record<number, number>>({});
|
|
let newLinkTrackingConfigId = $state<Record<number, number>>({});
|
|
let newLinkTemplateConfigId = $state<Record<number, number>>({});
|
|
|
|
// Test menu
|
|
let testMenuOpen = $state<string | null>(null);
|
|
let testMenuStyle = $state('');
|
|
|
|
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
|
// that have those notification slots in their capabilities AND have the feature
|
|
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
|
// default config means cron dispatch won't fire it in production either — so
|
|
// the test button would just surface a silent skip.
|
|
const allTestTypes: Record<string, {
|
|
key: string; icon: string; labelKey: string;
|
|
requiredSlot?: string; enabledField?: string;
|
|
}> = {
|
|
basic: { key: 'basic', icon: 'mdiSend', labelKey: 'notificationTracker.testBasic' },
|
|
periodic: { key: 'periodic', icon: 'mdiCalendarClock', labelKey: 'notificationTracker.testPeriodic', requiredSlot: 'periodic_summary_message', enabledField: 'periodic_enabled' },
|
|
scheduled: { key: 'scheduled', icon: 'mdiCalendarCheck', labelKey: 'notificationTracker.testScheduled', requiredSlot: 'scheduled_assets_message', enabledField: 'scheduled_enabled' },
|
|
memory: { key: 'memory', icon: 'mdiHistory', labelKey: 'notificationTracker.testMemory', requiredSlot: 'memory_mode_message', enabledField: 'memory_enabled' },
|
|
};
|
|
|
|
let testMenuTrackerId = $state<number | null>(null);
|
|
let testTypes = $derived.by(() => {
|
|
const base: { key: string; icon: string; labelKey: string; disabledReason?: string }[] = [allTestTypes.basic];
|
|
if (!testMenuTrackerId) return base;
|
|
const tracker = notificationTrackers.find(t => t.id === testMenuTrackerId);
|
|
if (!tracker) return base;
|
|
const provider = providers.find(p => p.id === tracker.provider_id);
|
|
if (!provider) return base;
|
|
const caps = allCapabilities[provider.type];
|
|
if (!caps) return base;
|
|
const slotNames = new Set((caps.notification_slots || []).map((s: any) => s.name));
|
|
const defaultTc = trackingConfigs.find(c => c.id === tracker.default_tracking_config_id);
|
|
for (const tt of [allTestTypes.periodic, allTestTypes.scheduled, allTestTypes.memory]) {
|
|
if (!tt.requiredSlot || !slotNames.has(tt.requiredSlot)) continue;
|
|
const enabled = !!defaultTc && !!tt.enabledField && !!(defaultTc as any)[tt.enabledField];
|
|
base.push({
|
|
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
|
// When surfaced, the button still renders but is disabled and
|
|
// shows *why* — users who land here via the test menu without
|
|
// having toggled the feature on Tracking Config see a clear
|
|
// pointer to the missing setting instead of a silent failure.
|
|
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
|
});
|
|
}
|
|
return base;
|
|
});
|
|
|
|
onMount(load);
|
|
|
|
async function load() {
|
|
loadError = '';
|
|
try {
|
|
[allNotificationTrackers] = await Promise.all([
|
|
api<Tracker[]>('/notification-trackers'),
|
|
providersCache.fetch(), targetsCache.fetch(),
|
|
trackingConfigsCache.fetch(), templateConfigsCache.fetch(),
|
|
capabilitiesCache.fetch(),
|
|
]);
|
|
} catch (err: any) {
|
|
loadError = err.message || t('common.loadFailed');
|
|
snackError(loadError);
|
|
} finally { loaded = true; highlightFromUrl(); }
|
|
}
|
|
|
|
async function loadCollections() {
|
|
if (!form.provider_id) return;
|
|
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
|
}
|
|
|
|
let _prevProviderId = $state(0);
|
|
$effect(() => {
|
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
|
_prevProviderId = form.provider_id;
|
|
loadCollections();
|
|
// Auto-select first available tracking/template config for this provider when creating
|
|
if (editing === null) {
|
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
|
if (ptype) {
|
|
if (!form.default_tracking_config_id) {
|
|
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
|
if (first) form.default_tracking_config_id = first.id;
|
|
}
|
|
if (!form.default_template_config_id) {
|
|
const first = templateConfigs.find(c => c.provider_type === ptype);
|
|
if (first) form.default_template_config_id = first.id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function openNew() {
|
|
form = defaultForm();
|
|
// Auto-select first provider if any
|
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
|
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
|
}
|
|
|
|
async function edit(trk: Tracker) {
|
|
form = {
|
|
name: trk.name, icon: trk.icon || '', provider_id: trk.provider_id,
|
|
collection_ids: [...(trk.collection_ids || [])],
|
|
scan_interval: trk.scan_interval,
|
|
adaptive_max_skip: trk.adaptive_max_skip ?? null,
|
|
default_tracking_config_id: trk.default_tracking_config_id ?? 0,
|
|
default_template_config_id: trk.default_template_config_id ?? 0,
|
|
filters: trk.filters || {},
|
|
};
|
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
|
editing = trk.id; showForm = true;
|
|
if (form.provider_id) await loadCollections();
|
|
}
|
|
|
|
async function save(e: SubmitEvent) {
|
|
e.preventDefault(); error = '';
|
|
if (submitting) return;
|
|
|
|
// Delegate provider-specific pre-save checks to the descriptor
|
|
const desc = getDescriptor(selectedProviderType);
|
|
if (desc?.onBeforeSave && form.provider_id) {
|
|
linkCheckLoading = true;
|
|
try {
|
|
const result = await desc.onBeforeSave({
|
|
form, previousCollectionIds, collections, api,
|
|
});
|
|
if (!result.proceed) {
|
|
if (result.warnings?.length) {
|
|
linkWarning = { albums: result.warnings, providerId: form.provider_id };
|
|
}
|
|
linkCheckLoading = false;
|
|
return;
|
|
}
|
|
} catch (err) { console.warn('Pre-save check failed, proceeding:', err); }
|
|
linkCheckLoading = false;
|
|
}
|
|
|
|
await doSave();
|
|
}
|
|
|
|
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,
|
|
// Empty string, 0, or null all mean "disable adaptive polling".
|
|
// Coerce to null so the DB column stays NULL rather than 0.
|
|
adaptive_max_skip:
|
|
form.adaptive_max_skip && form.adaptive_max_skip > 1
|
|
? form.adaptive_max_skip
|
|
: null,
|
|
};
|
|
if (editing) {
|
|
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(payload) });
|
|
snackSuccess(t('snack.trackerCreated'));
|
|
}
|
|
showForm = false; editing = null; linkWarning = null; await load();
|
|
} catch (err: any) { error = err.message; snackError(err.message); } finally { submitting = false; }
|
|
}
|
|
|
|
async function autoCreateLinks() {
|
|
if (!linkWarning) return;
|
|
linkCreating = true;
|
|
let created = 0;
|
|
for (const album of linkWarning.albums) {
|
|
if (album.issue === 'missing') {
|
|
try {
|
|
await api(`/providers/${linkWarning.providerId}/albums/${album.id}/shared-links`, { method: 'POST' });
|
|
created++;
|
|
} catch (err: any) {
|
|
snackError(`Failed to create link for "${album.name}": ${err.message}`);
|
|
}
|
|
}
|
|
}
|
|
if (created > 0) snackSuccess(t('notificationTracker.createdLinks').replace('{count}', String(created)));
|
|
linkWarning = null;
|
|
linkCreating = false;
|
|
await doSave();
|
|
}
|
|
|
|
async function dismissLinkWarning() {
|
|
linkWarning = null;
|
|
await doSave();
|
|
}
|
|
|
|
async function toggle(tracker: Tracker) {
|
|
if (toggling[tracker.id]) return;
|
|
toggling = { ...toggling, [tracker.id]: true };
|
|
try {
|
|
await api(`/notification-trackers/${tracker.id}`, { method: 'PUT', body: JSON.stringify({ enabled: !tracker.enabled }) });
|
|
await load();
|
|
snackSuccess(tracker.enabled ? t('snack.trackerPaused') : t('snack.trackerResumed'));
|
|
} catch (err: any) { snackError(err.message); } finally { toggling = { ...toggling, [tracker.id]: false }; }
|
|
}
|
|
|
|
function startDelete(tracker: Tracker) { confirmDelete = tracker; }
|
|
|
|
async function doDelete() {
|
|
if (!confirmDelete) return;
|
|
try {
|
|
await api(`/notification-trackers/${confirmDelete.id}`, { method: 'DELETE' });
|
|
await load();
|
|
snackSuccess(t('snack.trackerDeleted'));
|
|
} catch (err: any) { error = err.message; snackError(err.message); }
|
|
confirmDelete = null;
|
|
}
|
|
|
|
function toggleCollection(collectionId: string) {
|
|
form.collection_ids = form.collection_ids.includes(collectionId)
|
|
? form.collection_ids.filter(id => id !== collectionId)
|
|
: [...form.collection_ids, collectionId];
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
if (!dateStr) return '';
|
|
try {
|
|
const d = parseDate(dateStr);
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
} catch (e) { console.warn('Date format error:', e); return ''; }
|
|
}
|
|
|
|
// --- Linked Targets helpers ---
|
|
function toggleExpand(trackerId: number) {
|
|
if (expandedTracker === trackerId) { expandedTracker = null; return; }
|
|
expandedTracker = trackerId;
|
|
}
|
|
|
|
function getProviderType(tracker: Tracker): string {
|
|
const p = providers.find(p => p.id === tracker.provider_id);
|
|
return p?.type || '';
|
|
}
|
|
|
|
function getProviderName(id: number): string {
|
|
const p = providers.find(p => p.id === id);
|
|
return p?.name || `#${id}`;
|
|
}
|
|
|
|
function getCollectionLabel(tracker: Tracker): string {
|
|
const pt = getProviderType(tracker);
|
|
const desc = getDescriptor(pt);
|
|
return desc?.collectionMeta ? t(desc.collectionMeta.countLabel) : t('notificationTracker.collections_count');
|
|
}
|
|
|
|
function configsForTracker(tracker: Tracker, configs: (TrackingConfig | TemplateConfig)[]): (TrackingConfig | TemplateConfig)[] {
|
|
const pt = getProviderType(tracker);
|
|
return pt ? configs.filter((c) => c.provider_type === pt) : configs;
|
|
}
|
|
|
|
function getUnlinkedTargets(tracker: Tracker): NotificationTarget[] {
|
|
const linkedIds = new Set((tracker.tracker_targets || []).map((tt: TrackerTarget) => tt.target_id));
|
|
return targets.filter(t => !linkedIds.has(t.id));
|
|
}
|
|
|
|
async function addTargetLink(trackerId: number) {
|
|
const targetId = newLinkTargetId[trackerId];
|
|
if (!targetId) return;
|
|
addingTarget = { ...addingTarget, [trackerId]: true };
|
|
try {
|
|
await api(`/notification-trackers/${trackerId}/targets`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
target_id: targetId,
|
|
tracking_config_id: newLinkTrackingConfigId[trackerId] || null,
|
|
template_config_id: newLinkTemplateConfigId[trackerId] || null,
|
|
}),
|
|
});
|
|
newLinkTargetId[trackerId] = 0;
|
|
newLinkTrackingConfigId[trackerId] = 0;
|
|
newLinkTemplateConfigId[trackerId] = 0;
|
|
await load();
|
|
snackSuccess(t('snack.targetLinked'));
|
|
} catch (err: any) { snackError(err.message); }
|
|
addingTarget = { ...addingTarget, [trackerId]: false };
|
|
}
|
|
|
|
async function removeTargetLink(trackerId: number, ttId: number) {
|
|
try {
|
|
await api(`/notification-trackers/${trackerId}/targets/${ttId}`, { method: 'DELETE' });
|
|
await load();
|
|
snackSuccess(t('snack.targetUnlinked'));
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
async function updateTargetLink(trackerId: number, tt: TrackerTarget, field: string, value: string | number | boolean | null) {
|
|
try {
|
|
await api(`/notification-trackers/${trackerId}/targets/${tt.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ [field]: value }),
|
|
});
|
|
await load();
|
|
} catch (err: any) { snackError(err.message); }
|
|
}
|
|
|
|
async function testTrackerTarget(trackerId: number, ttId: number, testType: string) {
|
|
testMenuOpen = null;
|
|
const key = `${ttId}_${testType}`;
|
|
if (ttTesting[key]) return;
|
|
ttTesting = { ...ttTesting, [key]: testType };
|
|
try {
|
|
// The endpoint returns 200 OK with ``{success: false, error: "..."}``
|
|
// on soft failures (missing template slot, no matching assets,
|
|
// provider unreachable, etc.), so checking for a thrown exception
|
|
// is not enough. Surface ``error`` as a snackError when present.
|
|
const res = await api<{ success?: boolean; error?: string; target?: string }>(
|
|
`/notification-trackers/${trackerId}/targets/${ttId}/test/${testType}?locale=${getLocale()}`,
|
|
{ method: 'POST' },
|
|
);
|
|
if (res && res.success === false) {
|
|
snackError(res.error || t('common.error'));
|
|
} else {
|
|
snackSuccess(t('snack.targetTestSent'));
|
|
}
|
|
} catch (err: any) {
|
|
snackError(err.message);
|
|
} finally {
|
|
ttTesting = { ...ttTesting, [key]: '' };
|
|
}
|
|
}
|
|
|
|
function openTestMenu(ttId: number, event: MouseEvent) {
|
|
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: TrackerTarget) => String(x.id) === String(ttId)))?.id ?? null;
|
|
testMenuOpen = testMenuOpen === String(ttId) ? null : String(ttId);
|
|
}
|
|
|
|
function handleTestFromMenu(ttId: number, testType: string) {
|
|
const trackerId = notificationTrackers.find(t => t.tracker_targets?.some((x: TrackerTarget) => String(x.id) === String(ttId)))?.id;
|
|
if (trackerId) testTrackerTarget(trackerId, ttId, testType);
|
|
}
|
|
</script>
|
|
|
|
<PageHeader title={t('notificationTracker.title')} description={t('notificationTracker.description')}>
|
|
<Button size="sm" onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}>
|
|
{showForm ? t('notificationTracker.cancel') : t('notificationTracker.newTracker')}
|
|
</Button>
|
|
</PageHeader>
|
|
|
|
{#if !loaded}
|
|
<Loading />
|
|
{:else if loadError}
|
|
<Card>
|
|
<ErrorBanner message={loadError} class="mb-0" />
|
|
</Card>
|
|
{:else if showForm}
|
|
<TrackerForm
|
|
bind:form
|
|
{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}
|
|
{error}
|
|
providerType={selectedProviderType}
|
|
onsave={save}
|
|
ontoggleCollection={toggleCollection}
|
|
{formatDate}
|
|
/>
|
|
{/if}
|
|
|
|
{#if loaded && !loadError}
|
|
|
|
{#if !showForm && allNotificationTrackers.length > 0}
|
|
<div class="flex items-center gap-2 mb-3">
|
|
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
|
|
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
|
|
{#if !globalProviderFilter.id}
|
|
<div class="w-48">
|
|
<EntitySelect items={[{value: 0, label: t('common.allProviders'), icon: 'mdiFilterOff'}, ...providerItems]} bind:value={filterProviderId} placeholder={t('common.allProviders')} />
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if allNotificationTrackers.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiRadar" message={t('notificationTracker.noTrackers')} />
|
|
</Card>
|
|
{:else if notificationTrackers.length === 0 && !showForm}
|
|
<Card>
|
|
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
|
|
</Card>
|
|
{:else if !showForm}
|
|
<div class="space-y-3 stagger-children">
|
|
{#each notificationTrackers as tracker (tracker.id)}
|
|
<Card hover entityId={tracker.id}>
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<div class="flex items-center gap-2">
|
|
<span style="color: var(--color-primary);"><MdiIcon name={tracker.icon || 'mdiRadar'} size={20} /></span>
|
|
<p class="font-medium">{tracker.name}</p>
|
|
<span class="text-xs px-1.5 py-0.5 rounded {tracker.enabled ? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]' : 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
|
|
{tracker.enabled ? t('notificationTracker.active') : t('notificationTracker.paused')}
|
|
</span>
|
|
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
|
</div>
|
|
<p class="text-sm text-[var(--color-muted-foreground)]">
|
|
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} · {t('notificationTracker.every')} {tracker.scan_interval}s · {(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
|
</p>
|
|
</div>
|
|
<div class="flex items-center gap-1 flex-wrap justify-end">
|
|
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(tracker)} />
|
|
<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">
|
|
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
|
</button>
|
|
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
|
</div>
|
|
</div>
|
|
|
|
{#if expandedTracker === tracker.id}
|
|
<LinkedTargetsSection
|
|
{tracker}
|
|
{trackingConfigs}
|
|
{templateConfigs}
|
|
unlinkedTargets={targets}
|
|
newLinkTargetId={newLinkTargetId[tracker.id] || 0}
|
|
newLinkTrackingConfigId={newLinkTrackingConfigId[tracker.id] || 0}
|
|
newLinkTemplateConfigId={newLinkTemplateConfigId[tracker.id] || 0}
|
|
addingTarget={addingTarget[tracker.id] || false}
|
|
{ttTesting}
|
|
configsForTracker={(configs) => configsForTracker(tracker, configs)}
|
|
onupdateLink={(tt, field, value) => updateTargetLink(tracker.id, tt, field, value)}
|
|
onremoveLink={(ttId) => removeTargetLink(tracker.id, ttId)}
|
|
onaddLink={() => addTargetLink(tracker.id)}
|
|
onopenTestMenu={openTestMenu}
|
|
onchangeNewTarget={(v) => newLinkTargetId = { ...newLinkTargetId, [tracker.id]: v }}
|
|
onchangeNewTrackingConfig={(v) => newLinkTrackingConfigId = { ...newLinkTrackingConfigId, [tracker.id]: v }}
|
|
onchangeNewTemplateConfig={(v) => newLinkTemplateConfigId = { ...newLinkTemplateConfigId, [tracker.id]: v }}
|
|
/>
|
|
{/if}
|
|
</Card>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
<TestMenu
|
|
{testMenuOpen}
|
|
{testMenuStyle}
|
|
{ttTesting}
|
|
testTypes={testTypes}
|
|
ontest={handleTestFromMenu}
|
|
onclose={() => testMenuOpen = null}
|
|
/>
|
|
|
|
<SharedLinkModal
|
|
{linkWarning}
|
|
{linkCreating}
|
|
onclose={() => { linkWarning = null; }}
|
|
onautoCreate={autoCreateLinks}
|
|
ondismiss={dismissLinkWarning}
|
|
onupdate={(remaining) => {
|
|
if (!linkWarning) return;
|
|
if (remaining.length === 0) {
|
|
linkWarning = null;
|
|
doSave();
|
|
} else {
|
|
linkWarning = { ...linkWarning, albums: remaining };
|
|
}
|
|
}}
|
|
/>
|
|
|
|
<ConfirmModal
|
|
open={!!confirmDelete}
|
|
message={t('notificationTracker.confirmDelete')}
|
|
onconfirm={doDelete}
|
|
oncancel={() => confirmDelete = null}
|
|
/>
|