Files
notify-bridge/frontend/src/routes/command-template-configs/+page.svelte
T
alexei.dolgolyov a7a2b4efa4 feat: large polish pass — UX fixes, per-chat scope, restore/backup, action events
Backend
- Per-chat album scope for Immich commands (search/latest/memory/...): new
  allowed_album_ids on CommandTrackerListener, threaded listener/page kwargs
  through ProviderCommandHandler.handle; PATCH listener-scope endpoint.
- /search and /find accept a trailing page number; Immich client search_smart
  / search_metadata take a page param.
- Immich person-asset lookup switched from removed GET /api/people/{id}/assets
  to POST /api/search/metadata with personIds (fixes /person command and
  auto_organize rules silently returning zero candidates on Immich 1.106+).
- Auto_organize rule now sets the target album's thumbnail to the first added
  image when missing (falls back to any asset type); failures do not fail the
  rule. add_assets_to_album surfaces the Immich error body on non-2xx.
- EventLog.user_id / action_id / action_name columns with defensive migration
  + backfill. Status query filters by user_id directly; Immich/webhook paths
  emit user_id explicitly. action_runner writes an action_success/partial/
  failed event on each non-dry-run.
- Dashboard DELETE /api/status/events (scoped to user_id) + rendering live
  tracker/provider/action names via FK join with snapshot fallback.
- PATCH /api/users/{id} for username/role change with last-admin guard.
- Deletion protection returns structured {message, entity, blocked_by}
  (ApiError carries .blockedBy; frontend opens BlockedByModal).
- Backup prepare-restore → AppSetting markers + atomic write of
  pending_restore.json; lifespan hook applies on next startup and archives
  under data/applied_restores/. apply-restart sends SIGTERM so the lifespan
  shutdown runs; NOTIFY_BRIDGE_SUPERVISED env override gates the button.
  Manual POST /api/backup/files (same format as scheduled).
- New periodic-summary test path reuses shared collect_scheduled_assets
  (limit=0) so test and future production code go through one primitive.
