feat: bridge_self bot commands — status, thresholds, reset, health
Adds bot commands for the bridge_self provider so operators can inspect and manage bridge health from chat: /status, /thresholds, /reset, /health. Includes Jinja2 templates for both locales, seed data, capability slots, and a handler that exposes pending deferred backlog plus per-counter reset. Also adds .claude/skills/ for project-scoped graph-aware skills.
This commit is contained in:
@@ -1127,6 +1127,18 @@
|
||||
"scopeInherit": "Inherit: derive from notification routing",
|
||||
"noCollections": "No albums available."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Bridge status",
|
||||
"statusDesc": "Show current bridge health counters",
|
||||
"thresholds": "Bridge thresholds",
|
||||
"thresholdsDesc": "Show configured alert thresholds",
|
||||
"reset": "Reset counter",
|
||||
"resetDesc": "Manually reset a failure counter",
|
||||
"health": "Bridge health",
|
||||
"healthDesc": "Terse one-line health summary"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details"
|
||||
|
||||
@@ -1127,6 +1127,18 @@
|
||||
"scopeInherit": "Наследовать: вычислить из маршрутизации уведомлений",
|
||||
"noCollections": "Нет доступных альбомов."
|
||||
},
|
||||
"commands": {
|
||||
"bridgeSelf": {
|
||||
"status": "Состояние моста",
|
||||
"statusDesc": "Показать счётчики состояния моста",
|
||||
"thresholds": "Пороги моста",
|
||||
"thresholdsDesc": "Показать настроенные пороги оповещений",
|
||||
"reset": "Сбросить счётчик",
|
||||
"resetDesc": "Вручную сбросить счётчик сбоев",
|
||||
"health": "Здоровье моста",
|
||||
"healthDesc": "Краткая однострочная сводка состояния"
|
||||
}
|
||||
},
|
||||
"snackbar": {
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
let submitting = $state(false);
|
||||
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
|
||||
|
||||
// Immich command icons — used as fallback when capabilities don't specify icons
|
||||
// Immich command icons — used as fallback when capabilities don't specify icons
|
||||
const commandIcons: Record<string, string> = {
|
||||
help: 'mdiHelpCircle', status: 'mdiChartBox', albums: 'mdiImageMultiple',
|
||||
events: 'mdiPulse', summary: 'mdiFileDocumentEdit', latest: 'mdiImagePlus',
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
return {
|
||||
value: code,
|
||||
label: m.native,
|
||||
desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(),
|
||||
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
|
||||
};
|
||||
}));
|
||||
$effect(() => {
|
||||
@@ -144,10 +144,10 @@
|
||||
* Group command slots by purpose so the form mirrors how notification
|
||||
* templates are split (event vs scheduled vs settings).
|
||||
*
|
||||
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||
* commandErrors — fallback messages (rate_limited, no_results)
|
||||
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||
* commandResponses — primary reply templates (/start, /help, /status, data slots)
|
||||
* commandErrors — fallback messages (rate_limited, no_results)
|
||||
* commandDescriptions — desc_* slots: short menu blurbs in Telegram's command picker
|
||||
* commandUsage — usage_* slots: invocation examples shown by /help
|
||||
*/
|
||||
let commandSlotGroups = $derived([
|
||||
{
|
||||
@@ -548,7 +548,7 @@
|
||||
{#each filteredSlots as slot}
|
||||
<CollapsibleSlot
|
||||
label={slot.name}
|
||||
description="/{slot.name} — {slot.description}"
|
||||
description="/{slot.name} — {slot.description}"
|
||||
expanded={expandedSlots.has(slot.name)}
|
||||
status={getSlotStatus(slot.name)}
|
||||
ontoggle={() => toggleSlot(slot.name)}
|
||||
@@ -587,7 +587,7 @@
|
||||
|
||||
{#if slotErrors[slot.name]}
|
||||
{#if slotErrorTypes[slot.name] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">вљ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.name]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.name]}{slotErrorLines[slot.name] ? ` (${t('common.line')} ${slotErrorLines[slot.name]})` : ''}</p>
|
||||
{/if}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
// Test types: basic is always available; periodic/scheduled/memory only for providers
|
||||
// that have those notification slots in their capabilities AND have the feature
|
||||
// enabled on the tracker's default TrackingConfig. A disabled feature on the
|
||||
// default config means cron dispatch won't fire it in production either — so
|
||||
// default config means cron dispatch won't fire it in production either — so
|
||||
// the test button would just surface a silent skip.
|
||||
const allTestTypes: Record<string, {
|
||||
key: string; icon: string; labelKey: string;
|
||||
@@ -128,7 +128,7 @@
|
||||
base.push({
|
||||
key: tt.key, icon: tt.icon, labelKey: tt.labelKey,
|
||||
// When surfaced, the button still renders but is disabled and
|
||||
// shows *why* — users who land here via the test menu without
|
||||
// shows *why* — users who land here via the test menu without
|
||||
// having toggled the feature on Tracking Config see a clear
|
||||
// pointer to the missing setting instead of a silent failure.
|
||||
disabledReason: enabled ? undefined : 'notificationTracker.testDisabledHint',
|
||||
@@ -179,7 +179,7 @@
|
||||
|
||||
async function loadUsers() {
|
||||
if (!form.provider_id) { users = []; return; }
|
||||
// Skip the fetch when the descriptor has no user filters — saves a
|
||||
// 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; }
|
||||
@@ -383,7 +383,7 @@
|
||||
function trackerTiles(tracker: Tracker): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
const trkDesc = getDescriptor(getProviderType(tracker));
|
||||
// Status — armed/paused with color tone
|
||||
// Status — armed/paused with color tone
|
||||
tiles.push(tracker.enabled
|
||||
? { icon: 'mdiPulse', label: t('notificationTracker.armed'), tone: 'mint' }
|
||||
: { icon: 'mdiPauseCircleOutline', label: t('notificationTracker.paused'), tone: 'citrus' });
|
||||
@@ -393,7 +393,7 @@
|
||||
label: getProviderName(tracker.provider_id),
|
||||
tone: 'lavender',
|
||||
});
|
||||
// Collections — count + label (varies per provider descriptor)
|
||||
// Collections — count + label (varies per provider descriptor)
|
||||
const collCount = (tracker.collection_ids || []).length;
|
||||
if (collCount > 0 || !trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
@@ -403,7 +403,7 @@
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Scan interval — only meaningful for polling trackers
|
||||
// Scan interval — only meaningful for polling trackers
|
||||
if (!trkDesc?.webhookBased) {
|
||||
tiles.push({
|
||||
icon: 'mdiTimerOutline',
|
||||
@@ -593,8 +593,8 @@
|
||||
<div class="list-row__secondary mt-0.5">
|
||||
<CrossLink href="/providers" icon="mdiServer" label={getProviderName(tracker.provider_id)} entityId={tracker.provider_id} />
|
||||
<p class="text-sm text-[var(--color-muted-foreground)]">
|
||||
{(tracker.collection_ids || []).length} {getCollectionLabel(tracker)} Р’В·
|
||||
{#if !trkDesc?.webhookBased}{t('notificationTracker.every')} {tracker.scan_interval}s Р’В·{/if}
|
||||
{(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>
|
||||
@@ -605,7 +605,7 @@
|
||||
<IconButton icon={tracker.enabled ? 'mdiPause' : 'mdiPlay'} title={tracker.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')} onclick={() => toggle(tracker)} disabled={toggling[tracker.id]} />
|
||||
<button onclick={() => toggleExpand(tracker.id)}
|
||||
class="text-xs text-[var(--color-muted-foreground)] hover:underline px-2 py-1">
|
||||
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? 'в–І' : 'в–С'}
|
||||
{t('notificationTracker.linkedTargets')} {expandedTracker === tracker.id ? '▲' : '▼'}
|
||||
</button>
|
||||
<IconButton icon="mdiDelete" title={t('notificationTracker.delete')} onclick={() => startDelete(tracker)} variant="danger" />
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
const tiles: MetaTile[] = [];
|
||||
const h = health[provider.id];
|
||||
const provDesc = getDescriptor(provider.type);
|
||||
// Status — first tile, color-coded
|
||||
// Status — first tile, color-coded
|
||||
if (h === true) {
|
||||
tiles.push({ icon: 'mdiCheckCircle', label: t('providers.online'), tone: 'mint' });
|
||||
} else if (h === false) {
|
||||
@@ -107,10 +107,10 @@
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const segments = u.pathname.split('/').filter(Boolean);
|
||||
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
||||
const tail = segments.length ? `/${segments[0]}${segments.length > 1 ? '/…' : ''}` : '';
|
||||
return `${u.host}${tail}`;
|
||||
} catch {
|
||||
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
||||
return url.length > 32 ? `${url.slice(0, 30)}…` : url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@
|
||||
|
||||
let health = $state<Record<number, boolean | null>>({});
|
||||
|
||||
// Status pill row for the page header — derived from health probes.
|
||||
// Status pill row for the page header — derived from health probes.
|
||||
const headerPills = $derived.by(() => {
|
||||
const onlineCount = Object.values(health).filter(v => v === true).length;
|
||||
const offlineCount = Object.values(health).filter(v => v === false).length;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
import ReceiverSection from './ReceiverSection.svelte';
|
||||
import BotGroupHeader from './BotGroupHeader.svelte';
|
||||
|
||||
// в”Ђв”Ђ Helpers в”Ђв”Ђ
|
||||
// ──── Helpers ────
|
||||
|
||||
function getBotName(target: NotificationTarget): string | null {
|
||||
if (target.type === 'telegram' && target.config?.bot_id) {
|
||||
@@ -74,7 +74,7 @@
|
||||
return recv.receiver_key || '?';
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Constants в”Ђв”Ђ
|
||||
// ──── Constants ────
|
||||
|
||||
const ALL_TYPES = ['telegram', 'webhook', 'email', 'discord', 'slack', 'ntfy', 'matrix', 'broadcast'] as const;
|
||||
type TargetType = typeof ALL_TYPES[number];
|
||||
@@ -97,7 +97,7 @@
|
||||
|
||||
function targetTiles(target: NotificationTarget): MetaTile[] {
|
||||
const tiles: MetaTile[] = [];
|
||||
// Type tile — useful when the "all types" filter is active and rows
|
||||
// Type tile — useful when the "all types" filter is active and rows
|
||||
// from multiple types appear side-by-side. The receivers count is
|
||||
// already shown inside the `target-summary` button, so we don't repeat
|
||||
// it as a tile.
|
||||
@@ -115,8 +115,8 @@
|
||||
tone: 'sky',
|
||||
});
|
||||
}
|
||||
// Telegram targets expose a chat label in config — surface it so the
|
||||
// row reads "Telegram Р’В· @bot Р’В· Family chat" without expanding.
|
||||
// Telegram targets expose a chat label in config — surface it so the
|
||||
// row reads "Telegram · @bot · Family chat" without expanding.
|
||||
const cfg = (target.config || {}) as Record<string, any>;
|
||||
if (target.type === 'telegram' && cfg.chat_id) {
|
||||
tiles.push({
|
||||
@@ -126,7 +126,7 @@
|
||||
mono: true,
|
||||
});
|
||||
}
|
||||
// Webhook target — show host
|
||||
// Webhook target — show host
|
||||
if (target.type === 'webhook' && cfg.url) {
|
||||
let host = String(cfg.url);
|
||||
try { host = new URL(host).host; } catch { /* keep raw */ }
|
||||
@@ -142,7 +142,7 @@
|
||||
return tiles;
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Derived state в”Ђв”Ђ
|
||||
// ──── Derived state ────
|
||||
|
||||
let allTargets = $derived(targetsCache.items);
|
||||
let activeType = $derived(page.url.searchParams.get('type') as TargetType | null);
|
||||
@@ -158,7 +158,7 @@
|
||||
const emailBotItems = $derived(emailBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiEmailOutline', desc: b.email })));
|
||||
const matrixBotItems = $derived(matrixBots.map(b => ({ value: b.id, label: b.name, icon: b.icon || 'mdiMatrix', desc: b.display_name || b.homeserver_url })));
|
||||
|
||||
// в”Ђв”Ђ Target form state в”Ђв”Ђ
|
||||
// ──── Target form state ────
|
||||
|
||||
let showForm = $state(false);
|
||||
let editing = $state<number | null>(null);
|
||||
@@ -204,7 +204,7 @@
|
||||
formEl?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Receiver inline form state в”Ђв”Ђ
|
||||
// ──── Receiver inline form state ────
|
||||
|
||||
let addingReceiverForTarget = $state<number | null>(null);
|
||||
let receiverForm = $state<Record<string, any>>({});
|
||||
@@ -228,7 +228,7 @@
|
||||
if (!expandedTargets.has(id)) expandedTargets.add(id);
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Effects в”Ђв”Ђ
|
||||
// ──── Effects ────
|
||||
|
||||
// Reset form when switching target type tabs
|
||||
$effect(() => {
|
||||
@@ -239,11 +239,11 @@
|
||||
addingReceiverForTarget = null;
|
||||
});
|
||||
|
||||
// в”Ђв”Ђ Data loading в”Ђв”Ђ
|
||||
// ──── Data loading ────
|
||||
|
||||
onMount(load);
|
||||
|
||||
// в”Ђв”Ђ Bot grouping в”Ђв”Ђ
|
||||
// ──── Bot grouping ────
|
||||
|
||||
type TargetGroup = {
|
||||
key: string;
|
||||
@@ -372,7 +372,7 @@
|
||||
} catch (e) { console.warn('Failed to load bot chats:', e); }
|
||||
}
|
||||
|
||||
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||
// Active discovery — actually polls Telegram getUpdates and persists any new chats.
|
||||
// Fired when the chat picker opens so the user sees the freshest list without a manual click.
|
||||
async function discoverReceiverBotChats(botId: number) {
|
||||
if (!botId) return;
|
||||
@@ -382,7 +382,7 @@
|
||||
} catch (e) { console.warn('Failed to discover bot chats:', e); }
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Target CRUD в”Ђв”Ђ
|
||||
// ──── Target CRUD ────
|
||||
|
||||
function openNew() {
|
||||
form = defaultForm();
|
||||
@@ -507,7 +507,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// в”Ђв”Ђ Receiver CRUD в”Ђв”Ђ
|
||||
// ──── Receiver CRUD ────
|
||||
|
||||
async function openReceiverForm(targetId: number, targetType: string) {
|
||||
// Force a remount of any picker palette when the same target is reopened
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
return {
|
||||
value: code,
|
||||
label: m.native,
|
||||
desc: i === 0 ? `${code.toUpperCase()} Р’В· ${t('locales.primary')}` : code.toUpperCase(),
|
||||
desc: i === 0 ? `${code.toUpperCase()} · ${t('locales.primary')}` : code.toUpperCase(),
|
||||
};
|
||||
}));
|
||||
/**
|
||||
@@ -442,7 +442,7 @@
|
||||
label: t('templateConfig.slots'),
|
||||
tone: slotCount > 0 ? 'sky' : 'default',
|
||||
});
|
||||
// Locale coverage — count unique locales present across all slots
|
||||
// Locale coverage — count unique locales present across all slots
|
||||
const locales = new Set<string>();
|
||||
for (const s of Object.values(config.slots || {})) {
|
||||
for (const loc of Object.keys(s || {})) locales.add(loc);
|
||||
@@ -624,7 +624,7 @@
|
||||
|
||||
{#if slotErrors[slot.key]}
|
||||
{#if slotErrorTypes[slot.key] === 'undefined'}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">вљ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
<p class="mt-1 text-xs" style="color: var(--color-warning-fg);">⚠ {t('common.undefinedVar')}: {slotErrors[slot.key]}</p>
|
||||
{:else}
|
||||
<p class="mt-1 text-xs" style="color: var(--color-error-fg);">вњ• {t('common.syntaxError')}: {slotErrors[slot.key]}{slotErrorLines[slot.key] ? ` (${t('common.line')} ${slotErrorLines[slot.key]})` : ''}</p>
|
||||
{/if}
|
||||
|
||||
@@ -28,13 +28,13 @@
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import MetaStrip, { type MetaTile } from '$lib/components/MetaStrip.svelte';
|
||||
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
/** Grid-select item source lookup — maps descriptor string name to actual function. */
|
||||
const gridItemSources: Record<string, () => any[]> = {
|
||||
sortByItems, sortOrderItems, albumModeItems, assetTypeItems, memorySourceItems,
|
||||
};
|
||||
|
||||
/**
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* HH:MM, comma-separated: "09:00" or "09:00, 18:30" — the only format cron
|
||||
* dispatch accepts. Matched on blur for time-list fields; invalid values
|
||||
* are surfaced inline next to the input.
|
||||
*/
|
||||
@@ -43,7 +43,7 @@
|
||||
/** Per-field error messages surfaced inline under time-list inputs. */
|
||||
let timeListErrors = $state<Record<string, string>>({});
|
||||
|
||||
/** Normalize "9:0 , 18:30" в†’ "09:00,18:30" on blur, clear error when valid. */
|
||||
/** Normalize "9:0 , 18:30" → "09:00,18:30" on blur, clear error when valid. */
|
||||
function normalizeTimeList(key: string) {
|
||||
const raw = String(form[key] ?? '').trim();
|
||||
if (!raw) { timeListErrors = { ...timeListErrors, [key]: '' }; return; }
|
||||
@@ -74,8 +74,8 @@
|
||||
}
|
||||
|
||||
/**
|
||||
* Quiet-hours preview: "22:00 в†’ 07:00 next day (9h)" or "Quiet period is 0
|
||||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||
* Quiet-hours preview: "22:00 → 07:00 next day (9h)" or "Quiet period is 0
|
||||
* minutes — adjust times" when start equals end. Handles overnight ranges
|
||||
* (start > end) correctly.
|
||||
*/
|
||||
function quietHoursPreview(start: string, end: string): string {
|
||||
@@ -92,8 +92,8 @@
|
||||
const m = span % 60;
|
||||
const dur = m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
const arrow = overnight
|
||||
? `${start} в†’ ${end} ${t('trackingConfig.nextDay')}`
|
||||
: `${start} в†’ ${end}`;
|
||||
? `${start} → ${end} ${t('trackingConfig.nextDay')}`
|
||||
: `${start} → ${end}`;
|
||||
return `${arrow} (${dur})`;
|
||||
}
|
||||
|
||||
@@ -112,12 +112,12 @@
|
||||
/**
|
||||
* Inline preview of the shipped default template for a scheduled/periodic/
|
||||
* memory slot. Using the shipped default (not a tracker's current template)
|
||||
* keeps this scoped to the tracking-config page — which has no concept of
|
||||
* keeps this scoped to the tracking-config page — which has no concept of
|
||||
* which TemplateConfig a given tracker uses. Users who want to edit the
|
||||
* actual config can click "Edit template" in the modal footer.
|
||||
*
|
||||
* ``previewLocale`` is modal-scoped so switching tabs only refetches for
|
||||
* this preview — the user's UI locale (and other previews) are untouched.
|
||||
* this preview — the user's UI locale (and other previews) are untouched.
|
||||
*/
|
||||
let previewModal = $state<{ slotName: string; rendered: string; error: string; locale: string } | null>(null);
|
||||
let previewLoading = $state(false);
|
||||
@@ -262,7 +262,7 @@
|
||||
if (config.quiet_hours_start && config.quiet_hours_end) {
|
||||
tiles.push({
|
||||
icon: 'mdiWeatherNight',
|
||||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||||
label: `${config.quiet_hours_start}–${config.quiet_hours_end}`,
|
||||
hint: t('trackingConfig.quietHoursStart'),
|
||||
tone: 'citrus',
|
||||
mono: true,
|
||||
@@ -344,7 +344,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Event tracking — driven by descriptor -->
|
||||
<!-- Event tracking — driven by descriptor -->
|
||||
{#if descriptor}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">{t('trackingConfig.eventTracking')}</legend>
|
||||
@@ -377,7 +377,7 @@
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
|
||||
<!-- Feature sections (periodic, scheduled, memory) — driven by descriptor -->
|
||||
{#each descriptor.featureSections ?? [] as section (section.key)}
|
||||
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
|
||||
<legend class="text-sm font-medium px-1">
|
||||
@@ -494,9 +494,9 @@
|
||||
</div>
|
||||
<p class="text-sm text-[var(--color-muted-foreground)] list-row__secondary">
|
||||
{(desc?.eventFields ?? []).filter(f => (config as Record<string, any>)[f.key]).map(f => t(f.label)).join(', ')}
|
||||
{config.periodic_enabled ? ` Р’В· ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` Р’В· ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` Р’В· ${t('trackingConfig.memory')}` : ''}
|
||||
{config.periodic_enabled ? ` · ${t('trackingConfig.periodic')}` : ''}
|
||||
{config.scheduled_enabled ? ` · ${t('trackingConfig.scheduled')}` : ''}
|
||||
{config.memory_enabled ? ` · ${t('trackingConfig.memory')}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<MetaStrip tiles={trackingConfigTiles(config)} />
|
||||
@@ -518,7 +518,7 @@
|
||||
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
|
||||
|
||||
<Modal open={previewModal !== null}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
title={previewModal ? `${t('trackingConfig.previewTemplate')} — ${previewModal.slotName}` : ''}
|
||||
onclose={() => previewModal = null}>
|
||||
{#if previewModal}
|
||||
{#if previewLocales.length > 1}
|
||||
@@ -537,7 +537,7 @@
|
||||
{t('trackingConfig.previewSampleNote')}
|
||||
</p>
|
||||
<!-- Keep the prior rendered/error box mounted while refetching on locale
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
switch — just dim it. Unmounting and replacing with a small "…"
|
||||
placeholder caused a one-frame layout jump as the modal shrank and
|
||||
then re-expanded. -->
|
||||
<div class="relative mb-3" style="opacity: {previewLoading ? 0.5 : 1}; transition: opacity 0.15s ease;">
|
||||
@@ -550,7 +550,7 @@
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(previewModal.rendered)}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
<div class="p-3 text-xs" style="color: var(--color-muted-foreground);">…</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-3">
|
||||
|
||||
Reference in New Issue
Block a user