feat: locale-aware notification templates + UX improvements

- Add locale support to notification templates (matching command template
  pattern): TemplateSlot now has locale field with (config_id, slot_name,
  locale) uniqueness, nested API format {slot: {locale: template}}
- Migration merges separate EN/RU system configs into unified per-provider
  configs; seeds create one config per provider with multi-locale slots
- Locale-aware dispatch with EN fallback in NotificationDispatcher
- Frontend locale tabs (EN/RU) on template config editor
- Fix tracking config cards not showing default provider icons
- Global provider filter, search palette, and various UX polish
This commit is contained in:
2026-03-23 19:08:48 +03:00
parent 6a559bfcd2
commit 37388c430c
30 changed files with 628 additions and 318 deletions
+12 -4
View File
@@ -13,8 +13,11 @@
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import EntitySelect from '$lib/components/EntitySelect.svelte';
import CrossLink from '$lib/components/CrossLink.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
import { providerDefaultIcon } from '$lib/grid-items';
import RuleEditor from './RuleEditor.svelte';
import ExecutionHistory from './ExecutionHistory.svelte';
import type { Action, ActionRule } from '$lib/types';
@@ -23,7 +26,8 @@
let providers = $derived(providersCache.items);
let filterText = $state('');
let actions = $derived(allActions.filter((a: Action) =>
!filterText || a.name.toLowerCase().includes(filterText.toLowerCase()) || a.action_type.toLowerCase().includes(filterText.toLowerCase())
(!filterText || a.name.toLowerCase().includes(filterText.toLowerCase()) || a.action_type.toLowerCase().includes(filterText.toLowerCase())) &&
(!globalProviderFilter.id || a.provider_id === globalProviderFilter.id)
));
let showForm = $state(false);
@@ -49,7 +53,7 @@
}));
let providerItems = $derived(actionProviders.map((p: any) => ({
value: p.id, label: p.name, icon: p.icon || 'mdiServer', desc: p.type,
value: p.id, label: p.name, icon: providerDefaultIcon(p), desc: p.type,
})));
// Action types for selected provider
@@ -136,8 +140,12 @@
executing = { ...executing, [id]: false };
}
function getProvider(providerId: number) {
return providers.find((p: any) => p.id === providerId);
}
function getProviderName(providerId: number): string {
return providers.find((p: any) => p.id === providerId)?.name || '?';
return getProvider(providerId)?.name || '?';
}
function formatSchedule(action: Action): string {
@@ -297,7 +305,7 @@
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{action.action_type}</span>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--color-muted-foreground)]">
<span>{getProviderName(action.provider_id)}</span>
<CrossLink href="/providers" icon={providerDefaultIcon(getProvider(action.provider_id) || {})} label={getProviderName(action.provider_id)} entityId={action.provider_id} />
<span>{formatSchedule(action)}</span>
<span>{action.rules?.length || 0} {t('actions.rules')}</span>
{#if action.last_run_status}