- Per-receiver locale for Telegram test messages (resolves
  TelegramChat.language_override per chat instead of applying the first
  receiver's locale to everyone).
- Bounded concurrency (semaphores) in NotificationDispatcher._preload_asset_data
  and _refresh_telegram_chat_titles; chat title sweep extended to 24h since
  save_chat_from_webhook covers active chats opportunistically.
- Telegram poller detects the \"webhook is active\" 409 and auto-calls
  deleteWebhook for bots whose DB update_mode is polling (throttled per bot).
- TelegramClient.get_chat added (CLAUDE.md rule 6); set_album_thumbnail added.
- Seeds: rename \"Default Commands\" → \"Default Immich Commands\";
  track_assets_removed default False.

Frontend
- Global provider selector visible when there is only one provider.
- Clear-events button + i18n + ConfirmModal on the dashboard; new icons/
  labels/filters/colors for action_success / action_partial / action_failed.
- Auto-select first available tracking/template/command/config + bot on
  create forms (trackers, command-trackers, targets, template/command
  configs).
- Telegram target disable_url_preview defaults to true.
- BlockedByModal wired into 8 deletion flows; fetchAuth helper for
  multipart/binary calls (reuses api()'s refresh + ApiError mapping).
- Immich tracker 'Checking links' parallelised (concurrency cap 6).
- Backup page: pending-restore banner + Apply-now / Apply-later modal,
  restarting overlay polling /api/health, manual 'Create backup' button.
- Command-trackers listener row gets an 'Edit album scope' modal with
  inherit/explicit multiselect.
- Users page: Edit user modal (username + role).
- parseDate helper for consistent UTC date rendering.

Migrations / schema
- event_log: + user_id, action_id, action_name (+ backfill user_id from
  notification_tracker).
- command_tracker_listener: + allowed_album_ids.
2026-04-22 01:13:11 +03:00

502 lines
19 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import { api, getBlockedBy, type BlockedByDetail } from '$lib/api';
import BlockedByModal from '$lib/components/BlockedByModal.svelte';
import { t } from '$lib/i18n';
import { sanitizePreview } from '$lib/sanitize';
import { commandTemplateConfigsCache, supportedLocalesCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import IconGridSelect from '$lib/components/IconGridSelect.svelte';
import { providerTypeItems as providerTypeItemsFn, providerTypeFilterItems } from '$lib/grid-items';
import Modal from '$lib/components/Modal.svelte';
import JinjaEditor from '$lib/components/JinjaEditor.svelte';
import CollapsibleSlot from '$lib/components/CollapsibleSlot.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import { highlightFromUrl } from '$lib/highlight';
import { globalProviderFilter } from '$lib/stores/provider-filter.svelte';
interface CmdTemplateConfig {
id: number;
user_id: number;
provider_type: string;
name: string;
description: string;
icon: string;
slots: Record<string, Record<string, string>>;
created_at: string;
}
interface SlotDef {
name: string;
description: string;
}
let LOCALES = $derived(supportedLocalesCache.items);
let allCmdTplConfigs = $state<CmdTemplateConfig[]>([]);
let filterText = $state('');
let filterType = $state('');
let effectiveType = $derived(globalProviderFilter.providerType || filterType);
let configs = $derived(allCmdTplConfigs.filter(c =>
(!filterText || c.name.toLowerCase().includes(filterText.toLowerCase())) &&
(!effectiveType || c.provider_type === effectiveType)
));
let loaded = $state(false);
let showForm = $state(false);
let editing = $state<number | null>(null);
let error = $state('');
let confirmDelete = $state<{ id: number; onconfirm: () => Promise<void> } | null>(null);
let slotPreview = $state<Record<string, string>>({});
let slotErrors = $state<Record<string, string>>({});
let slotErrorLines = $state<Record<string, number | null>>({});
let slotErrorTypes = $state<Record<string, string>>({});
let validateTimers: Record<string, ReturnType<typeof setTimeout>> = {};
/** Clean up validate timers on unmount */
onMount(() => {
return () => {
for (const timer of Object.values(validateTimers)) clearTimeout(timer);
};
});
let varsRef = $state<Record<string, any>>({});
let showVarsFor = $state<string | null>(null);
let activeLocale = $state<string>('en');
let expandedSlots = $state<Set<string>>(new Set());
let slotFilter = $state('');
let showPreviewFor = $state<Set<string>>(new Set());
function toggleSlot(key: string) {
const next = new Set(expandedSlots);
if (next.has(key)) next.delete(key); else next.add(key);
expandedSlots = next;
}
function togglePreview(key: string) {
const next = new Set(showPreviewFor);
if (next.has(key)) next.delete(key); else next.add(key);
showPreviewFor = next;
}
function getSlotStatus(key: string): 'empty' | 'valid' | 'error' | 'warning' {
if (slotErrors[key] && slotErrorTypes[key] !== 'undefined') return 'error';
if (slotErrors[key] && slotErrorTypes[key] === 'undefined') return 'warning';
if (getSlotValue(key)) return 'valid';
return 'empty';
}
const defaultForm = () => ({
provider_type: '',
name: '',
description: '',
icon: '',
slots: {} as Record<string, Record<string, string>>,
});
let form = $state(defaultForm());
// Provider capabilities
let allCapabilities = $state<Record<string, any>>({});
let providerTypes = $derived(Object.keys(allCapabilities));
let commandSlots = $derived<SlotDef[]>(
allCapabilities[form.provider_type]?.command_slots || []
);
let filteredCmdSlots = $derived(
slotFilter
? commandSlots.filter(s => s.name.toLowerCase().includes(slotFilter.toLowerCase()) || s.description.toLowerCase().includes(slotFilter.toLowerCase()))
: commandSlots
);
/** Get slot template for current locale, with fallback. */
function getSlotValue(slotName: string): string {
return form.slots[slotName]?.[activeLocale] || '';
}
/** Set slot template for current locale (immutable update). */
function setSlotValue(slotName: string, value: string) {
form.slots = {
...form.slots,
[slotName]: { ...(form.slots[slotName] || {}), [activeLocale]: value }
};
}
onMount(load);
async function load() {
try {
const [cfgs, caps, vars] = await Promise.all([
commandTemplateConfigsCache.fetch(true),
api('/providers/capabilities'),
api('/command-template-configs/variables'),
supportedLocalesCache.fetch(),
]);
allCmdTplConfigs = cfgs;
allCapabilities = caps;
varsRef = vars;
} catch (err: any) {
error = err.message || t('common.loadError');
snackError(error);
} finally {
loaded = true;
highlightFromUrl();
}
}
function validateSlot(slotKey: string, template: string, immediate = false) {
if (validateTimers[slotKey]) clearTimeout(validateTimers[slotKey]);
if (!template) {
slotErrors = { ...slotErrors, [slotKey]: '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: '' };
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
return;
}
const doValidate = async () => {
try {
const res = await api('/command-template-configs/preview-raw', {
method: 'POST',
body: JSON.stringify({ template }),
});
slotErrors = { ...slotErrors, [slotKey]: res.error || '' };
slotErrorLines = { ...slotErrorLines, [slotKey]: res.error_line || null };
slotErrorTypes = { ...slotErrorTypes, [slotKey]: res.error_type || '' };
if (res.rendered) {
slotPreview = { ...slotPreview, [slotKey]: res.rendered };
} else {
const { [slotKey]: _, ...rest } = slotPreview;
slotPreview = rest;
}
} catch {
slotErrors = { ...slotErrors, [slotKey]: '' };
}
};
if (immediate) doValidate();
else validateTimers[slotKey] = setTimeout(doValidate, 800);
}
function refreshAllPreviews() {
for (const slot of commandSlots) {
const template = getSlotValue(slot.name);
if (template) validateSlot(slot.name, template, true);
}
}
function openNew() {
form = defaultForm();
const typesWithCmdSlots = providerTypes.filter(t => (allCapabilities[t]?.command_slots?.length || 0) > 0);
if (typesWithCmdSlots.length > 0) form.provider_type = typesWithCmdSlots[0];
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
showPreviewFor = new Set();
slotFilter = '';
}
function edit(c: CmdTemplateConfig) {
// Deep copy nested slots
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = {
provider_type: c.provider_type,
name: c.name,
description: c.description || '',
icon: c.icon || '',
slots: slotsCopy,
};
editing = c.id;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
showPreviewFor = new Set();
slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100);
}
async function save(e: SubmitEvent) {
e.preventDefault();
error = '';
try {
if (editing) {
await api(`/command-template-configs/${editing}`, { method: 'PUT', body: JSON.stringify(form) });
} else {
await api('/command-template-configs', { method: 'POST', body: JSON.stringify(form) });
}
showForm = false;
editing = null;
await load();
snackSuccess(t('snack.cmdTemplateSaved'));
} catch (err: any) {
error = err.message;
snackError(err.message);
}
}
function clone(c: CmdTemplateConfig) {
const slotsCopy: Record<string, Record<string, string>> = {};
for (const [k, v] of Object.entries(c.slots)) {
slotsCopy[k] = { ...v };
}
form = {
provider_type: c.provider_type,
name: `${c.name} (Copy)`,
description: c.description || '',
icon: c.icon || '',
slots: slotsCopy,
};
editing = null;
showForm = true;
activeLocale = 'en';
slotPreview = {};
slotErrors = {};
expandedSlots = new Set();
showPreviewFor = new Set();
slotFilter = '';
setTimeout(() => refreshAllPreviews(), 100);
}
let blockedBy = $state<BlockedByDetail | null>(null);
function remove(id: number) {
confirmDelete = {
id,
onconfirm: async () => {
try {
await api(`/command-template-configs/${id}`, { method: 'DELETE' });
await load();
snackSuccess(t('snack.cmdTemplateDeleted'));
} catch (err: any) {
const bb = getBlockedBy(err);
if (bb) { blockedBy = bb; return; }
error = err.message;
snackError(err.message);
} finally {
confirmDelete = null;
}
},
};
}
</script>
<PageHeader title={t('cmdTemplateConfig.title')} description={t('cmdTemplateConfig.description')}>
<button onclick={() => { showForm ? (showForm = false, editing = null) : openNew(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showForm ? t('common.cancel') : t('cmdTemplateConfig.newConfig')}
</button>
</PageHeader>
{#if !loaded}<Loading />{:else}
{#if showForm}
<div in:slide={{ duration: 200 }}>
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={save} class="space-y-5">
<div>
<label for="ct-name" class="block text-sm font-medium mb-1">{t('cmdTemplateConfig.name')}</label>
<div class="flex gap-2">
<IconPicker value={form.icon} onselect={(v: string) => form.icon = v} />
<input id="ct-name" bind:value={form.name} required placeholder={t('cmdTemplateConfig.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="ct-desc" class="block text-sm font-medium mb-1">{t('common.description')}</label>
<input id="ct-desc" bind:value={form.description} placeholder={t('cmdTemplateConfig.descriptionPlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{#if !editing}
<div>
<label class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</label>
<IconGridSelect items={providerTypeItemsFn()} bind:value={form.provider_type} columns={2} />
</div>
{:else}
<div>
<span class="block text-sm font-medium mb-1">{t('templateConfig.providerType')}</span>
<span class="text-sm text-[var(--color-muted-foreground)]">{allCapabilities[form.provider_type]?.display_name || form.provider_type}</span>
</div>
{/if}
<fieldset class="border border-[var(--color-border)] rounded-md p-3">
<legend class="text-sm font-medium px-1">{t('cmdTemplateConfig.commandResponses')}</legend>
<p class="text-xs text-[var(--color-muted-foreground)] mb-2">{t('cmdTemplateConfig.commandResponsesHint')}</p>
<!-- Locale tabs -->
<div class="flex gap-1 mb-3 border-b border-[var(--color-border)]">
{#each LOCALES as loc}
<button type="button"
class="px-3 py-1.5 text-xs font-medium rounded-t-md transition-colors {activeLocale === loc ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}"
onclick={() => { activeLocale = loc; refreshAllPreviews(); }}>
{loc.toUpperCase()}
</button>
{/each}
</div>
<!-- Slot filter -->
{#if commandSlots.length > 4}
<div class="mb-3">
<input type="text" bind:value={slotFilter} placeholder={t('templateConfig.filterSlots')}
class="w-full px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
{/if}
<div class="space-y-2">
{#each filteredCmdSlots as slot}
<CollapsibleSlot
label={slot.name}
description="/{slot.name} {slot.description}"
expanded={expandedSlots.has(slot.name)}
status={getSlotStatus(slot.name)}
ontoggle={() => toggleSlot(slot.name)}
>
<div class="flex items-center justify-end gap-2 mb-2">
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
<button type="button" onclick={() => togglePreview(slot.name)}
class="text-xs px-2 py-0.5 rounded-md transition-colors {showPreviewFor.has(slot.name) ? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]' : 'text-[var(--color-muted-foreground)] hover:bg-[var(--color-muted)]'}">
{t('templateConfig.preview')}
</button>
{/if}
{#if varsRef[slot.name]}
<button type="button" onclick={() => showVarsFor = slot.name}
class="text-xs text-[var(--color-muted-foreground)] hover:underline">{t('templateConfig.variables')}</button>
{/if}
</div>
{#if showPreviewFor.has(slot.name) && slotPreview[slot.name] && !slotErrors[slot.name]}
<div class="p-2 bg-[var(--color-muted)] rounded text-sm mb-2">
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
</div>
{:else}
<JinjaEditor
value={getSlotValue(slot.name)}
onchange={(v: string) => { setSlotValue(slot.name, v); validateSlot(slot.name, v); }}
rows={3}
errorLine={slotErrorLines[slot.name] || null}
variables={varsRef[slot.name] || undefined}
/>
{/if}
{#if slotErrors[slot.name]}
{#if slotErrorTypes[slot.name] === 'undefined'}
<p class="mt-1 text-xs" style="color: #d97706;">{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}
{/if}
</CollapsibleSlot>
{/each}
</div>
</fieldset>
<button type="submit" class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{editing ? t('common.save') : t('common.create')}
</button>
</form>
</Card>
</div>
{/if}
{#if !showForm && allCmdTplConfigs.length > 0}
<div class="flex items-center gap-2 mb-3">
<input type="text" bind:value={filterText} placeholder={t('common.filterByName')}
class="flex-1 px-3 py-1.5 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
{#if !globalProviderFilter.id}
<div class="w-48">
<IconGridSelect items={providerTypeFilterItems()} bind:value={filterType} columns={2} compact />
</div>
{/if}
</div>
{/if}
{#if allCmdTplConfigs.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiConsoleLine" message={t('cmdTemplateConfig.noConfigs')} />
</Card>
{:else if configs.length === 0 && !showForm}
<Card>
<EmptyState icon="mdiFilterOff" message={t('common.noFilterResults')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each configs as config}
<Card hover entityId={config.id}>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={config.icon || 'mdiConsoleLine'} size={20} /></span>
<p class="font-medium">{config.name}</p>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{config.provider_type}</span>
{#if config.user_id === 0}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{t('common.system')}</span>
{/if}
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{Object.keys(config.slots).length} {t('templateConfig.slots')}</span>
</div>
{#if config.description}
<p class="text-sm text-[var(--color-muted-foreground)] mt-1">{config.description}</p>
{/if}
</div>
<div class="flex items-center gap-1 ml-4">
<IconButton icon="mdiContentCopy" title={t('common.clone')} onclick={() => clone(config)} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => edit(config)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => remove(config.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
{/if}
<ConfirmModal open={confirmDelete !== null} message={t('cmdTemplateConfig.confirmDelete')}
onconfirm={() => confirmDelete?.onconfirm()} oncancel={() => confirmDelete = null} />
<BlockedByModal open={!!blockedBy} detail={blockedBy} onclose={() => blockedBy = null} />
<!-- Variables reference modal -->
<Modal open={showVarsFor !== null} title="{t('templateConfig.variables')}: /{showVarsFor || ''}" onclose={() => showVarsFor = null}>
{#if showVarsFor && varsRef[showVarsFor]}
<p class="text-sm text-[var(--color-muted-foreground)] mb-3">{varsRef[showVarsFor].description}</p>
<div class="space-y-1">
<p class="text-xs font-medium mb-1">{t('templateConfig.variables')}:</p>
{#each Object.entries(varsRef[showVarsFor].variables || {}) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
</div>
{/each}
</div>
{#each [
['asset_fields', 'asset', 'Asset fields'],
['album_fields', 'album', 'Album fields'],
['command_fields', 'cmd', 'Command fields'],
['event_fields', 'event', 'Event fields'],
] as [fieldKey, prefix, title]}
{#if varsRef[showVarsFor][fieldKey]}
<div class="mt-3 pt-3 border-t border-[var(--color-border)]">
<p class="text-xs font-medium mb-1">{title} <span class="font-normal text-[var(--color-muted-foreground)]">(use {prefix}.field)</span>:</p>
{#each Object.entries(varsRef[showVarsFor][fieldKey]) as [name, desc]}
<div class="flex items-start gap-2 text-sm">
<code class="text-xs bg-[var(--color-muted)] px-1 py-0.5 rounded font-mono whitespace-nowrap">{'{{ ' + prefix + '.' + name + ' }}'}</code>
<span class="text-xs text-[var(--color-muted-foreground)]">{desc}</span>
</div>
{/each}
</div>
{/if}
{/each}
{/if}
</Modal>