feat(frontend): smoother event refresh, localized crumbs, template config deep-link

- Auto-refresh ticker is now silent: skips ``eventsLoading`` so the
  loading placeholder no longer flashes, uses ``(event.id)`` key on
  the events ``{#each}`` so unchanged rows reuse their DOM nodes, and
  short-circuits the array reassignment when the visible page is
  identical to what we already rendered. No-op refreshes leave the
  list completely untouched.
- ``PageHeader`` crumbs (Routing · Notification, Operators · Bots, …)
  were hard-coded literals. Moved to a new ``crumbs`` i18n namespace
  with 9 keys; updated all 15 call sites to ``t('crumbs.*')`` so they
  switch with the language.
- Tracker form's Immich feature-discovery banner now exposes both
  ``Open Tracking Config`` and ``Open Template Config``. Added the
  ``?edit=<id>`` auto-open hook to ``/template-configs`` (mirrors the
  existing one on ``/tracking-configs``) so the new link lands users
  directly on the editor.
This commit is contained in:
2026-05-07 22:34:24 +03:00
parent 35a3008896
commit b170c2b792
19 changed files with 111 additions and 29 deletions
+12
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge", "name": "Notify Bridge",
"tagline": "Service notifications" "tagline": "Service notifications"
}, },
"crumbs": {
"routingNotification": "Routing · Notification",
"routingCommands": "Routing · Commands",
"routingTargets": "Routing · Targets",
"routingAutomation": "Routing · Automation",
"operatorsBots": "Operators · Bots",
"systemAccess": "System · Access",
"systemConfiguration": "System · Configuration",
"systemMaintenance": "System · Maintenance",
"serviceConnections": "Service · Connections"
},
"nav": { "nav": {
"sectionOverview": "Overview", "sectionOverview": "Overview",
"sectionRouting": "Routing", "sectionRouting": "Routing",
@@ -342,6 +353,7 @@
"checkingLinks": "Checking links...", "checkingLinks": "Checking links...",
"featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.", "featureDiscovery": "Configure periodic summaries, scheduled photo picks, memories, and quiet hours in the default Tracking Config.",
"openTrackingConfig": "Open Tracking Config", "openTrackingConfig": "Open Tracking Config",
"openTemplateConfig": "Open Template Config",
"linkReplace": "Replace", "linkReplace": "Replace",
"linkReplacing": "Replacing...", "linkReplacing": "Replacing...",
"linkReplaceFailed": "Failed to replace link for \"{name}\"", "linkReplaceFailed": "Failed to replace link for \"{name}\"",
+12
View File
@@ -3,6 +3,17 @@
"name": "Notify Bridge", "name": "Notify Bridge",
"tagline": "Уведомления о сервисах" "tagline": "Уведомления о сервисах"
}, },
"crumbs": {
"routingNotification": "Маршрутизация · Уведомления",
"routingCommands": "Маршрутизация · Команды",
"routingTargets": "Маршрутизация · Цели",
"routingAutomation": "Маршрутизация · Автоматизация",
"operatorsBots": "Операторы · Боты",
"systemAccess": "Система · Доступ",
"systemConfiguration": "Система · Настройки",
"systemMaintenance": "Система · Обслуживание",
"serviceConnections": "Сервис · Подключения"
},
"nav": { "nav": {
"sectionOverview": "Обзор", "sectionOverview": "Обзор",
"sectionRouting": "Маршрутизация", "sectionRouting": "Маршрутизация",
@@ -342,6 +353,7 @@
"checkingLinks": "Проверка ссылок...", "checkingLinks": "Проверка ссылок...",
"featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.", "featureDiscovery": "Периодические сводки, запланированные подборки, воспоминания и тихие часы настраиваются в привязанной конфигурации отслеживания.",
"openTrackingConfig": "Открыть конфигурацию отслеживания", "openTrackingConfig": "Открыть конфигурацию отслеживания",
"openTemplateConfig": "Открыть конфигурацию шаблона",
"linkReplace": "Пересоздать", "linkReplace": "Пересоздать",
"linkReplacing": "Пересоздание...", "linkReplacing": "Пересоздание...",
"linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»", "linkReplaceFailed": "Не удалось пересоздать ссылку для «{name}»",
+37 -6
View File
@@ -108,7 +108,7 @@
// visibility flip via ``visibilitychange`` below. // visibility flip via ``visibilitychange`` below.
const tick = () => { const tick = () => {
if (typeof document !== 'undefined' && document.hidden) return; if (typeof document !== 'undefined' && document.hidden) return;
loadEvents(); loadEvents({ silent: true });
loadChart(); loadChart();
}; };
const handle = setInterval(tick, refreshSeconds * 1000); const handle = setInterval(tick, refreshSeconds * 1000);
@@ -163,22 +163,53 @@
return params; return params;
} }
async function loadEvents() { /** Reload the events panel.
eventsLoading = true; *
* ``silent`` is set by the auto-refresh ticker so the loading
* placeholder doesn't flash and the row list isn't disturbed when
* nothing actually changed. We diff the new payload against the
* current ``status`` and reuse the existing ``recent_events`` array
* reference when the ID list is identical — that lets Svelte's keyed
* ``{#each}`` skip its diff entirely instead of patching every row.
*/
async function loadEvents(opts: { silent?: boolean } = {}) {
if (!opts.silent) eventsLoading = true;
try { try {
const params = buildFilterParams(); const params = buildFilterParams();
params.set('sort', filterSort); params.set('sort', filterSort);
params.set('limit', String(eventsLimit)); params.set('limit', String(eventsLimit));
params.set('offset', String(eventsOffset)); params.set('offset', String(eventsOffset));
const qs = params.toString(); const qs = params.toString();
status = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`); const next = await api<DashboardStatus>(`/status${qs ? '?' + qs : ''}`);
if (opts.silent && status && _sameEventIds(status.recent_events, next.recent_events)) {
// Nothing changed in the visible page. Update only the
// out-of-band counts so the header and pager stay accurate;
// keep the existing array reference so no row re-renders.
status = {
...status,
providers: next.providers,
trackers: next.trackers,
targets: next.targets,
total_events: next.total_events,
command_trackers: next.command_trackers,
};
return;
}
status = next;
} catch (err: unknown) { } catch (err: unknown) {
error = err instanceof Error ? err.message : t('common.error'); error = err instanceof Error ? err.message : t('common.error');
} finally { } finally {
eventsLoading = false; if (!opts.silent) eventsLoading = false;
} }
} }
function _sameEventIds(a: { id: number }[], b: { id: number }[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id) return false;
return true;
}
async function loadChart() { async function loadChart() {
try { try {
const params = buildFilterParams(); const params = buildFilterParams();
@@ -653,7 +684,7 @@
</div> </div>
{:else} {:else}
<div class="signal-list stagger-children"> <div class="signal-list stagger-children">
{#each status.recent_events as event, i} {#each status.recent_events as event, i (event.id)}
<button type="button" class="signal-row signal-row--clickable" <button type="button" class="signal-row signal-row--clickable"
style="animation-delay: {i * 60}ms;" style="animation-delay: {i * 60}ms;"
onclick={() => selectedEvent = event} onclick={() => selectedEvent = event}
+1 -1
View File
@@ -199,7 +199,7 @@
title={t('actions.title')} title={t('actions.title')}
emphasis={t('actions.titleEmphasis')} emphasis={t('actions.titleEmphasis')}
description={t('actions.description')} description={t('actions.description')}
crumb="Routing · Automation" crumb={t('crumbs.routingAutomation')}
count={actions.length} count={actions.length}
countLabel={t('actions.countLabel')} countLabel={t('actions.countLabel')}
pills={headerPills} pills={headerPills}
+1 -1
View File
@@ -99,7 +99,7 @@
title={t('emailBot.title')} title={t('emailBot.title')}
emphasis={t('emailBot.titleEmphasis')} emphasis={t('emailBot.titleEmphasis')}
description={t('emailBot.description')} description={t('emailBot.description')}
crumb="Operators · Bots" crumb={t('crumbs.operatorsBots')}
count={emailBots.length} count={emailBots.length}
countLabel={t('emailBot.countLabel')} countLabel={t('emailBot.countLabel')}
> >
+1 -1
View File
@@ -97,7 +97,7 @@
title={t('matrixBot.title')} title={t('matrixBot.title')}
emphasis={t('matrixBot.titleEmphasis')} emphasis={t('matrixBot.titleEmphasis')}
description={t('matrixBot.description')} description={t('matrixBot.description')}
crumb="Operators · Bots" crumb={t('crumbs.operatorsBots')}
count={matrixBots.length} count={matrixBots.length}
countLabel={t('matrixBot.countLabel')} countLabel={t('matrixBot.countLabel')}
> >
@@ -303,7 +303,7 @@
title={t('telegramBot.title')} title={t('telegramBot.title')}
emphasis={t('telegramBot.titleEmphasis')} emphasis={t('telegramBot.titleEmphasis')}
description={t('telegramBot.description')} description={t('telegramBot.description')}
crumb="Operators · Bots" crumb={t('crumbs.operatorsBots')}
count={bots.length} count={bots.length}
countLabel={t('telegramBot.countLabel')} countLabel={t('telegramBot.countLabel')}
> >
@@ -205,7 +205,7 @@
title={t('commandConfig.title')} title={t('commandConfig.title')}
emphasis={t('commandConfig.titleEmphasis')} emphasis={t('commandConfig.titleEmphasis')}
description={t('commandConfig.description')} description={t('commandConfig.description')}
crumb="Routing · Commands" crumb={t('crumbs.routingCommands')}
count={configs.length} count={configs.length}
countLabel={t('commandConfig.countLabel')} countLabel={t('commandConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -422,7 +422,7 @@
title={t('cmdTemplateConfig.title')} title={t('cmdTemplateConfig.title')}
emphasis={t('cmdTemplateConfig.titleEmphasis')} emphasis={t('cmdTemplateConfig.titleEmphasis')}
description={t('cmdTemplateConfig.description')} description={t('cmdTemplateConfig.description')}
crumb="Routing · Commands" crumb={t('crumbs.routingCommands')}
count={configs.length} count={configs.length}
countLabel={t('cmdTemplateConfig.countLabel')} countLabel={t('cmdTemplateConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -278,7 +278,7 @@
title={t('commandTracker.title')} title={t('commandTracker.title')}
emphasis={t('commandTracker.titleEmphasis')} emphasis={t('commandTracker.titleEmphasis')}
description={t('commandTracker.description')} description={t('commandTracker.description')}
crumb="Routing · Commands" crumb={t('crumbs.routingCommands')}
count={trackers.length} count={trackers.length}
countLabel={t('dashboard.trackersShort')} countLabel={t('dashboard.trackersShort')}
pills={headerPills} pills={headerPills}
@@ -468,7 +468,7 @@
title={t('notificationTracker.title')} title={t('notificationTracker.title')}
emphasis={t('notificationTracker.titleEmphasis')} emphasis={t('notificationTracker.titleEmphasis')}
description={t('notificationTracker.description')} description={t('notificationTracker.description')}
crumb="Routing · Notification" crumb={t('crumbs.routingNotification')}
count={notificationTrackers.length} count={notificationTrackers.length}
countLabel={t('dashboard.trackersShort')} countLabel={t('dashboard.trackersShort')}
pills={headerPills} pills={headerPills}
@@ -227,13 +227,22 @@
<span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span> <span style="color: var(--color-primary);"><MdiIcon name="mdiInformationOutline" size={16} /></span>
<div class="flex-1 text-xs"> <div class="flex-1 text-xs">
<p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p> <p style="color: var(--color-muted-foreground);">{t('notificationTracker.featureDiscovery')}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1">
<a href={form.default_tracking_config_id <a href={form.default_tracking_config_id
? `/tracking-configs?edit=${form.default_tracking_config_id}` ? `/tracking-configs?edit=${form.default_tracking_config_id}`
: '/tracking-configs'} : '/tracking-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline mt-1"> class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} /> <MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTrackingConfig')} {t('notificationTracker.openTrackingConfig')}
</a> </a>
<a href={form.default_template_config_id
? `/template-configs?edit=${form.default_template_config_id}`
: '/template-configs'}
class="inline-flex items-center gap-1 text-[var(--color-primary)] hover:underline">
<MdiIcon name="mdiArrowRight" size={12} />
{t('notificationTracker.openTemplateConfig')}
</a>
</div>
</div> </div>
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -198,7 +198,7 @@
title={t('providers.title')} title={t('providers.title')}
emphasis={t('providers.titleEmphasis')} emphasis={t('providers.titleEmphasis')}
description={t('providers.description')} description={t('providers.description')}
crumb="Service · Connections" crumb={t('crumbs.serviceConnections')}
count={providers.length} count={providers.length}
countLabel={t('dashboard.providersShort')} countLabel={t('dashboard.providersShort')}
pills={headerPills} pills={headerPills}
+1 -1
View File
@@ -101,7 +101,7 @@
title={t('settings.title')} title={t('settings.title')}
emphasis={t('settings.titleEmphasis')} emphasis={t('settings.titleEmphasis')}
description={t('settings.description')} description={t('settings.description')}
crumb="System · Configuration" crumb={t('crumbs.systemConfiguration')}
/> />
{#if !loaded} {#if !loaded}
@@ -296,7 +296,7 @@
title={t('backup.title')} title={t('backup.title')}
emphasis={t('backup.titleEmphasis')} emphasis={t('backup.titleEmphasis')}
description={t('backup.description')} description={t('backup.description')}
crumb="System · Maintenance" crumb={t('crumbs.systemMaintenance')}
/> />
{#if !loaded} {#if !loaded}
+1 -1
View File
@@ -453,7 +453,7 @@
title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')} title={activeType ? activeType.charAt(0).toUpperCase() + activeType.slice(1) : t('targets.title')}
emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')} emphasis={activeType ? t('targets.titleEmphasis') : t('targets.titleEmphasisAll')}
description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')} description={activeType ? t(TYPE_DESC_KEYS[activeType]) : t('targets.description')}
crumb="Routing · Targets" crumb={t('crumbs.routingTargets')}
count={targets.length} count={targets.length}
countLabel={t('dashboard.targetsShort')} countLabel={t('dashboard.targetsShort')}
pills={headerPills} pills={headerPills}
@@ -261,7 +261,25 @@
supportedLocalesCache.fetch(), supportedLocalesCache.fetch(),
]); ]);
} catch (err: any) { error = err.message || t('common.loadError'); snackError(error); } } catch (err: any) { error = err.message || t('common.loadError'); snackError(error); }
finally { loaded = true; highlightFromUrl(); handleDeepLink(); } finally { loaded = true; highlightFromUrl(); _openEditFromUrl(); handleDeepLink(); }
}
// Cross-page deep-link: ``/template-configs?edit=<id>`` auto-opens that
// config in edit mode. Mirrors the same hook on tracking-configs so the
// Notification Tracker form can link directly to the editor instead of
// the generic list. Strips the param afterwards so a browser refresh
// doesn't re-open the modal.
function _openEditFromUrl() {
if (typeof window === 'undefined') return;
const params = new URLSearchParams(window.location.search);
const editId = params.get('edit');
if (!editId) return;
const match = allTemplateConfigs.find(c => String(c.id) === editId);
if (match) edit(match);
params.delete('edit');
const qs = params.toString();
const cleanUrl = window.location.pathname + (qs ? '?' + qs : '');
window.history.replaceState(null, '', cleanUrl);
} }
/** /**
@@ -429,7 +447,7 @@
title={t('templateConfig.title')} title={t('templateConfig.title')}
emphasis={t('templateConfig.titleEmphasis')} emphasis={t('templateConfig.titleEmphasis')}
description={t('templateConfig.description')} description={t('templateConfig.description')}
crumb="Routing · Notification" crumb={t('crumbs.routingNotification')}
count={configs.length} count={configs.length}
countLabel={t('templateConfig.countLabel')} countLabel={t('templateConfig.countLabel')}
pills={headerPills} pills={headerPills}
@@ -276,7 +276,7 @@
title={t('trackingConfig.title')} title={t('trackingConfig.title')}
emphasis={t('trackingConfig.titleEmphasis')} emphasis={t('trackingConfig.titleEmphasis')}
description={t('trackingConfig.description')} description={t('trackingConfig.description')}
crumb="Routing · Notification" crumb={t('crumbs.routingNotification')}
count={configs.length} count={configs.length}
countLabel={t('trackingConfig.countLabel')} countLabel={t('trackingConfig.countLabel')}
pills={headerPills} pills={headerPills}
+1 -1
View File
@@ -93,7 +93,7 @@
title={t('users.title')} title={t('users.title')}
emphasis={t('users.titleEmphasis')} emphasis={t('users.titleEmphasis')}
description={t('users.description')} description={t('users.description')}
crumb="System · Access" crumb={t('crumbs.systemAccess')}
count={users.length} count={users.length}
countLabel={t('users.countLabel')} countLabel={t('users.countLabel')}
> >