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