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.
This commit is contained in:
2026-04-22 01:13:11 +03:00
parent b5ffab7ece
commit a7a2b4efa4
57 changed files with 2452 additions and 335 deletions
+96 -1
View File
@@ -11,6 +11,37 @@ export function errMsg(err: unknown, fallback = 'Unexpected error'): string {
return fallback;
}
/** Structured 409 blocked-by payload attached to ApiError.blockedBy. */
export interface BlockedByDetail {
message: string;
entity: string;
blocked_by: string[];
}
export class ApiError extends Error {
status: number;
blockedBy?: BlockedByDetail;
constructor(message: string, status: number, blockedBy?: BlockedByDetail) {
super(message);
this.name = 'ApiError';
this.status = status;
this.blockedBy = blockedBy;
}
}
/** Parse a server-issued datetime string as UTC (appends Z if no timezone info present). */
export function parseDate(dateStr: string): Date {
if (!dateStr) return new Date(NaN);
if (!/Z$|[+-]\d{2}:?\d{2}$/.test(dateStr)) return new Date(dateStr + 'Z');
return new Date(dateStr);
}
/** If the thrown error was a structured 409 from delete_protection, return its payload. */
export function getBlockedBy(err: unknown): BlockedByDetail | null {
if (err instanceof ApiError && err.blockedBy) return err.blockedBy;
return null;
}
function getToken(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem('access_token');
@@ -106,7 +137,17 @@ export async function api<T = any>(
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
// Structured blocked-by detail (from delete_protection.raise_if_used)
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res.json();
@@ -114,3 +155,57 @@ export async function api<T = any>(
clearTimeout(timeout);
}
}
/**
* Auth-aware ``fetch`` wrapper for calls that can't go through ``api()`` —
* typically multipart/form-data uploads or binary downloads where we need the
* raw ``Response`` object rather than parsed JSON.
*
* - Injects the Bearer token automatically.
* - Does NOT set ``Content-Type`` (the caller's body — e.g. ``FormData`` —
* decides the encoding; browsers add the boundary).
* - Attempts a one-shot token refresh on 401, matching ``api()``.
* - Translates non-OK responses to ``ApiError`` so callers can use the same
* ``getBlockedBy`` / ``err.message`` handling pattern.
*/
export async function fetchAuth(
path: string,
options: RequestInit = {},
): Promise<Response> {
const token = getToken();
const headers: Record<string, string> = { ...(options.headers as Record<string, string>) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
let res = await fetch(url, { ...options, headers });
if (res.status === 401 && token) {
const refreshed = await refreshAccessToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${getToken()}`;
res = await fetch(url, { ...options, headers });
}
}
if (res.status === 401) {
clearTokens();
if (typeof window !== 'undefined') window.location.href = '/login';
throw new ApiError('Unauthorized', 401);
}
if (!res.ok) {
const err = await res.clone().json().catch(() => ({ detail: res.statusText }));
if (err && err.detail && typeof err.detail === 'object' && Array.isArray(err.detail.blocked_by)) {
const bb: BlockedByDetail = {
message: err.detail.message || `HTTP ${res.status}`,
entity: err.detail.entity || '',
blocked_by: err.detail.blocked_by,
};
throw new ApiError(bb.message, res.status, bb);
}
const msg = typeof err.detail === 'string' ? err.detail : (err.detail?.message || `HTTP ${res.status}`);
throw new ApiError(msg, res.status);
}
return res;
}
@@ -0,0 +1,74 @@
<script lang="ts">
import Modal from './Modal.svelte';
import MdiIcon from './MdiIcon.svelte';
import { t } from '$lib/i18n';
import type { BlockedByDetail } from '$lib/api';
let { open = false, detail = null, onclose } = $props<{
open: boolean;
detail: BlockedByDetail | null;
onclose: () => void;
}>();
const blockedCount = $derived(detail?.blocked_by?.length ?? 0);
</script>
<Modal {open} title={t('common.cannotDelete')} onclose={onclose}>
{#if detail}
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-9 h-9 rounded-full flex-shrink-0"
style="background: var(--color-error-bg); color: var(--color-error-fg);">
<MdiIcon name="mdiLinkVariant" size={20} />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium mb-1 break-words">{detail.message}</p>
{#if detail.entity}
<p class="text-xs break-all" style="color: var(--color-muted-foreground);">{detail.entity}</p>
{/if}
</div>
</div>
<div class="flex items-center justify-between mb-2">
<p class="text-xs" style="color: var(--color-muted-foreground);">{t('common.blockedByIntro')}</p>
{#if blockedCount > 0}
<span class="text-[0.65rem] font-mono px-1.5 py-0.5 rounded"
style="background: var(--color-muted); color: var(--color-muted-foreground);">
{blockedCount}
</span>
{/if}
</div>
{#if blockedCount > 0}
<ul class="space-y-1.5 max-h-64 overflow-y-auto pr-1 mb-5">
{#each detail.blocked_by as consumer}
<li class="flex items-start gap-2 text-sm px-3 py-2 rounded-md"
style="background: var(--color-muted); border: 1px solid var(--color-border);">
<span class="flex-shrink-0 mt-0.5" style="color: var(--color-muted-foreground);">
<MdiIcon name="mdiChevronRight" size={14} />
</span>
<span class="font-mono text-xs break-all min-w-0 flex-1">{consumer}</span>
</li>
{/each}
</ul>
{/if}
{/if}
<div class="flex justify-end">
<button onclick={onclose} class="blocked-by-close-btn">
{t('common.close')}
</button>
</div>
</Modal>
<style>
.blocked-by-close-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
border: 1px solid var(--color-border);
background: transparent;
color: var(--color-foreground);
cursor: pointer;
transition: all 0.2s ease;
}
.blocked-by-close-btn:hover {
background: var(--color-muted);
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { t } from '$lib/i18n';
import { parseDate } from '$lib/api';
import MdiIcon from './MdiIcon.svelte';
interface DayData {
@@ -47,7 +48,7 @@
const activeTypes = $derived(EVENT_TYPES.filter(et => days.some(d => (d[et] as number) > 0)));
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const d = parseDate(dateStr);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
+3
View File
@@ -89,6 +89,9 @@ export const eventTypeFilterItems = (): GridItem[] => [
{ value: 'collection_renamed', icon: 'mdiRename', label: t('dashboard.filterRenamed'), desc: t('gridDesc.renamed') },
{ value: 'collection_deleted', icon: 'mdiDeleteAlert', label: t('dashboard.filterDeleted'), desc: t('gridDesc.deleted') },
{ value: 'sharing_changed', icon: 'mdiShareVariant', label: t('dashboard.filterSharingChanged'), desc: t('gridDesc.sharingChanged') },
{ value: 'action_success', icon: 'mdiPlayCircle', label: t('dashboard.filterActionSuccess'), desc: t('gridDesc.actionSuccess') },
{ value: 'action_partial', icon: 'mdiAlertCircle', label: t('dashboard.filterActionPartial'), desc: t('gridDesc.actionPartial') },
{ value: 'action_failed', icon: 'mdiCloseCircle', label: t('dashboard.filterActionFailed'), desc: t('gridDesc.actionFailed') },
];
// --- Sort filter (dashboard) ---
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Active Trackers",
"targets": "Targets",
"recentEvents": "Events",
"clearEvents": "Clear",
"confirmClearEvents": "Delete all event log entries? This cannot be undone.",
"chart": "Event chart",
"noEvents": "No events yet. Create a tracker to start monitoring.",
"loading": "Loading...",
@@ -76,6 +78,9 @@
"collectionRenamed": "collection renamed",
"collectionDeleted": "collection deleted",
"sharingChanged": "sharing changed",
"actionSuccess": "action run",
"actionPartial": "action partial",
"actionFailed": "action failed",
"searchEvents": "Search events...",
"allEvents": "All Events",
"filterAssetsAdded": "Assets Added",
@@ -83,6 +88,9 @@
"filterRenamed": "Renamed",
"filterDeleted": "Deleted",
"filterSharingChanged": "Sharing Changed",
"filterActionSuccess": "Action Success",
"filterActionPartial": "Action Partial",
"filterActionFailed": "Action Failed",
"allProviders": "All Providers",
"newestFirst": "Newest first",
"oldestFirst": "Oldest first",
@@ -365,6 +373,7 @@
"roleAdmin": "Admin",
"create": "Create User",
"delete": "Delete",
"edit": "Edit user",
"confirmDelete": "Delete this user?",
"joined": "joined",
"noUsers": "No users found"
@@ -785,13 +794,21 @@
"disabled": "Disabled",
"noListeners": "No listeners attached.",
"selectBot": "Select bot...",
"listenerType": "telegram_bot"
"listenerType": "telegram_bot",
"editScope": "Edit album scope",
"scopeAll": "all albums",
"albumsShort": "albums",
"scopeTitle": "Album Scope for This Chat",
"scopeDescription": "Restrict which tracked albums this chat can query via commands. Leave on \"inherit\" to allow all albums from the tracker.",
"scopeInherit": "Inherit: allow all tracked albums",
"noCollections": "No albums available."
},
"snackbar": {
"showDetails": "Show details",
"hideDetails": "Hide details"
},
"snack": {
"eventsCleared": "{count} event(s) cleared",
"providerSaved": "Provider saved",
"providerDeleted": "Provider deleted",
"trackerCreated": "Tracker created",
@@ -810,6 +827,7 @@
"botDeleted": "Bot deleted",
"userCreated": "User created",
"userDeleted": "User deleted",
"userUpdated": "User updated",
"passwordChanged": "Password changed",
"copied": "Copied to clipboard",
"genericError": "Something went wrong",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Command tracker disabled",
"listenerAdded": "Listener added",
"listenerRemoved": "Listener removed",
"listenerScopeSaved": "Scope updated",
"cmdTemplateSaved": "Command template saved",
"cmdTemplateDeleted": "Command template deleted",
"emailBotCreated": "Email bot created",
@@ -848,6 +867,8 @@
"description": "Description",
"close": "Close",
"confirm": "Confirm",
"cannotDelete": "Cannot delete",
"blockedByIntro": "Referenced by:",
"error": "Error",
"success": "Success",
"none": "None",
@@ -960,6 +981,9 @@
"renamed": "Album was renamed",
"deleted": "Album was deleted",
"sharingChanged": "Album sharing toggled",
"actionSuccess": "Scheduled action completed",
"actionPartial": "Scheduled action partially succeeded",
"actionFailed": "Scheduled action failed",
"newestFirst": "Most recent events on top",
"oldestFirst": "Oldest events on top",
"chatActionNone": "No indicator shown",
@@ -1021,6 +1045,7 @@
"name": "Name",
"schedule": "Schedule",
"interval": "Interval",
"cronMode": "Cron expression",
"seconds": "seconds",
"cronHint": "Standard cron expression (e.g. 0 3 * * * for daily at 3 AM)",
"enabled": "Enabled",
@@ -1126,6 +1151,18 @@
"savedFiles": "Saved Backups",
"noFiles": "No backup files yet.",
"download": "Download",
"fileDeleted": "Backup file deleted"
"fileDeleted": "Backup file deleted",
"createManual": "Create backup",
"manualCreated": "Backup created",
"pendingTitle": "Restore pending — restart to apply",
"pendingBy": "Uploaded by {by}",
"pendingAt": "at {at}",
"pendingCancelled": "Pending restore cancelled",
"restorePrepared": "Restore prepared",
"restoreApplyPrompt": "Apply the restore now (the backend will restart) or later on the next natural restart?",
"applyLater": "Apply later",
"restartNow": "Restart now",
"restartingTitle": "Restarting backend…",
"restartingDescription": "The page will reload once the server is back online."
}
}
+39 -2
View File
@@ -64,6 +64,8 @@
"activeTrackers": "Активные трекеры",
"targets": "Получатели",
"recentEvents": "События",
"clearEvents": "Очистить",
"confirmClearEvents": "Удалить все записи журнала событий? Это действие нельзя отменить.",
"chart": "График событий",
"noEvents": "Событий пока нет. Создайте трекер для отслеживания.",
"loading": "Загрузка...",
@@ -76,6 +78,9 @@
"collectionRenamed": "альбом переименован",
"collectionDeleted": "альбом удалён",
"sharingChanged": "изменение доступа",
"actionSuccess": "действие выполнено",
"actionPartial": "действие частично",
"actionFailed": "действие провалено",
"searchEvents": "Поиск событий...",
"allEvents": "Все события",
"filterAssetsAdded": "Добавление файлов",
@@ -83,6 +88,9 @@
"filterRenamed": "Переименование",
"filterDeleted": "Удаление",
"filterSharingChanged": "Изменение доступа",
"filterActionSuccess": "Действие выполнено",
"filterActionPartial": "Действие частично",
"filterActionFailed": "Действие провалено",
"allProviders": "Все провайдеры",
"newestFirst": "Сначала новые",
"oldestFirst": "Сначала старые",
@@ -365,6 +373,7 @@
"roleAdmin": "Администратор",
"create": "Создать",
"delete": "Удалить",
"edit": "Редактировать пользователя",
"confirmDelete": "Удалить этого пользователя?",
"joined": "зарегистрирован",
"noUsers": "Пользователи не найдены"
@@ -785,13 +794,21 @@
"disabled": "Отключён",
"noListeners": "Нет подключённых слушателей.",
"selectBot": "Выберите бота...",
"listenerType": "telegram_bot"
"listenerType": "telegram_bot",
"editScope": "Изменить область альбомов",
"scopeAll": "все альбомы",
"albumsShort": "альбомов",
"scopeTitle": "Область альбомов для этого чата",
"scopeDescription": "Ограничить, какие отслеживаемые альбомы доступны в этом чате через команды. Оставьте \"наследовать\", чтобы разрешить все альбомы трекера.",
"scopeInherit": "Наследовать: разрешить все отслеживаемые альбомы",
"noCollections": "Нет доступных альбомов."
},
"snackbar": {
"showDetails": "Показать детали",
"hideDetails": "Скрыть детали"
},
"snack": {
"eventsCleared": "Очищено событий: {count}",
"providerSaved": "Провайдер сохранён",
"providerDeleted": "Провайдер удалён",
"trackerCreated": "Трекер создан",
@@ -810,6 +827,7 @@
"botDeleted": "Бот удалён",
"userCreated": "Пользователь создан",
"userDeleted": "Пользователь удалён",
"userUpdated": "Пользователь обновлён",
"passwordChanged": "Пароль изменён",
"copied": "Скопировано",
"genericError": "Что-то пошло не так",
@@ -827,6 +845,7 @@
"commandTrackerDisabled": "Трекер команд отключён",
"listenerAdded": "Слушатель добавлен",
"listenerRemoved": "Слушатель удалён",
"listenerScopeSaved": "Область обновлена",
"cmdTemplateSaved": "Шаблон команд сохранён",
"cmdTemplateDeleted": "Шаблон команд удалён",
"emailBotCreated": "Email бот создан",
@@ -848,6 +867,8 @@
"description": "Описание",
"close": "Закрыть",
"confirm": "Подтвердить",
"cannotDelete": "Невозможно удалить",
"blockedByIntro": "На объект ссылаются:",
"error": "Ошибка",
"success": "Успешно",
"none": "Нет",
@@ -960,6 +981,9 @@
"renamed": "Альбом переименован",
"deleted": "Альбом удалён",
"sharingChanged": "Изменён доступ к альбому",
"actionSuccess": "Запланированное действие выполнено",
"actionPartial": "Запланированное действие выполнено частично",
"actionFailed": "Запланированное действие провалено",
"newestFirst": "Сначала новые события",
"oldestFirst": "Сначала старые события",
"chatActionNone": "Индикатор не показывается",
@@ -1021,6 +1045,7 @@
"name": "Название",
"schedule": "Расписание",
"interval": "Интервал",
"cronMode": "Cron выражение",
"seconds": "секунд",
"cronHint": "Стандартное cron-выражение (напр. 0 3 * * * — ежедневно в 3:00)",
"enabled": "Включено",
@@ -1126,6 +1151,18 @@
"savedFiles": "Сохранённые бэкапы",
"noFiles": "Файлов бэкапа пока нет.",
"download": "Скачать",
"fileDeleted": "Файл бэкапа удалён"
"fileDeleted": "Файл бэкапа удалён",
"createManual": "Создать бэкап",
"manualCreated": "Бэкап создан",
"pendingTitle": "Восстановление ожидает — перезапустите для применения",
"pendingBy": "Загружено пользователем {by}",
"pendingAt": "в {at}",
"pendingCancelled": "Ожидающее восстановление отменено",
"restorePrepared": "Восстановление подготовлено",
"restoreApplyPrompt": "Применить восстановление сейчас (бэкенд перезапустится) или позже при следующем штатном перезапуске?",
"applyLater": "Применить позже",
"restartNow": "Перезапустить сейчас",
"restartingTitle": "Перезапуск бэкенда…",
"restartingDescription": "Страница перезагрузится, как только сервер снова будет доступен."
}
}
+17 -1
View File
@@ -105,7 +105,10 @@ export const immichDescriptor: ProviderDescriptor = {
interface SharedLink { is_accessible: boolean; is_expired: boolean; has_password: boolean }
const warnings: { id: string; name: string; issue: string }[] = [];
for (const albumId of newIds) {
// Run shared-link checks in parallel with a concurrency cap so a large
// album set doesn't stall the save button for seconds.
const CONCURRENCY = 6;
async function checkOne(albumId: string): Promise<void> {
try {
const links = await apiFn<SharedLink[]>(`/providers/${form.provider_id}/albums/${albumId}/shared-links`);
const validLink = links.find((l) => l.is_accessible && !l.is_expired);
@@ -123,6 +126,19 @@ export const immichDescriptor: ProviderDescriptor = {
} catch { /* shared-link check failed, proceed */ }
}
const queue = [...newIds];
const workers: Promise<void>[] = [];
for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) {
workers.push((async () => {
while (queue.length > 0) {
const next = queue.shift();
if (next === undefined) return;
await checkOne(next);
}
})());
}
await Promise.all(workers);
if (warnings.length > 0) return { warnings, proceed: false };
return { proceed: true };
},