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
+164 -24
View File
@@ -1,12 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api';
import { api, fetchAuth } from '$lib/api';
import { t } from '$lib/i18n';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import Loading from '$lib/components/Loading.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import Hint from '$lib/components/Hint.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import Button from '$lib/components/Button.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
@@ -60,15 +59,23 @@
let backupFiles = $state<any[]>([]);
let loadingFiles = $state(false);
let confirmDeleteFile = $state('');
let creatingBackup = $state(false);
// --- Pending restore state ---
let pending = $state<{ pending: boolean; uploaded_at?: string | null; uploaded_by?: string | null; conflict_mode?: string; supervised?: boolean } | null>(null);
let postRestoreModalOpen = $state(false);
let restartingOverlay = $state(false);
onMount(async () => {
try {
const [settings, files] = await Promise.all([
const [settings, files, p] = await Promise.all([
api('/backup/scheduled'),
api('/backup/files'),
api('/backup/pending-restore'),
]);
scheduledSettings = settings;
backupFiles = files;
pending = p;
} catch (err: any) {
error = err.message;
snackError(err.message);
@@ -77,6 +84,53 @@
}
});
async function cancelPending() {
try {
await api('/backup/pending-restore', { method: 'DELETE' });
snackSuccess(t('backup.pendingCancelled'));
pending = null;
} catch (err: any) { snackError(err.message); }
}
async function applyAndRestart() {
try {
await api('/backup/apply-restart', { method: 'POST' });
restartingOverlay = true;
// Poll /health until the new instance is up
const startedAt = Date.now();
let attempts = 0;
const poll = async () => {
attempts += 1;
try {
const res = await fetch('/api/health');
if (res.ok && Date.now() - startedAt > 2000) {
window.location.reload();
return;
}
} catch { /* still down */ }
if (attempts < 120) setTimeout(poll, 1000);
};
setTimeout(poll, 1500);
} catch (err: any) {
restartingOverlay = false;
snackError(err.message);
}
}
async function createManualBackup() {
creatingBackup = true;
try {
const mode = scheduledSettings.backup_secrets_mode || 'exclude';
await api(`/backup/files?secrets_mode=${mode}`, { method: 'POST' });
snackSuccess(t('backup.manualCreated'));
await refreshFiles();
} catch (err: any) {
snackError(err.message);
} finally {
creatingBackup = false;
}
}
// --- Export ---
async function doExport() {
if (exportSecrets === 'include') {
@@ -120,16 +174,7 @@
try {
const formData = new FormData();
formData.append('file', importFile);
const token = localStorage.getItem('access_token');
const res = await fetch('/api/backup/validate', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
const res = await fetchAuth('/backup/validate', { method: 'POST', body: formData });
validationResult = await res.json();
} catch (err: any) {
snackError(err.message);
@@ -151,18 +196,15 @@
try {
const formData = new FormData();
formData.append('file', importFile);
const token = localStorage.getItem('access_token');
const res = await fetch(`/api/backup/import?conflict_mode=${importConflict}`, {
const res = await fetchAuth(`/backup/prepare-restore?conflict_mode=${importConflict}`, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || `HTTP ${res.status}`);
}
importResult = await res.json();
snackSuccess(t('backup.importSuccess'));
pending = importResult;
snackSuccess(t('backup.restorePrepared'));
postRestoreModalOpen = true;
importFile = null;
} catch (err: any) {
snackError(err.message);
} finally {
@@ -256,6 +298,33 @@
<Loading />
{:else}
<ErrorBanner message={error} />
{#if pending?.pending}
<div class="mb-4 p-3 rounded-lg flex flex-wrap items-center gap-3 pending-banner"
style="border: 1px solid color-mix(in srgb, var(--color-warning-fg) 40%, transparent); background: color-mix(in srgb, var(--color-warning-bg) 60%, transparent);">
<span style="color: var(--color-warning-fg); flex-shrink: 0;">
<MdiIcon name="mdiClockAlert" size={20} />
</span>
<div class="flex-1 min-w-[12rem] text-sm">
<div class="font-medium">{t('backup.pendingTitle')}</div>
<div class="text-xs break-words" style="color: var(--color-muted-foreground);">
{t('backup.pendingBy').replace('{by}', pending.uploaded_by || '')} · {t('backup.pendingAt').replace('{at}', pending.uploaded_at || '')}
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
{#if pending.supervised}
<Button size="sm" onclick={applyAndRestart}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
<button onclick={cancelPending}
class="px-3 py-1.5 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('common.cancel')}
</button>
</div>
</div>
{/if}
<div class="space-y-6">
<!-- Export Section -->
@@ -502,9 +571,14 @@
<MdiIcon name="mdiFolder" size={18} />
{t('backup.savedFiles')}
</h3>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
<div class="flex items-center gap-2">
<Button size="sm" onclick={createManualBackup} disabled={creatingBackup}>
<MdiIcon name="mdiPlus" size={14} /> {creatingBackup ? t('common.loading') : t('backup.createManual')}
</Button>
<button onclick={refreshFiles} class="text-xs" style="color: var(--color-primary);" disabled={loadingFiles}>
<MdiIcon name="mdiRefresh" size={14} />
</button>
</div>
</div>
{#if backupFiles.length === 0}
@@ -568,3 +642,69 @@
onconfirm={() => deleteFile(confirmDeleteFile)}
oncancel={() => confirmDeleteFile = ''}
/>
<!-- Post-restore modal: Apply now or later -->
<svelte:window onkeydown={postRestoreModalOpen ? (e) => { if (e.key === 'Escape') postRestoreModalOpen = false; } : undefined} />
{#if postRestoreModalOpen && pending?.pending}
<div class="post-restore-backdrop"
style="position: fixed; inset: 0; z-index: 50; background: rgba(0,0,0,0.5); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; padding: 1rem;"
onclick={() => postRestoreModalOpen = false}
onkeydown={(e) => { if (e.key === 'Escape') postRestoreModalOpen = false; }}
role="presentation">
<div role="dialog" aria-modal="true" aria-labelledby="post-restore-title" tabindex="-1"
style="background: var(--color-card); border: 1px solid var(--color-border); border-radius: 1rem; padding: 1.5rem; max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.4);"
onclick={(e) => e.stopPropagation()}>
<div class="flex items-start gap-3 mb-4">
<div class="flex items-center justify-center w-10 h-10 rounded-full flex-shrink-0"
style="background: var(--color-warning-bg); color: var(--color-warning-fg);">
<MdiIcon name="mdiClockAlert" size={22} />
</div>
<div class="min-w-0">
<h3 id="post-restore-title" class="font-semibold mb-1">{t('backup.restorePrepared')}</h3>
<p class="text-sm break-words" style="color: var(--color-muted-foreground);">{t('backup.restoreApplyPrompt')}</p>
</div>
</div>
<div class="flex gap-2 justify-end flex-wrap">
<button onclick={() => postRestoreModalOpen = false}
class="px-3 py-2 text-sm rounded-md border border-[var(--color-border)] hover:bg-[var(--color-muted)] transition-colors">
{t('backup.applyLater')}
</button>
{#if pending.supervised}
<Button size="sm" onclick={() => { postRestoreModalOpen = false; applyAndRestart(); }}>
<MdiIcon name="mdiRestart" size={14} /> {t('backup.restartNow')}
</Button>
{/if}
</div>
</div>
</div>
{/if}
<!-- Restarting overlay -->
{#if restartingOverlay}
<div role="alert" aria-live="assertive"
style="position: fixed; inset: 0; z-index: 60; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); padding: 1rem;">
<div class="text-center p-6" style="color: var(--color-foreground);">
<div class="restart-spinner" style="color: var(--color-primary); margin-bottom: 1rem;">
<MdiIcon name="mdiRestart" size={40} />
</div>
<p class="text-lg font-semibold">{t('backup.restartingTitle')}</p>
<p class="text-sm mt-2" style="color: var(--color-muted-foreground);">{t('backup.restartingDescription')}</p>
</div>
</div>
{/if}
<style>
.restart-spinner {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
animation: restart-spin 1.2s linear infinite;
transform-origin: center center;
}
@keyframes restart-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>