feat: Actions system — scheduled mutations on external services

Full-stack implementation of provider-scoped Actions with extensible
executor architecture. First action type: Immich auto_organize (sort
assets into albums by person, CLIP search, date range, favorites).

Core:
- ActionTypeDefinition registry + ActionExecutor ABC with execute/validate/dry-run
- ImmichActionExecutor with multi-album support and client-side filtering
- ImmichClient write methods: add/remove assets, create album, paginated search

Server:
- Action, ActionRule, ActionExecution DB models
- Full CRUD API + manual execute + dry-run + execution history endpoints
- APScheduler integration (interval + cron) for automated execution
- Action type discovery API + provider people endpoint

Frontend:
- Actions page with CRUD, execute/dry-run buttons, inline rule editor
- RuleEditor: person/album MultiEntitySelect pickers, criteria config
- ExecutionHistory: expandable per-rule result details
- MultiEntitySelect reusable component (searchable multi-pick palette)
- Notification tracker album picker migrated to MultiEntitySelect
- Fixed MdiIcon race condition (icons missing after cache-clearing reload)
This commit is contained in:
2026-03-23 16:59:20 +03:00
parent 0fde3c6b3d
commit 6a559bfcd2
26 changed files with 2888 additions and 25 deletions
@@ -0,0 +1,114 @@
<script lang="ts">
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import type { ActionExecution } from '$lib/types';
let { actionId }: { actionId: number } = $props();
let executions = $state<ActionExecution[]>([]);
let loading = $state(true);
let expandedId = $state<number | null>(null);
$effect(() => {
loadExecutions();
});
async function loadExecutions() {
loading = true;
try {
executions = await api<ActionExecution[]>(`/actions/${actionId}/executions?limit=10`);
} catch { /* ignore */ }
loading = false;
}
function statusIcon(status: string): string {
if (status === 'success') return 'mdiCheckCircle';
if (status === 'partial') return 'mdiAlertCircle';
if (status === 'failed') return 'mdiCloseCircle';
if (status === 'running') return 'mdiLoading';
return 'mdiCircleOutline';
}
function statusColor(status: string): string {
if (status === 'success') return '#059669';
if (status === 'partial') return '#f59e0b';
if (status === 'failed') return '#ef4444';
if (status === 'running') return '#3b82f6';
return 'var(--color-muted-foreground)';
}
function triggerLabel(trigger: string): string {
if (trigger === 'manual') return t('actions.triggerManual');
if (trigger === 'dry_run') return t('actions.triggerDryRun');
return t('actions.triggerScheduled');
}
function formatDate(iso: string | null): string {
if (!iso) return '-';
try {
return new Date(iso).toLocaleString();
} catch { return iso; }
}
function formatDuration(start: string, end: string | null): string {
if (!end) return '-';
try {
const ms = new Date(end).getTime() - new Date(start).getTime();
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
} catch { return '-'; }
}
</script>
<div class="space-y-2">
<h4 class="text-xs font-semibold text-[var(--color-muted-foreground)] uppercase tracking-wide">
{t('actions.history')}
</h4>
{#if loading}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('common.loading')}...</p>
{:else if executions.length === 0}
<p class="text-xs text-[var(--color-muted-foreground)]">{t('actions.noExecutions')}</p>
{:else}
<div class="space-y-1">
{#each executions as exec}
<button onclick={() => expandedId = expandedId === exec.id ? null : exec.id}
class="w-full text-left px-2 py-1.5 rounded text-xs hover:bg-[var(--color-muted)]/50 flex items-center gap-2">
<span style="color: {statusColor(exec.status)}">
<MdiIcon name={statusIcon(exec.status)} size={14} />
</span>
<span class="flex-1">{formatDate(exec.started_at)}</span>
<span class="text-[var(--color-muted-foreground)]">{triggerLabel(exec.trigger)}</span>
<span class="font-mono">{exec.rules_succeeded}/{exec.rules_processed}</span>
<span class="text-[var(--color-muted-foreground)]">{exec.total_items_affected} {t('actions.affected')}</span>
<span class="text-[var(--color-muted-foreground)]">{formatDuration(exec.started_at, exec.finished_at)}</span>
</button>
{#if expandedId === exec.id}
<div class="ml-6 px-2 py-1.5 text-xs space-y-1 border-l-2 border-[var(--color-border)]">
{#if exec.error}
<p class="text-[var(--color-error-fg)]">{exec.error}</p>
{/if}
{#if exec.summary?.rule_results}
{#each exec.summary.rule_results as rr}
<div class="flex items-center gap-2">
<span style="color: {rr.success ? '#059669' : '#ef4444'}">
<MdiIcon name={rr.success ? 'mdiCheck' : 'mdiClose'} size={12} />
</span>
<span class="font-medium">{rr.rule_name}</span>
<span class="text-[var(--color-muted-foreground)]">
{rr.items_matched} matched, {rr.items_affected} affected, {rr.items_skipped} skipped
</span>
{#if rr.error}
<span class="text-[var(--color-error-fg)]">{rr.error}</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
{/each}
</div>
{/if}
</div>