feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability
- Gitea: NotificationTracker now exposes sender allowlist / blocklist filters via MultiEntitySelect, populated from Gitea /users/search merged with past EventLog senders so the picker is useful before the first webhook arrives. - Webhook providers (gitea, planka, webhook): stop scheduling interval polling jobs on tracker create/update/startup; hide the "every Xs" indicator in the tracker list since there is no polling. - Dashboard: stat cards are now <a> links that route to providers, trackers, targets, command-trackers, or scroll to the events panel. Provider deck rows highlight the target provider on click. - Command trackers / command configs: auto-reselect the right config when the provider type changes (matches notification-tracker behavior). - Migration: drop legacy batch_duration column from notification_tracker — the field is gone from the model but its NOT NULL constraint blocked inserts on older DBs. - Docs: refresh entity-relationships.md with current NotificationTracker fields (filters, adaptive_max_skip, default_*_config_id).
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Entity Relationships
|
# Entity Relationships
|
||||||
|
|
||||||
```
|
```text
|
||||||
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
ServiceProvider → type: "immich" (inferred capabilities: notifications, commands)
|
||||||
NotificationTracker → provider_id, collection_ids, scan_interval, batch_duration, enabled
|
NotificationTracker → provider_id, collection_ids, scan_interval, adaptive_max_skip, filters, default_tracking_config_id, default_template_config_id, enabled
|
||||||
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
NotificationTrackerTarget → notification_tracker_id, target_id, tracking_config_id, template_config_id, quiet_hours, enabled
|
||||||
TrackingConfig → provider_type, event flags, scheduling rules
|
TrackingConfig → provider_type, event flags, scheduling rules
|
||||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||||
|
|||||||
@@ -246,6 +246,9 @@
|
|||||||
"selectAlbums": "Select albums...",
|
"selectAlbums": "Select albums...",
|
||||||
"repositories": "Repositories",
|
"repositories": "Repositories",
|
||||||
"selectRepositories": "Select repositories...",
|
"selectRepositories": "Select repositories...",
|
||||||
|
"userAllowlist": "Only from users",
|
||||||
|
"userBlocklist": "Exclude users",
|
||||||
|
"selectUsers": "Pick users...",
|
||||||
"boards": "Boards",
|
"boards": "Boards",
|
||||||
"selectBoards": "Select boards...",
|
"selectBoards": "Select boards...",
|
||||||
"upsDevices": "UPS Devices",
|
"upsDevices": "UPS Devices",
|
||||||
|
|||||||
@@ -246,6 +246,9 @@
|
|||||||
"selectAlbums": "Выберите альбомы...",
|
"selectAlbums": "Выберите альбомы...",
|
||||||
"repositories": "Репозитории",
|
"repositories": "Репозитории",
|
||||||
"selectRepositories": "Выберите репозитории...",
|
"selectRepositories": "Выберите репозитории...",
|
||||||
|
"userAllowlist": "Только от пользователей",
|
||||||
|
"userBlocklist": "Исключить пользователей",
|
||||||
|
"selectUsers": "Выберите пользователей...",
|
||||||
"boards": "Доски",
|
"boards": "Доски",
|
||||||
"selectBoards": "Выберите доски...",
|
"selectBoards": "Выберите доски...",
|
||||||
"upsDevices": "ИБП устройства",
|
"upsDevices": "ИБП устройства",
|
||||||
|
|||||||
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
|
|||||||
desc: () => '',
|
desc: () => '',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
userFilters: [
|
||||||
|
{
|
||||||
|
key: 'senders',
|
||||||
|
label: 'notificationTracker.userAllowlist',
|
||||||
|
placeholder: 'notificationTracker.selectUsers',
|
||||||
|
icon: 'mdiAccountCheck',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'exclude_senders',
|
||||||
|
label: 'notificationTracker.userBlocklist',
|
||||||
|
placeholder: 'notificationTracker.selectUsers',
|
||||||
|
icon: 'mdiAccountOff',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
webhookUrlPattern: '/api/webhooks/gitea/{token}',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -120,6 +120,25 @@ export interface CollectionMeta {
|
|||||||
desc: (col: any) => string;
|
desc: (col: any) => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── User-identity filters (TrackerForm) ──────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declares a filter that picks user identities from the provider's known
|
||||||
|
* senders. Rendered as a MultiEntitySelect populated from the provider's
|
||||||
|
* `/users` endpoint. The picked values are stored as `string[]` under
|
||||||
|
* `tracker.filters[key]`.
|
||||||
|
*/
|
||||||
|
export interface UserFilterMeta {
|
||||||
|
/** Filter key inside `tracker.filters` (e.g. "senders", "exclude_senders"). */
|
||||||
|
key: string;
|
||||||
|
/** i18n key for the label rendered above the picker. */
|
||||||
|
label: string;
|
||||||
|
/** i18n key for the picker placeholder. */
|
||||||
|
placeholder: string;
|
||||||
|
/** MDI icon shown on chips and dropdown rows. */
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main descriptor ──────────────────────────────────────────────────
|
// ── Main descriptor ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ProviderDescriptor {
|
export interface ProviderDescriptor {
|
||||||
@@ -153,6 +172,8 @@ export interface ProviderDescriptor {
|
|||||||
// ── Collections / Trackers ──
|
// ── Collections / Trackers ──
|
||||||
/** Null means this provider has no collections (e.g. scheduler). */
|
/** Null means this provider has no collections (e.g. scheduler). */
|
||||||
collectionMeta: CollectionMeta | null;
|
collectionMeta: CollectionMeta | null;
|
||||||
|
/** Sender allowlist / blocklist pickers shown on the tracker form. */
|
||||||
|
userFilters?: UserFilterMeta[];
|
||||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||||
webhookBased?: boolean;
|
webhookBased?: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { api, parseDate } from '$lib/api';
|
import { api, parseDate } from '$lib/api';
|
||||||
|
import { requestHighlight } from '$lib/highlight';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import {
|
import {
|
||||||
providersCache,
|
providersCache,
|
||||||
@@ -320,6 +322,19 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function scrollToEvents(e: MouseEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.getElementById('events-section');
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function gotoProvider(e: MouseEvent, providerId: number) {
|
||||||
|
e.preventDefault();
|
||||||
|
requestHighlight(providerId);
|
||||||
|
goto('/providers');
|
||||||
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const diff = Date.now() - parseDate(dateStr).getTime();
|
const diff = Date.now() - parseDate(dateStr).getTime();
|
||||||
const mins = Math.floor(diff / 60000);
|
const mins = Math.floor(diff / 60000);
|
||||||
@@ -424,8 +439,8 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==================== STATS ==================== -->
|
<!-- ==================== STATS ==================== -->
|
||||||
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
|
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string; href: string; onclick?: (e: MouseEvent) => void}, idx: number)}
|
||||||
<div class="stat-card" style="--accent: {card.accent}">
|
<a class="stat-card" style="--accent: {card.accent}" href={card.href} onclick={card.onclick}>
|
||||||
<div class="stat-card-inner">
|
<div class="stat-card-inner">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="stat-icon" style="color: {card.accent};">
|
<div class="stat-icon" style="color: {card.accent};">
|
||||||
@@ -439,7 +454,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet statCards()}
|
{#snippet statCards()}
|
||||||
@@ -452,6 +467,7 @@
|
|||||||
value: 0,
|
value: 0,
|
||||||
literalValue: globalProviderFilter.provider.name,
|
literalValue: globalProviderFilter.provider.name,
|
||||||
accent: STAT_ACCENTS[0],
|
accent: STAT_ACCENTS[0],
|
||||||
|
href: '/providers',
|
||||||
}, 0)}
|
}, 0)}
|
||||||
{:else}
|
{:else}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -459,6 +475,7 @@
|
|||||||
label: 'dashboard.providers',
|
label: 'dashboard.providers',
|
||||||
value: filteredProviderCount,
|
value: filteredProviderCount,
|
||||||
accent: STAT_ACCENTS[0],
|
accent: STAT_ACCENTS[0],
|
||||||
|
href: '/providers',
|
||||||
}, 0)}
|
}, 0)}
|
||||||
{/if}
|
{/if}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -467,12 +484,14 @@
|
|||||||
value: displayActive,
|
value: displayActive,
|
||||||
suffix: ` / ${displayTotal}`,
|
suffix: ` / ${displayTotal}`,
|
||||||
accent: STAT_ACCENTS[1],
|
accent: STAT_ACCENTS[1],
|
||||||
|
href: '/notification-trackers',
|
||||||
}, 1)}
|
}, 1)}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
icon: 'mdiTarget',
|
icon: 'mdiTarget',
|
||||||
label: 'dashboard.targets',
|
label: 'dashboard.targets',
|
||||||
value: displayTargets,
|
value: displayTargets,
|
||||||
accent: STAT_ACCENTS[2],
|
accent: STAT_ACCENTS[2],
|
||||||
|
href: '/targets',
|
||||||
}, 2)}
|
}, 2)}
|
||||||
{#if status?.command_trackers !== undefined}
|
{#if status?.command_trackers !== undefined}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -480,6 +499,7 @@
|
|||||||
label: 'nav.commandTrackers',
|
label: 'nav.commandTrackers',
|
||||||
value: displayCommandTrackers,
|
value: displayCommandTrackers,
|
||||||
accent: STAT_ACCENTS[3],
|
accent: STAT_ACCENTS[3],
|
||||||
|
href: '/command-trackers',
|
||||||
}, 3)}
|
}, 3)}
|
||||||
{:else}
|
{:else}
|
||||||
{@render statCardSnippet({
|
{@render statCardSnippet({
|
||||||
@@ -487,6 +507,8 @@
|
|||||||
label: 'dashboard.eventsTotal',
|
label: 'dashboard.eventsTotal',
|
||||||
value: heroSummary?.throughput ?? 0,
|
value: heroSummary?.throughput ?? 0,
|
||||||
accent: STAT_ACCENTS[3],
|
accent: STAT_ACCENTS[3],
|
||||||
|
href: '#events-section',
|
||||||
|
onclick: scrollToEvents,
|
||||||
}, 3)}
|
}, 3)}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -496,7 +518,7 @@
|
|||||||
<!-- ==================== TWO COL: stream + provider deck ==================== -->
|
<!-- ==================== TWO COL: stream + provider deck ==================== -->
|
||||||
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
|
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
|
||||||
<!-- Signal stream -->
|
<!-- Signal stream -->
|
||||||
<section class="panel">
|
<section class="panel" id="events-section">
|
||||||
<header class="panel-head">
|
<header class="panel-head">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
|
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
|
||||||
@@ -646,14 +668,14 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="provider-deck">
|
<div class="provider-deck">
|
||||||
{#each providerDeck as p}
|
{#each providerDeck as p}
|
||||||
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}">
|
<a href="/providers" class="provider-row" style="--accent: {STAT_ACCENTS[p.id % STAT_ACCENTS.length]}" onclick={(e) => gotoProvider(e, p.id)}>
|
||||||
<div class="provider-icon">
|
<div class="provider-icon">
|
||||||
<MdiIcon name={p.icon} size={20} />
|
<MdiIcon name={p.icon} size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="provider-name truncate">
|
<div class="provider-name">
|
||||||
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
|
{#if p.armedCount > 0}<span class="aurora-pulse"></span>{:else}<span class="aurora-pulse idle"></span>{/if}
|
||||||
{p.name}
|
<span class="truncate min-w-0">{p.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="provider-sub font-mono">
|
<div class="provider-sub font-mono">
|
||||||
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
|
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
|
||||||
@@ -909,6 +931,7 @@
|
|||||||
============================================================ */
|
============================================================ */
|
||||||
.stat-card {
|
.stat-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
display: block;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
background: var(--color-glass);
|
background: var(--color-glass);
|
||||||
backdrop-filter: blur(28px) saturate(160%);
|
backdrop-filter: blur(28px) saturate(160%);
|
||||||
@@ -918,7 +941,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
|
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
.stat-card:hover { text-decoration: none; }
|
||||||
.stat-card::before {
|
.stat-card::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -1282,6 +1308,7 @@
|
|||||||
.provider-meter {
|
.provider-meter {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
|
padding: 4px 4px 4px 0;
|
||||||
}
|
}
|
||||||
.provider-num {
|
.provider-num {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -1295,7 +1322,6 @@
|
|||||||
height: 4px;
|
height: 4px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
background: var(--color-glass-strong);
|
background: var(--color-glass-strong);
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
.provider-bar-fill {
|
.provider-bar-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -110,6 +110,27 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pick the command-template config when the provider type changes.
|
||||||
|
// The previously-selected id may belong to a different provider type and
|
||||||
|
// would no longer appear in the filtered EntitySelect, leaving it empty.
|
||||||
|
let _prevProviderType = $state('');
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && form.provider_type && form.provider_type !== _prevProviderType) {
|
||||||
|
_prevProviderType = form.provider_type;
|
||||||
|
if (editing === null) {
|
||||||
|
const currentTpl = cmdTemplateConfigs.find(
|
||||||
|
(c) => c.id === form.command_template_config_id,
|
||||||
|
);
|
||||||
|
if (!currentTpl || currentTpl.provider_type !== form.provider_type) {
|
||||||
|
const first = cmdTemplateConfigs.find(
|
||||||
|
(c) => c.provider_type === form.provider_type,
|
||||||
|
);
|
||||||
|
form.command_template_config_id = first?.id ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
function editConfig(cfg: CommandConfig) {
|
function editConfig(cfg: CommandConfig) {
|
||||||
form = {
|
form = {
|
||||||
name: cfg.name,
|
name: cfg.name,
|
||||||
|
|||||||
@@ -113,6 +113,26 @@
|
|||||||
editing = null;
|
editing = null;
|
||||||
showForm = true;
|
showForm = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-pick the command config when the provider changes. The previously
|
||||||
|
// selected id may belong to a different provider type and would no longer
|
||||||
|
// appear in the filtered EntitySelect, leaving the selector empty.
|
||||||
|
let _prevProviderId = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
|
_prevProviderId = form.provider_id;
|
||||||
|
if (editing === null) {
|
||||||
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
|
if (ptype) {
|
||||||
|
const currentCfg = commandConfigs.find(c => c.id === form.command_config_id);
|
||||||
|
if (!currentCfg || currentCfg.provider_type !== ptype) {
|
||||||
|
const first = commandConfigs.find(c => c.provider_type === ptype);
|
||||||
|
form.command_config_id = first?.id ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
function editTracker(trk: any) {
|
function editTracker(trk: any) {
|
||||||
form = {
|
form = {
|
||||||
name: trk.name,
|
name: trk.name,
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
let trackingConfigs = $derived(trackingConfigsCache.items);
|
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||||
let templateConfigs = $derived(templateConfigsCache.items);
|
let templateConfigs = $derived(templateConfigsCache.items);
|
||||||
let collections = $state<Record<string, any>[]>([]);
|
let collections = $state<Record<string, any>[]>([]);
|
||||||
|
let users = $state<{ id: string; name: string }[]>([]);
|
||||||
let showForm = $state(false);
|
let showForm = $state(false);
|
||||||
let editing = $state<number | null>(null);
|
let editing = $state<number | null>(null);
|
||||||
let collectionFilter = $state('');
|
let collectionFilter = $state('');
|
||||||
@@ -167,22 +168,38 @@
|
|||||||
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
try { collections = await api(`/providers/${form.provider_id}/collections`); } catch (e) { console.warn('Failed to load collections:', e); collections = []; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
if (!form.provider_id) { users = []; return; }
|
||||||
|
// Skip the fetch when the descriptor has no user filters — saves a
|
||||||
|
// pointless round-trip for providers like Immich/Scheduler.
|
||||||
|
const desc = getDescriptor(selectedProviderType);
|
||||||
|
if (!desc?.userFilters || desc.userFilters.length === 0) { users = []; return; }
|
||||||
|
try { users = await api(`/providers/${form.provider_id}/users`); }
|
||||||
|
catch (e) { console.warn('Failed to load users:', e); users = []; }
|
||||||
|
}
|
||||||
|
|
||||||
let _prevProviderId = $state(0);
|
let _prevProviderId = $state(0);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
if (showForm && form.provider_id && form.provider_id !== _prevProviderId) {
|
||||||
_prevProviderId = form.provider_id;
|
_prevProviderId = form.provider_id;
|
||||||
loadCollections();
|
loadCollections();
|
||||||
// Auto-select first available tracking/template config for this provider when creating
|
loadUsers();
|
||||||
|
// Re-pick tracking/template configs for the new provider type. The
|
||||||
|
// previously-selected ids may belong to a different provider type
|
||||||
|
// and therefore no longer appear in the filtered EntitySelect list,
|
||||||
|
// which would render the selector as empty.
|
||||||
if (editing === null) {
|
if (editing === null) {
|
||||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||||
if (ptype) {
|
if (ptype) {
|
||||||
if (!form.default_tracking_config_id) {
|
const currentTc = trackingConfigs.find(c => c.id === form.default_tracking_config_id);
|
||||||
|
if (!currentTc || currentTc.provider_type !== ptype) {
|
||||||
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
const first = trackingConfigs.find(c => c.provider_type === ptype);
|
||||||
if (first) form.default_tracking_config_id = first.id;
|
form.default_tracking_config_id = first?.id ?? 0;
|
||||||
}
|
}
|
||||||
if (!form.default_template_config_id) {
|
const currentTpl = templateConfigs.find(c => c.id === form.default_template_config_id);
|
||||||
|
if (!currentTpl || currentTpl.provider_type !== ptype) {
|
||||||
const first = templateConfigs.find(c => c.provider_type === ptype);
|
const first = templateConfigs.find(c => c.provider_type === ptype);
|
||||||
if (first) form.default_template_config_id = first.id;
|
form.default_template_config_id = first?.id ?? 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,7 +210,7 @@
|
|||||||
form = defaultForm();
|
form = defaultForm();
|
||||||
// Auto-select first provider if any
|
// Auto-select first provider if any
|
||||||
if (providers.length > 0) form.provider_id = providers[0].id;
|
if (providers.length > 0) form.provider_id = providers[0].id;
|
||||||
editing = null; showForm = true; collections = []; previousCollectionIds = [];
|
editing = null; showForm = true; collections = []; users = []; previousCollectionIds = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function edit(trk: Tracker) {
|
async function edit(trk: Tracker) {
|
||||||
@@ -208,7 +225,9 @@
|
|||||||
};
|
};
|
||||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||||
editing = trk.id; showForm = true;
|
editing = trk.id; showForm = true;
|
||||||
if (form.provider_id) await loadCollections();
|
if (form.provider_id) {
|
||||||
|
await Promise.all([loadCollections(), loadUsers()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(e: SubmitEvent) {
|
async function save(e: SubmitEvent) {
|
||||||
@@ -460,6 +479,7 @@
|
|||||||
bind:form
|
bind:form
|
||||||
{providerItems}
|
{providerItems}
|
||||||
{collections}
|
{collections}
|
||||||
|
{users}
|
||||||
bind:collectionFilter
|
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' }))}
|
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' }))}
|
templateConfigItems={templateConfigs.filter(c => !selectedProviderType || c.provider_type === selectedProviderType).map(c => ({ value: c.id, label: c.name, icon: (c as any).icon || 'mdiFileDocumentEdit' }))}
|
||||||
@@ -499,6 +519,7 @@
|
|||||||
{:else if !showForm}
|
{:else if !showForm}
|
||||||
<div class="space-y-3 stagger-children">
|
<div class="space-y-3 stagger-children">
|
||||||
{#each notificationTrackers as tracker (tracker.id)}
|
{#each notificationTrackers as tracker (tracker.id)}
|
||||||
|
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||||
<Card hover entityId={tracker.id}>
|
<Card hover entityId={tracker.id}>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -511,7 +532,9 @@
|
|||||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
<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')}
|
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} ·
|
||||||
|
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s ·{/if}
|
||||||
|
{(tracker.tracker_targets || []).length} {t('notificationTracker.linkedTargets')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
};
|
};
|
||||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||||
collections: any[];
|
collections: any[];
|
||||||
|
users?: { id: string; name: string }[];
|
||||||
collectionFilter?: string;
|
collectionFilter?: string;
|
||||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
form = $bindable(),
|
form = $bindable(),
|
||||||
providerItems,
|
providerItems,
|
||||||
collections,
|
collections,
|
||||||
|
users = [],
|
||||||
collectionFilter = $bindable(),
|
collectionFilter = $bindable(),
|
||||||
trackingConfigItems = [],
|
trackingConfigItems = [],
|
||||||
templateConfigItems = [],
|
templateConfigItems = [],
|
||||||
@@ -116,6 +118,21 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if descriptor?.userFilters && descriptor.userFilters.length > 0}
|
||||||
|
{@const userItems = users.map(u => ({ value: u.id, label: u.name }))}
|
||||||
|
{#each descriptor.userFilters as uf (uf.key)}
|
||||||
|
<div>
|
||||||
|
<div class="block text-sm font-medium mb-1">{t(uf.label)}</div>
|
||||||
|
<MultiEntitySelect
|
||||||
|
items={userItems.map(i => ({ ...i, icon: uf.icon }))}
|
||||||
|
values={form.filters[uf.key] || []}
|
||||||
|
onchange={(vals) => form.filters = { ...form.filters, [uf.key]: vals }}
|
||||||
|
placeholder={t(uf.placeholder)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if isScheduler}
|
{#if isScheduler}
|
||||||
<!-- Schedule type -->
|
<!-- Schedule type -->
|
||||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||||
|
|||||||
@@ -150,6 +150,40 @@ class GiteaClient:
|
|||||||
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def get_users(self, limit: int = 200) -> list[dict[str, Any]]:
|
||||||
|
"""List users known to the Gitea instance via /users/search.
|
||||||
|
|
||||||
|
``/users/search`` with an empty ``q`` returns all users the
|
||||||
|
authenticated token can see, paginated. We cap at ``limit`` to avoid
|
||||||
|
unbounded memory on large instances; the picker only needs enough to
|
||||||
|
cover senders that may appear in webhook payloads.
|
||||||
|
"""
|
||||||
|
users: list[dict[str, Any]] = []
|
||||||
|
page = 1
|
||||||
|
per_page = min(50, limit)
|
||||||
|
while len(users) < limit:
|
||||||
|
try:
|
||||||
|
async with self._session.get(
|
||||||
|
f"{self._url}/api/v1/users/search",
|
||||||
|
headers=self._headers,
|
||||||
|
params={"page": str(page), "limit": str(per_page)},
|
||||||
|
) as response:
|
||||||
|
if response.status != 200:
|
||||||
|
_LOGGER.warning("Failed to fetch users: HTTP %s", response.status)
|
||||||
|
break
|
||||||
|
body = await response.json()
|
||||||
|
items = body.get("data", []) if isinstance(body, dict) else body
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
users.extend(items)
|
||||||
|
if len(items) < per_page:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
except aiohttp.ClientError as err:
|
||||||
|
_LOGGER.warning("Failed to fetch users: %s", err)
|
||||||
|
break
|
||||||
|
return users[:limit]
|
||||||
|
|
||||||
|
|
||||||
class GiteaApiError(Exception):
|
class GiteaApiError(Exception):
|
||||||
"""Raised when a Gitea API call fails."""
|
"""Raised when a Gitea API call fails."""
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import aiohttp
|
|||||||
|
|
||||||
from ..auth.dependencies import get_current_user
|
from ..auth.dependencies import get_current_user
|
||||||
from ..database.engine import get_session
|
from ..database.engine import get_session
|
||||||
from ..database.models import ServiceProvider, User
|
from ..database.models import EventLog, ServiceProvider, User
|
||||||
from ..services import (
|
from ..services import (
|
||||||
make_immich_provider, make_gitea_provider, make_planka_provider,
|
make_immich_provider, make_gitea_provider, make_planka_provider,
|
||||||
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
||||||
@@ -398,6 +398,62 @@ async def list_collections(
|
|||||||
return await list_provider_collections(provider)
|
return await list_provider_collections(provider)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider_id}/users")
|
||||||
|
async def list_provider_users(
|
||||||
|
provider_id: int,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
"""Return user identities for sender allowlist/blocklist pickers.
|
||||||
|
|
||||||
|
Two sources are merged so the picker is useful both before and after the
|
||||||
|
first webhook arrives:
|
||||||
|
|
||||||
|
- **Provider API** (primary): Gitea's ``/users/search`` returns instance
|
||||||
|
users the api_token can see. Skipped when no api_token is set.
|
||||||
|
- **Past senders** (fallback): distinct ``sender`` values from
|
||||||
|
``EventLog.details`` for this provider, so pre-existing trackers stay
|
||||||
|
filterable even if the API call fails or is unconfigured.
|
||||||
|
"""
|
||||||
|
provider = await _get_user_provider(session, provider_id, user.id)
|
||||||
|
|
||||||
|
users_by_id: dict[str, str] = {}
|
||||||
|
|
||||||
|
# 1. Try the provider API.
|
||||||
|
if provider.type == "gitea" and (provider.config or {}).get("api_token"):
|
||||||
|
from notify_bridge_core.providers.gitea.client import GiteaClient
|
||||||
|
http_session = await get_http_session()
|
||||||
|
client = GiteaClient(
|
||||||
|
http_session,
|
||||||
|
provider.config.get("url", ""),
|
||||||
|
provider.config.get("api_token", ""),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
for u in await client.get_users():
|
||||||
|
login = u.get("login", "")
|
||||||
|
if isinstance(login, str) and login:
|
||||||
|
users_by_id[login] = u.get("full_name") or login
|
||||||
|
except Exception:
|
||||||
|
_LOGGER.warning("Failed to fetch Gitea users via API", exc_info=True)
|
||||||
|
|
||||||
|
# 2. Merge in past senders (covers users not visible to the API token, or
|
||||||
|
# cases where the API call fails).
|
||||||
|
result = await session.exec(
|
||||||
|
select(EventLog.details).where(EventLog.provider_id == provider.id)
|
||||||
|
)
|
||||||
|
for details in result.all():
|
||||||
|
if not isinstance(details, dict):
|
||||||
|
continue
|
||||||
|
sender = details.get("sender", "")
|
||||||
|
if isinstance(sender, str) and sender and sender not in users_by_id:
|
||||||
|
users_by_id[sender] = sender
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"id": login, "name": name}
|
||||||
|
for login, name in sorted(users_by_id.items(), key=lambda kv: kv[0].lower())
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
@router.get("/{provider_id}/albums/{album_id}/shared-links")
|
||||||
async def get_album_shared_links(
|
async def get_album_shared_links(
|
||||||
provider_id: int,
|
provider_id: int,
|
||||||
|
|||||||
@@ -197,6 +197,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
|||||||
)
|
)
|
||||||
logger.info("Added filters column to %s table", tracker_table)
|
logger.info("Added filters column to %s table", tracker_table)
|
||||||
|
|
||||||
|
# Drop legacy batch_duration column from notification_tracker.
|
||||||
|
# The field was removed from the SQLModel class but the column still
|
||||||
|
# exists as NOT NULL in older DBs, so INSERTs from the new code fail
|
||||||
|
# with "NOT NULL constraint failed: notification_tracker.batch_duration".
|
||||||
|
if await _has_table(conn, tracker_table):
|
||||||
|
if await _has_column(conn, tracker_table, "batch_duration"):
|
||||||
|
_assert_ident(tracker_table, "table")
|
||||||
|
await conn.execute(
|
||||||
|
text(f"ALTER TABLE {tracker_table} DROP COLUMN batch_duration")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Dropped legacy batch_duration column from %s table",
|
||||||
|
tracker_table,
|
||||||
|
)
|
||||||
|
|
||||||
# Add Gitea tracking flags to tracking_config if missing
|
# Add Gitea tracking flags to tracking_config if missing
|
||||||
if await _has_table(conn, "tracking_config"):
|
if await _has_table(conn, "tracking_config"):
|
||||||
gitea_flags = [
|
gitea_flags = [
|
||||||
|
|||||||
@@ -378,6 +378,8 @@ async def _load_tracker_jobs() -> None:
|
|||||||
|
|
||||||
tz = await _load_app_timezone()
|
tz = await _load_app_timezone()
|
||||||
|
|
||||||
|
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||||
|
|
||||||
for tracker in trackers:
|
for tracker in trackers:
|
||||||
job_id = f"tracker_{tracker.id}"
|
job_id = f"tracker_{tracker.id}"
|
||||||
if scheduler.get_job(job_id):
|
if scheduler.get_job(job_id):
|
||||||
@@ -386,6 +388,18 @@ async def _load_tracker_jobs() -> None:
|
|||||||
ptype = provider_types.get(tracker.provider_id, "")
|
ptype = provider_types.get(tracker.provider_id, "")
|
||||||
filters = tracker.filters or {}
|
filters = tracker.filters or {}
|
||||||
|
|
||||||
|
# Webhook-based providers receive events via inbound HTTP — there is
|
||||||
|
# nothing to poll. Scheduling an interval job for them just wakes up
|
||||||
|
# check_tracker every scan_interval seconds to immediately return,
|
||||||
|
# wasting CPU and DB queries for no work.
|
||||||
|
caps = get_capabilities(ptype) if ptype else None
|
||||||
|
if caps and caps.webhook_based:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping interval scheduling for webhook tracker %d (%s, type=%s)",
|
||||||
|
tracker.id, tracker.name, ptype,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Scheduler providers can use cron triggers
|
# Scheduler providers can use cron triggers
|
||||||
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
|
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
|
||||||
cron_expr = filters.get("cron_expression", "")
|
cron_expr = filters.get("cron_expression", "")
|
||||||
@@ -450,6 +464,29 @@ def _add_cron_job(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _is_webhook_tracker(tracker_id: int) -> bool:
|
||||||
|
"""Return True iff the tracker's provider type is webhook-based.
|
||||||
|
|
||||||
|
Looks up provider type once via the capabilities registry. Used by
|
||||||
|
``schedule_tracker`` to short-circuit interval scheduling.
|
||||||
|
"""
|
||||||
|
from sqlmodel import select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||||
|
from ..database.engine import get_engine
|
||||||
|
from ..database.models import NotificationTracker, ServiceProvider as ServiceProviderModel
|
||||||
|
|
||||||
|
async with AsyncSession(get_engine()) as session:
|
||||||
|
tracker = await session.get(NotificationTracker, tracker_id)
|
||||||
|
if tracker is None:
|
||||||
|
return False
|
||||||
|
provider = await session.get(ServiceProviderModel, tracker.provider_id)
|
||||||
|
if provider is None:
|
||||||
|
return False
|
||||||
|
caps = get_capabilities(provider.type)
|
||||||
|
return bool(caps and caps.webhook_based)
|
||||||
|
|
||||||
|
|
||||||
async def schedule_tracker(
|
async def schedule_tracker(
|
||||||
tracker_id: int,
|
tracker_id: int,
|
||||||
interval: int,
|
interval: int,
|
||||||
@@ -461,6 +498,10 @@ async def schedule_tracker(
|
|||||||
``adaptive_max_skip`` mirrors the DB column and is registered with the
|
``adaptive_max_skip`` mirrors the DB column and is registered with the
|
||||||
adaptive module-state so tick-time skip decisions don't re-query the DB.
|
adaptive module-state so tick-time skip decisions don't re-query the DB.
|
||||||
Pass ``None`` or ``0`` to disable back-off for the tracker.
|
Pass ``None`` or ``0`` to disable back-off for the tracker.
|
||||||
|
|
||||||
|
Webhook-based providers receive events via inbound HTTP and have nothing
|
||||||
|
to poll, so this no-ops for them — preventing scan_interval from creating
|
||||||
|
useless wakeups via the API create/update path.
|
||||||
"""
|
"""
|
||||||
scheduler = get_scheduler()
|
scheduler = get_scheduler()
|
||||||
job_id = f"tracker_{tracker_id}"
|
job_id = f"tracker_{tracker_id}"
|
||||||
@@ -474,6 +515,13 @@ async def schedule_tracker(
|
|||||||
if scheduler.get_job(job_id):
|
if scheduler.get_job(job_id):
|
||||||
scheduler.remove_job(job_id)
|
scheduler.remove_job(job_id)
|
||||||
|
|
||||||
|
# Webhook-based providers don't poll — skip job creation entirely.
|
||||||
|
if await _is_webhook_tracker(tracker_id):
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Skipping interval scheduling for webhook tracker %d", tracker_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if cron_expression:
|
if cron_expression:
|
||||||
try:
|
try:
|
||||||
tz = await _load_app_timezone()
|
tz = await _load_app_timezone()
|
||||||
|
|||||||
Reference in New Issue
Block a user