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:
@@ -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}\"",
|
||||||
|
|||||||
@@ -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}»",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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')}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user