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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user