Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e675c4b38 | |||
| 4307955163 | |||
| b107b01a00 | |||
| 42af7a6551 |
@@ -1,8 +1,8 @@
|
||||
# Entity Relationships
|
||||
|
||||
```
|
||||
```text
|
||||
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
|
||||
TrackingConfig → provider_type, event flags, scheduling rules
|
||||
TemplateConfig → provider_type, Jinja2 template slots per event type
|
||||
|
||||
+35
-8
@@ -1,18 +1,45 @@
|
||||
# v0.6.2 (2026-04-27)
|
||||
# v0.6.3 (2026-04-27)
|
||||
|
||||
Polishing pass on locale and timezone pickers in the redesigned UI: editors and selectors now use the same `EntitySelect` palette pattern, and the timezone dropdown is portalled to escape Card clipping.
|
||||
Adds user filters for the Gitea tracker, makes the dashboard navigable, removes leftover webhook polling, and fixes the theme/sidebar flash on hard reload.
|
||||
|
||||
## User-facing changes
|
||||
|
||||
### Features
|
||||
|
||||
- **Gitea — sender filters:** `NotificationTracker` now exposes `sender_allowlist` and `sender_blocklist` via `MultiEntitySelect`. The picker is populated from `Gitea /users/search` merged with past `EventLog` senders, so it is useful even before the first webhook arrives ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
||||
- **Dashboard navigability:** 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 ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
||||
- **Command trackers / configs:** auto-reselect the matching config when the provider type changes, matching notification-tracker behaviour ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
||||
- **Webhook providers (gitea, planka, webhook):** stop scheduling interval polling jobs on tracker create/update/startup, and hide the misleading "every Xs" indicator in the tracker list — webhook trackers do not poll ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Template editors (notification & command) now use `EntitySelect` for locale switching and default to the configured **primary locale** when opening, editing, or cloning a config (previously always defaulted to `en`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
||||
- `LocaleSelector` add-flow now uses `EntitySelect` for catalog pick; custom BCP-47 codes (e.g. `de-CH`) keep a small dedicated input ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
||||
- `TimezoneSelector` dropdown was being clipped by Card's `overflow: hidden` and `backdrop-filter`; portalled to `<body>` with an overlay backdrop and styled as a centered modal palette (same pattern as `EntitySelect`) ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
||||
- Removed top padding on the timezone scroll list so sticky region group headers no longer leak rows above them ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
||||
- **Theme FOUC on hard reload:** an inline blocking script in `app.html` now resolves the theme from `localStorage` (or `prefers-color-scheme`) and sets `data-theme` on `<html>` before first paint, eliminating the dark→light flash users saw when the light theme was selected ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
|
||||
- **Sidebar jump on reload:** sidebar collapsed state and expanded nav groups now hydrate synchronously in their `$state` initialisers instead of inside `onMount`, so the sidebar no longer snaps from expanded→collapsed and groups no longer slide open after mount ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
|
||||
- **Provider-filter row pop-in:** the global provider-filter row now stays rendered while `providersCache.fetchedAt === 0`, so it no longer pops in mid-paint and pushes the nav down once the cache resolves ([b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01))
|
||||
|
||||
## Development / Internal
|
||||
|
||||
### Refactoring
|
||||
### Build
|
||||
|
||||
- Extracted shared locale catalog to `frontend/src/lib/locales.ts` for reuse across selectors ([1bfec52](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/1bfec52))
|
||||
- **Build-time app version:** `vite.config.ts` now reads `frontend/package.json` and exposes its version as an `__APP_VERSION__` global via Vite's `define`, with an ambient declaration in `app.d.ts` so the layout's brand version badge type-checks ([4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955))
|
||||
|
||||
### Database
|
||||
|
||||
- **Migration:** drop legacy `batch_duration` column from `notification_tracker` — the field had been removed from the model but its `NOT NULL` constraint still blocked inserts on older DBs ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Refresh `.claude/docs/entity-relationships.md` with current `NotificationTracker` fields (filters, `adaptive_max_skip`, `default_*_config_id`) ([42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6))
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>All Commits</summary>
|
||||
|
||||
| Hash | Message | Author |
|
||||
|------|---------|--------|
|
||||
| [4307955](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/4307955) | feat(frontend): inject `__APP_VERSION__` from package.json at build time | alexei.dolgolyov |
|
||||
| [b107b01](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/b107b01) | fix(redesign): prevent theme FOUC and sidebar jump on hard reload | alexei.dolgolyov |
|
||||
| [42af7a6](https://git.dolgolyov-family.by/alexei.dolgolyov/notify-bridge/commit/42af7a6) | feat(trackers): user filters for Gitea, webhook polling cleanup, dashboard navigability | alexei.dolgolyov |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "notify-bridge-frontend",
|
||||
"private": true,
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
// Ambient type declarations for SvelteKit + project-level build-time globals.
|
||||
|
||||
declare global {
|
||||
/** App version, injected from frontend/package.json at build time. */
|
||||
const __APP_VERSION__: string;
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -5,6 +5,23 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Notify Bridge</title>
|
||||
<script>
|
||||
// Resolve theme before first paint to avoid dark→light FOUC on hard reload.
|
||||
(function () {
|
||||
try {
|
||||
var saved = localStorage.getItem('theme');
|
||||
var resolved =
|
||||
saved === 'light' || saved === 'dark'
|
||||
? saved
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
} catch (_) {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
||||
@@ -246,6 +246,9 @@
|
||||
"selectAlbums": "Select albums...",
|
||||
"repositories": "Repositories",
|
||||
"selectRepositories": "Select repositories...",
|
||||
"userAllowlist": "Only from users",
|
||||
"userBlocklist": "Exclude users",
|
||||
"selectUsers": "Pick users...",
|
||||
"boards": "Boards",
|
||||
"selectBoards": "Select boards...",
|
||||
"upsDevices": "UPS Devices",
|
||||
|
||||
@@ -246,6 +246,9 @@
|
||||
"selectAlbums": "Выберите альбомы...",
|
||||
"repositories": "Репозитории",
|
||||
"selectRepositories": "Выберите репозитории...",
|
||||
"userAllowlist": "Только от пользователей",
|
||||
"userBlocklist": "Исключить пользователей",
|
||||
"selectUsers": "Выберите пользователей...",
|
||||
"boards": "Доски",
|
||||
"selectBoards": "Выберите доски...",
|
||||
"upsDevices": "ИБП устройства",
|
||||
|
||||
@@ -56,5 +56,20 @@ export const giteaDescriptor: ProviderDescriptor = {
|
||||
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}',
|
||||
};
|
||||
|
||||
@@ -120,6 +120,25 @@ export interface CollectionMeta {
|
||||
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 ──────────────────────────────────────────────────
|
||||
|
||||
export interface ProviderDescriptor {
|
||||
@@ -153,6 +172,8 @@ export interface ProviderDescriptor {
|
||||
// ── Collections / Trackers ──
|
||||
/** Null means this provider has no collections (e.g. scheduler). */
|
||||
collectionMeta: CollectionMeta | null;
|
||||
/** Sender allowlist / blocklist pickers shown on the tracker form. */
|
||||
userFilters?: UserFilterMeta[];
|
||||
/** Whether this provider is webhook-based (hides scan_interval). */
|
||||
webhookBased?: boolean;
|
||||
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
let providerFilterValue = $state(globalProviderFilter.id ?? 0);
|
||||
let _syncingFilter = false;
|
||||
|
||||
// Reserve the provider-filter row from first paint until the cache resolves.
|
||||
// Without this, the row appears mid-paint and pushes nav items down on every
|
||||
// hard reload — the most visible "jump" the user reported.
|
||||
let showProviderFilter = $derived(allProviders.length >= 1 || providersCache.fetchedAt === 0);
|
||||
|
||||
// Sync filter value → store
|
||||
$effect(() => {
|
||||
const v = providerFilterValue;
|
||||
@@ -78,7 +83,24 @@
|
||||
} catch (err: any) { pwdMsg = err.message; pwdSuccess = false; snackError(err.message); }
|
||||
}
|
||||
|
||||
let collapsed = $state(false);
|
||||
// Read persisted UI state synchronously so first paint already matches the
|
||||
// user's last session — otherwise the sidebar visibly snaps from expanded
|
||||
// to collapsed (and groups slide open) right after mount.
|
||||
function readPersistedCollapsed(): boolean {
|
||||
if (typeof localStorage === 'undefined') return false;
|
||||
return localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
}
|
||||
function readPersistedExpandedGroups(): Record<string, boolean> {
|
||||
if (typeof localStorage === 'undefined') return {};
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
return saved ? JSON.parse(saved) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
let collapsed = $state(readPersistedCollapsed());
|
||||
let isMac = $derived(typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent));
|
||||
|
||||
// Nav counts — computed reactively from caches + global provider filter
|
||||
@@ -216,7 +238,7 @@
|
||||
};
|
||||
|
||||
// Track which groups are expanded (persisted in localStorage)
|
||||
let expandedGroups = $state<Record<string, boolean>>({});
|
||||
let expandedGroups = $state<Record<string, boolean>>(readPersistedExpandedGroups());
|
||||
|
||||
function toggleGroup(key: string) {
|
||||
expandedGroups = { ...expandedGroups, [key]: !expandedGroups[key] };
|
||||
@@ -262,13 +284,8 @@
|
||||
|
||||
onMount(async () => {
|
||||
initTheme();
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
collapsed = localStorage.getItem('sidebar_collapsed') === 'true';
|
||||
try {
|
||||
const saved = localStorage.getItem('nav_expanded');
|
||||
if (saved) expandedGroups = JSON.parse(saved);
|
||||
} catch (e) { console.warn('Failed to parse nav_expanded:', e); }
|
||||
}
|
||||
// `collapsed` and `expandedGroups` are now hydrated synchronously in
|
||||
// their $state initializers above to avoid a post-mount layout snap.
|
||||
await loadUser();
|
||||
if (!auth.user && !isAuthPage) {
|
||||
redirecting = true;
|
||||
@@ -384,7 +401,7 @@
|
||||
{/if}
|
||||
Notify Bridge
|
||||
</h1>
|
||||
<p class="brand-version font-mono">v0.5.2</p>
|
||||
<p class="brand-version font-mono">v{__APP_VERSION__}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -398,8 +415,10 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Global provider filter -->
|
||||
{#if allProviders.length >= 1}
|
||||
<!-- Global provider filter — kept rendered during the initial cache
|
||||
fetch (fetchedAt === 0) so the row doesn't pop in mid-paint and
|
||||
push the nav down. Hides only once we confirm zero providers. -->
|
||||
{#if showProviderFilter}
|
||||
<div class="{collapsed ? 'px-2 py-1' : 'px-3 py-1.5'}" style="border-bottom: 1px solid var(--color-border);">
|
||||
{#if collapsed}
|
||||
<button onclick={() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, parseDate } from '$lib/api';
|
||||
import { requestHighlight } from '$lib/highlight';
|
||||
import { t } from '$lib/i18n';
|
||||
import {
|
||||
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 {
|
||||
const diff = Date.now() - parseDate(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -424,8 +439,8 @@
|
||||
</section>
|
||||
|
||||
<!-- ==================== STATS ==================== -->
|
||||
{#snippet statCardSnippet(card: {icon: string; label: string; literalLabel?: string; value: number; literalValue?: string; suffix?: string; accent: string}, idx: number)}
|
||||
<div class="stat-card" style="--accent: {card.accent}">
|
||||
{#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)}
|
||||
<a class="stat-card" style="--accent: {card.accent}" href={card.href} onclick={card.onclick}>
|
||||
<div class="stat-card-inner">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="stat-icon" style="color: {card.accent};">
|
||||
@@ -439,7 +454,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet statCards()}
|
||||
@@ -452,6 +467,7 @@
|
||||
value: 0,
|
||||
literalValue: globalProviderFilter.provider.name,
|
||||
accent: STAT_ACCENTS[0],
|
||||
href: '/providers',
|
||||
}, 0)}
|
||||
{:else}
|
||||
{@render statCardSnippet({
|
||||
@@ -459,6 +475,7 @@
|
||||
label: 'dashboard.providers',
|
||||
value: filteredProviderCount,
|
||||
accent: STAT_ACCENTS[0],
|
||||
href: '/providers',
|
||||
}, 0)}
|
||||
{/if}
|
||||
{@render statCardSnippet({
|
||||
@@ -467,12 +484,14 @@
|
||||
value: displayActive,
|
||||
suffix: ` / ${displayTotal}`,
|
||||
accent: STAT_ACCENTS[1],
|
||||
href: '/notification-trackers',
|
||||
}, 1)}
|
||||
{@render statCardSnippet({
|
||||
icon: 'mdiTarget',
|
||||
label: 'dashboard.targets',
|
||||
value: displayTargets,
|
||||
accent: STAT_ACCENTS[2],
|
||||
href: '/targets',
|
||||
}, 2)}
|
||||
{#if status?.command_trackers !== undefined}
|
||||
{@render statCardSnippet({
|
||||
@@ -480,6 +499,7 @@
|
||||
label: 'nav.commandTrackers',
|
||||
value: displayCommandTrackers,
|
||||
accent: STAT_ACCENTS[3],
|
||||
href: '/command-trackers',
|
||||
}, 3)}
|
||||
{:else}
|
||||
{@render statCardSnippet({
|
||||
@@ -487,6 +507,8 @@
|
||||
label: 'dashboard.eventsTotal',
|
||||
value: heroSummary?.throughput ?? 0,
|
||||
accent: STAT_ACCENTS[3],
|
||||
href: '#events-section',
|
||||
onclick: scrollToEvents,
|
||||
}, 3)}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -496,7 +518,7 @@
|
||||
<!-- ==================== TWO COL: stream + provider deck ==================== -->
|
||||
<div class="two-col" class:two-col--single={!!globalProviderFilter.id}>
|
||||
<!-- Signal stream -->
|
||||
<section class="panel">
|
||||
<section class="panel" id="events-section">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h2 class="panel-title">{t('dashboard.streamTitle')} <em>{t('dashboard.streamEmphasis')}</em></h2>
|
||||
@@ -646,14 +668,14 @@
|
||||
{:else}
|
||||
<div class="provider-deck">
|
||||
{#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">
|
||||
<MdiIcon name={p.icon} size={20} />
|
||||
</div>
|
||||
<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}
|
||||
{p.name}
|
||||
<span class="truncate min-w-0">{p.name}</span>
|
||||
</div>
|
||||
<div class="provider-sub font-mono">
|
||||
{p.descriptor?.defaultName ?? p.type} · {p.trackerCount} {t('dashboard.trackersShort')}
|
||||
@@ -909,6 +931,7 @@
|
||||
============================================================ */
|
||||
.stat-card {
|
||||
position: relative;
|
||||
display: block;
|
||||
border-radius: 22px;
|
||||
background: var(--color-glass);
|
||||
backdrop-filter: blur(28px) saturate(160%);
|
||||
@@ -918,7 +941,10 @@
|
||||
overflow: hidden;
|
||||
transition: transform 0.25s cubic-bezier(.4,.4,0,1);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.stat-card:hover { text-decoration: none; }
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -1282,6 +1308,7 @@
|
||||
.provider-meter {
|
||||
text-align: right;
|
||||
min-width: 80px;
|
||||
padding: 4px 4px 4px 0;
|
||||
}
|
||||
.provider-num {
|
||||
font-size: 1rem;
|
||||
@@ -1295,7 +1322,6 @@
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-glass-strong);
|
||||
overflow: hidden;
|
||||
}
|
||||
.provider-bar-fill {
|
||||
height: 100%;
|
||||
|
||||
@@ -110,6 +110,27 @@
|
||||
editing = null;
|
||||
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) {
|
||||
form = {
|
||||
name: cfg.name,
|
||||
|
||||
@@ -113,6 +113,26 @@
|
||||
editing = null;
|
||||
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) {
|
||||
form = {
|
||||
name: trk.name,
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
let trackingConfigs = $derived(trackingConfigsCache.items);
|
||||
let templateConfigs = $derived(templateConfigsCache.items);
|
||||
let collections = $state<Record<string, any>[]>([]);
|
||||
let users = $state<{ id: string; name: string }[]>([]);
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
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 = []; }
|
||||
}
|
||||
|
||||
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);
|
||||
$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
|
||||
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) {
|
||||
const ptype = providers.find(p => p.id === form.provider_id)?.type || '';
|
||||
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);
|
||||
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);
|
||||
if (first) form.default_template_config_id = first.id;
|
||||
form.default_template_config_id = first?.id ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,7 +210,7 @@
|
||||
form = defaultForm();
|
||||
// Auto-select first provider if any
|
||||
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) {
|
||||
@@ -208,7 +225,9 @@
|
||||
};
|
||||
previousCollectionIds = [...(trk.collection_ids || [])];
|
||||
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) {
|
||||
@@ -460,6 +479,7 @@
|
||||
bind:form
|
||||
{providerItems}
|
||||
{collections}
|
||||
{users}
|
||||
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' }))}
|
||||
@@ -499,6 +519,7 @@
|
||||
{:else if !showForm}
|
||||
<div class="space-y-3 stagger-children">
|
||||
{#each notificationTrackers as tracker (tracker.id)}
|
||||
{@const trkDesc = getDescriptor(getProviderType(tracker))}
|
||||
<Card hover entityId={tracker.id}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -511,7 +532,9 @@
|
||||
<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')}
|
||||
{(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>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-wrap justify-end">
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
};
|
||||
providerItems: { value: number; label: string; icon: string; desc: string }[];
|
||||
collections: any[];
|
||||
users?: { id: string; name: string }[];
|
||||
collectionFilter?: string;
|
||||
trackingConfigItems?: { value: number; label: string; icon: string }[];
|
||||
templateConfigItems?: { value: number; label: string; icon: string }[];
|
||||
@@ -40,6 +41,7 @@
|
||||
form = $bindable(),
|
||||
providerItems,
|
||||
collections,
|
||||
users = [],
|
||||
collectionFilter = $bindable(),
|
||||
trackingConfigItems = [],
|
||||
templateConfigItems = [],
|
||||
@@ -116,6 +118,21 @@
|
||||
</div>
|
||||
{/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}
|
||||
<!-- Schedule type -->
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(fileURLToPath(new URL('./package.json', import.meta.url)), 'utf8'),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
server: {
|
||||
port: 5175,
|
||||
proxy: {
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-core"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
description = "Core library for Notify Bridge — service provider abstractions, models, notifications, and templates"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -150,6 +150,40 @@ class GiteaClient:
|
||||
_LOGGER.warning("Failed to fetch commits for %s/%s: %s", owner, repo, err)
|
||||
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):
|
||||
"""Raised when a Gitea API call fails."""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "notify-bridge-server"
|
||||
version = "0.6.2"
|
||||
version = "0.6.3"
|
||||
description = "Standalone Notify Bridge server — FastAPI REST API with SQLite database"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
||||
@@ -12,7 +12,7 @@ import aiohttp
|
||||
|
||||
from ..auth.dependencies import get_current_user
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import ServiceProvider, User
|
||||
from ..database.models import EventLog, ServiceProvider, User
|
||||
from ..services import (
|
||||
make_immich_provider, make_gitea_provider, make_planka_provider,
|
||||
make_nut_provider, make_google_photos_provider, list_provider_collections,
|
||||
@@ -398,6 +398,62 @@ async def list_collections(
|
||||
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")
|
||||
async def get_album_shared_links(
|
||||
provider_id: int,
|
||||
|
||||
@@ -197,6 +197,21 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
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
|
||||
if await _has_table(conn, "tracking_config"):
|
||||
gitea_flags = [
|
||||
|
||||
@@ -378,6 +378,8 @@ async def _load_tracker_jobs() -> None:
|
||||
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
from notify_bridge_core.providers.capabilities import get_capabilities
|
||||
|
||||
for tracker in trackers:
|
||||
job_id = f"tracker_{tracker.id}"
|
||||
if scheduler.get_job(job_id):
|
||||
@@ -386,6 +388,18 @@ async def _load_tracker_jobs() -> None:
|
||||
ptype = provider_types.get(tracker.provider_id, "")
|
||||
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
|
||||
if ptype == "scheduler" and filters.get("schedule_type") == "cron":
|
||||
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(
|
||||
tracker_id: int,
|
||||
interval: int,
|
||||
@@ -461,6 +498,10 @@ async def schedule_tracker(
|
||||
``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.
|
||||
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()
|
||||
job_id = f"tracker_{tracker_id}"
|
||||
@@ -474,6 +515,13 @@ async def schedule_tracker(
|
||||
if scheduler.get_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:
|
||||
try:
|
||||
tz = await _load_app_timezone()
|
||||
|
||||
Reference in New Issue
Block a user