feat: workflow logging (#29878)

* feat: workflow logging

* chore: update sql and openapi patch

* chore: include id in workflow log entries

* chore(server): update sql and lint

* chore(mobile): remove openapi files

* chore(web): lint

* chore: clean up workflow logging

* chore(server): add runId to workflow logs

* chore(server): store workflow results as enum
This commit is contained in:
Ben Beckford 2026-08-13 19:18:24 -07:00 committed by GitHub
parent af33a78d18
commit 447cc40a50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 689 additions and 22 deletions

View file

@ -0,0 +1,171 @@
<script lang="ts">
import { type WorkflowLogEntryDto, type WorkflowResponseDto, WorkflowResult, getWorkflowLogs } from '@immich/sdk';
import {
Modal,
ModalBody,
Table,
TableHeader,
TableHeading,
TableBody,
TableRow,
TableCell,
HStack,
Icon,
VStack,
Button,
Select,
type SelectOption,
} from '@immich/ui';
import { mdiHistory, mdiOpenInNew } from '@mdi/js';
import { t } from 'svelte-i18n';
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
import { Route } from '$lib/route';
import { handleUpdateWorkflow } from '$lib/services/workflow.service';
type Props = {
workflow: WorkflowResponseDto;
onClose: () => void;
};
let { workflow, onClose }: Props = $props();
let entries: WorkflowLogEntryDto[] = $state([]);
let placeholder = $state<HTMLElement>();
let filter = $state<WorkflowResult>();
let before = $state<string>();
let hasNext = $state(true);
let loading = $state(false);
const setLogging = async (logging: boolean) => {
const success = await handleUpdateWorkflow(workflow.id, { logging });
if (!success) {
return;
}
workflow = { ...workflow, logging };
reset();
};
const reset = () => {
entries = [];
hasNext = true;
before = undefined;
};
const setFilter = (option: SelectOption<WorkflowResult>) => {
reset();
filter = option.value;
void getLogs();
};
const getLogs = async () => {
if (!hasNext || loading) {
return;
}
loading = true;
const limit = 50;
const results = await getWorkflowLogs({ id: workflow.id, result: filter, before, limit });
entries.push(...results);
if (results.length < limit) {
hasNext = false;
} else {
before = results.at(-1)?.at;
}
loading = false;
};
const observer = new IntersectionObserver((entries) => {
const entry = entries.find((entry) => entry.target === placeholder);
if (entry?.isIntersecting) {
void getLogs();
}
});
$effect(() => {
if (!placeholder) {
return;
}
observer.disconnect();
observer.observe(placeholder);
});
</script>
<Modal title={$t('logs')} icon={mdiHistory} {onClose} size="medium">
<ModalBody>
{#if workflow.logging}
<Table striped>
<TableHeader>
<TableHeading>{$t('date')}</TableHeading>
<TableHeading>{$t('result')}</TableHeading>
</TableHeader>
<TableBody class="max-h-100">
{#each entries as entry (entry.id)}
<TableRow>
<TableCell>
<HStack class="justify-center">
<p>
{DateTime.fromISO(entry.at).toLocaleString(DateTime.DATETIME_MED, {
locale: $locale,
})}
</p>
{#if entry.triggerDataId}
<a href={Route.viewAsset({ id: entry.triggerDataId })} target="_blank">
<Icon icon={mdiOpenInNew} size="20" class="text-primary" />
</a>
{/if}
</HStack>
</TableCell>
<TableCell>
<HStack class="justify-center">
{#if entry.result === WorkflowResult.Completed}
<p class="rounded-full bg-green-700 px-3 py-1 text-xs text-white">
{$t('workflow_logging_completed')}
</p>
{:else if entry.result === WorkflowResult.Halted}
<p class="rounded-full bg-gray-600 px-3 py-1 text-xs text-white">
{#if entry.lastStep}
{$t('workflow_logging_halted_step', { values: { step: entry.lastStep.index + 1 } })}
{:else}
{$t('workflow_logging_halted')}
{/if}
</p>
{:else}
<p class="rounded-full bg-red-500 px-3 py-1 text-xs text-white">
{#if entry.lastStep}
{$t('workflow_logging_error_step', { values: { step: entry.lastStep.index + 1 } })}
{:else}
{$t('error')}
{/if}
</p>
{/if}
</HStack>
</TableCell>
</TableRow>
{/each}
{#if hasNext}
<TableRow><TableCell><div bind:this={placeholder}>...</div></TableCell></TableRow>
{/if}
</TableBody>
</Table>
<div class="mt-5 flex gap-5">
<Select
onSelect={setFilter}
class="flex-1"
placeholder={$t('filter')}
options={[
{ value: WorkflowResult.Completed, label: $t('workflow_logging_completed') },
{ value: WorkflowResult.Halted, label: $t('workflow_logging_halted') },
{ value: WorkflowResult.Error, label: $t('error') },
]}
/>
<Button class="flex-1" onclick={() => setLogging(false)}>{$t('workflow_logging_disable')}</Button>
</div>
{:else}
<VStack class="gap-5 py-5">
<p class="text-md text-center">{$t('workflow_logging_disabled_description')}</p>
<Button onclick={() => setLogging(true)}>{$t('workflow_logging_enable')}</Button>
</VStack>
{/if}
</ModalBody>
</Modal>

View file

@ -15,6 +15,7 @@ import {
mdiDeleteOutline,
mdiDownload,
mdiFileDocumentMultipleOutline,
mdiHistory,
mdiPause,
mdiPencil,
mdiPlay,
@ -24,6 +25,7 @@ import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { eventManager } from '$lib/managers/event-manager.svelte';
import WorkflowDuplicateModal from '$lib/modals/WorkflowDuplicateModal.svelte';
import WorkflowLogsModal from '$lib/modals/WorkflowLogsModal.svelte';
import WorkflowTemplatePickerModal from '$lib/modals/WorkflowTemplatePickerModal.svelte';
import { Route } from '$lib/route';
import { copyToClipboard, downloadJson } from '$lib/utils';
@ -112,7 +114,13 @@ export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowRespo
onAction: () => handleDeleteWorkflow(workflow),
};
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete };
const Logs: ActionItem = {
title: $t('check_logs'),
icon: mdiHistory,
onAction: () => modalManager.show(WorkflowLogsModal, { workflow }),
};
return { CopyJson, Download, Duplicate, ToggleEnabled, Edit, Delete, Logs };
};
export const getWorkflowShowSchemaAction = (

View file

@ -75,7 +75,7 @@
{:else}
<div class="my-6 flex flex-col gap-3">
{#each workflows as workflow (workflow.id)}
{@const { ToggleEnabled, Duplicate, Edit, Delete } = getWorkflowActions($t, workflow)}
{@const { ToggleEnabled, Duplicate, Logs, Edit, Delete } = getWorkflowActions($t, workflow)}
<Card class="group shadow-none transition-colors hover:border-primary">
<CardHeader>
@ -120,6 +120,7 @@
ToggleEnabled,
Edit,
Duplicate,
Logs,
getWorkflowShowSchemaAction($t, expandedIds.has(workflow.id), () => onToggleExpand(workflow.id)),
MenuItemType.Divider,
Delete,

View file

@ -265,7 +265,7 @@
$effect(() => console.log(steps));
const { Download, Duplicate, CopyJson, Delete } = $derived(
const { Download, Duplicate, CopyJson, Delete, Logs } = $derived(
getWorkflowActions($t, { ...savedWorkflow, name, description, enabled, trigger, steps }),
);
</script>
@ -280,7 +280,7 @@
{onClose}
translations={{ close: $t('back') }}
closeIcon={mdiArrowLeft}
actions={[Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
actions={[Logs, Duplicate, CopyJson, Download, Delete].map((item) => ({ ...item, color: undefined }))}
>
<ControlBarHeader>
<ControlBarTitle>{data.workflow.name}</ControlBarTitle>