Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
509 changes: 509 additions & 0 deletions api/openapi-v2.json

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions frontend/src/lib/api/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ export const actions = {
}),
getLogs: (workspaceId, actionId) =>
fetchAllV2Pages(`/workspaces/${workspaceId}/actions/${actionId}/logs`),
// Definition history: immutable, attributable revisions of the executable
// configuration. Listing returns metadata; getRevision returns the full
// snapshot so an operator can reconstruct what actually ran.
getRevisions: (workspaceId, actionId) =>
fetchAllV2Pages(`/workspaces/${workspaceId}/actions/${actionId}/revisions`),
getRevision: (workspaceId, actionId, revisionId) =>
fetchV2Data(`/workspaces/${workspaceId}/actions/${actionId}/revisions/${revisionId}`),
};

// Action templates: read-only registry shipped with the binary, plus
Expand Down
307 changes: 307 additions & 0 deletions frontend/src/lib/features/actions/ActionHistory.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
<script>
import { onMount } from 'svelte';
import StateDisplay from '../../components/StateDisplay.svelte';
import { actions } from '../../api/actions.js';
import { t } from '../../stores/i18n.svelte.js';
import DataTable from '../../components/DataTable.svelte';
import Button from '../../components/Button.svelte';
import BackButton from '../../components/BackButton.svelte';
import Badge from '../../components/Badge.svelte';
import Modal from '../../dialogs/Modal.svelte';
import ModalHeader from '../../dialogs/ModalHeader.svelte';
import Eye from '@lucide/svelte/icons/eye';
import History from '@lucide/svelte/icons/history';
import { formatAuthenticatedDateTime } from '../../utils/authenticatedDateFormatter.js';
import { surfaceError } from '../../stores/toasts.svelte.js';

let { workspaceId, action, onBack } = $props();

let revisions = $state([]);
let loading = $state(true);
let selectedRevision = $state(null);
let detailLoading = $state(false);

const columns = [
{ key: 'revision_number', label: t('actions.history.revision'), render: (item) => `#${item.revision_number}`, width: '90px' },
{ key: 'source', label: t('actions.history.source'), slot: 'source', width: '150px' },
{ key: 'editor_username', label: t('actions.history.editor'), slot: 'editor' },
{ key: 'changed_fields', label: t('actions.history.changedFields'), slot: 'changed' },
{ key: 'created_at', label: t('actions.history.createdAt'), slot: 'date', width: '180px' },
{ key: 'details', label: t('actions.logs.details'), slot: 'details', width: '100px' },
];

onMount(loadRevisions);

async function loadRevisions() {
loading = true;
try {
revisions = (await actions.getRevisions(workspaceId, action.id)) || [];
} catch (err) {
surfaceError(err, { context: 'load action revisions' });
revisions = [];
} finally {
loading = false;
}
}

async function showDetails(revision) {
selectedRevision = revision;
detailLoading = true;
try {
selectedRevision = await actions.getRevision(workspaceId, action.id, revision.id);
} catch (err) {
surfaceError(err, { context: 'load action revision' });
} finally {
detailLoading = false;
}
}

function closeDetails() {
selectedRevision = null;
}

function formatDate(dateStr) {
if (!dateStr) return '-';
return formatAuthenticatedDateTime(dateStr);
}

function changedFields(revision) {
const fields = revision?.changed_fields || [];
return fields.length > 0 ? fields.join(', ') : t('actions.history.noChangedFields');
}

function sourceLabel(source) {
return source || 'update';
}
</script>

<div class="action-history h-full flex flex-col" data-testid="action-history">
<div class="flex items-center gap-4 p-4 border-b header">
<BackButton onclick={onBack} tooltip={t('common.back')} class="p-1" />
<div class="flex-1">
<h1 class="text-xl font-medium title">{t('actions.history.title')}</h1>
<p class="text-sm subtitle">{action.name}</p>
</div>
</div>

<div class="flex-1 p-4 overflow-auto">
{#if loading}
<StateDisplay type="loading" />
{:else}
<DataTable
{columns}
data={revisions}
keyField="id"
emptyMessage={t('actions.history.noRevisions')}
emptyIcon={History}
pagination={true}
pageSize={25}
rowAttrs={(item) => ({ 'data-testid': `action-history-row-${item.id}` })}
>
{#snippet source(item)}
<Badge variant="neutral">{sourceLabel(item.source)}</Badge>
{/snippet}

{#snippet editor(item)}
{#if item.editor_username}
<span>{item.editor_username}</span>
{:else}
<span class="text-subtle">{t('actions.history.system')}</span>
{/if}
{/snippet}

{#snippet changed(item)}
<span class="changed-fields">{changedFields(item)}</span>
{/snippet}

{#snippet date(item)}
<span class="date-text whitespace-nowrap">{formatDate(item.created_at)}</span>
{/snippet}

{#snippet details(item)}
<Button
variant="ghost"
size="sm"
onclick={() => showDetails(item)}
title={t('actions.history.viewDetails')}
dataTestid={`action-history-details-${item.id}`}
>
<Eye class="w-4 h-4" />
</Button>
{/snippet}
</DataTable>
{/if}
</div>
</div>

{#if selectedRevision}
<Modal isOpen={true} onclose={closeDetails} maxWidth="max-w-2xl">
<ModalHeader
title={t('actions.history.detailTitle')}
subtitle={`${action.name} — #${selectedRevision.revision_number}`}
onClose={closeDetails}
/>
<div class="p-6 space-y-4" data-testid="action-revision-detail">
{#if detailLoading}
<StateDisplay type="loading" />
{:else}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="neutral">{sourceLabel(selectedRevision.source)}</Badge>
<span class="text-sm text-subtle">
{selectedRevision.editor_username || t('actions.history.system')}
</span>
<span class="text-sm text-subtle">{formatDate(selectedRevision.created_at)}</span>
</div>

<div>
<div class="field-label">{t('actions.history.changedFields')}</div>
<div class="changed-fields" data-testid="action-revision-changed-fields">
{changedFields(selectedRevision)}
</div>
</div>

<div>
<div class="field-label">{t('actions.history.snapshot')}</div>
<div class="snapshot">
<div class="snapshot-row">
<span class="snapshot-key">{t('common.name')}</span>
<span class="snapshot-value" data-testid="action-revision-name">
{selectedRevision.snapshot?.name}
</span>
</div>
<div class="snapshot-row">
<span class="snapshot-key">{t('actions.history.triggerType', 'Trigger')}</span>
<span class="snapshot-value">{selectedRevision.snapshot?.trigger_type}</span>
</div>
<div class="snapshot-row">
<span class="snapshot-key">{t('actions.history.nodes')}</span>
<span class="snapshot-value">
{selectedRevision.snapshot?.nodes?.length ?? 0}
</span>
</div>
<div class="snapshot-row">
<span class="snapshot-key">{t('actions.history.edges')}</span>
<span class="snapshot-value">
{selectedRevision.snapshot?.edges?.length ?? 0}
</span>
</div>
</div>
</div>

<div>
<div class="field-label">{t('actions.history.nodes')}</div>
<ul class="node-list">
{#each selectedRevision.snapshot?.nodes ?? [] as node, index}
<li class="node-item" data-testid={`action-revision-node-${index}`}>
<span class="node-type">{node.node_type}</span>
<code class="node-config">{node.node_config}</code>
</li>
{/each}
</ul>
</div>
{/if}
</div>
</Modal>
{/if}

<style>
.action-history {
background-color: var(--ds-surface);
}

.header {
border-color: var(--ds-border);
background-color: var(--ds-surface-raised);
}

.title {
color: var(--ds-text);
}

.subtitle {
color: var(--ds-text-subtle);
}

.text-subtle {
color: var(--ds-text-subtle);
}

.changed-fields {
color: var(--ds-text-subtle);
font-size: 0.8125rem;
}

.date-text {
color: var(--ds-text);
}

.field-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--ds-text-subtlest);
margin-bottom: 0.375rem;
}

.snapshot {
border: 1px solid var(--ds-border);
border-radius: 0.375rem;
overflow: hidden;
}

.snapshot-row {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--ds-border);
font-size: 0.8125rem;
}

.snapshot-row:last-child {
border-bottom: none;
}

.snapshot-key {
color: var(--ds-text-subtle);
}

.snapshot-value {
color: var(--ds-text);
font-weight: 500;
}

.node-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}

.node-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--ds-border);
border-radius: 0.375rem;
}

.node-type {
font-weight: 600;
font-size: 0.8125rem;
color: var(--ds-text);
flex-shrink: 0;
}

.node-config {
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: var(--ds-text-subtle);
word-break: break-all;
white-space: pre-wrap;
}
</style>
16 changes: 15 additions & 1 deletion frontend/src/lib/features/actions/ActionsManager.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import Play from '@lucide/svelte/icons/play';
import Zap from '@lucide/svelte/icons/zap';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import History from '@lucide/svelte/icons/history';
import Pencil from '@lucide/svelte/icons/pencil';
import Copy from '@lucide/svelte/icons/copy';
import Trash2 from '@lucide/svelte/icons/trash-2';
Expand All @@ -18,7 +19,7 @@
import PageHeader from '../../layout/PageHeader.svelte';
import Badge from '../../components/Badge.svelte';

/** @type {{ workspaceId: any, actions?: any[], loading?: boolean, oncreate?: () => void, onfromtemplate?: () => void, onedit?: (action: any) => void, onrename?: (action: any) => void, onduplicate?: (action: any) => void, ontoggle?: (action: any) => void, ondelete?: (action: any) => void, onviewlogs?: (action: any) => void, ontestexecuted?: (action: any) => void }} */
/** @type {{ workspaceId: any, actions?: any[], loading?: boolean, oncreate?: () => void, onfromtemplate?: () => void, onedit?: (action: any) => void, onrename?: (action: any) => void, onduplicate?: (action: any) => void, ontoggle?: (action: any) => void, ondelete?: (action: any) => void, onviewlogs?: (action: any) => void, onviewhistory?: (action: any) => void, ontestexecuted?: (action: any) => void }} */
let {
workspaceId,
actions = [],
Expand All @@ -31,6 +32,7 @@
ontoggle,
ondelete,
onviewlogs,
onviewhistory,
ontestexecuted,
} = $props();

Expand Down Expand Up @@ -96,6 +98,10 @@
onviewlogs?.(action);
}

function handleViewHistory(action) {
onviewhistory?.(action);
}

function moreItems(action) {
return [
...(onrename ? [{ id: 'rename', title: t('actions.rename'), icon: Pencil, testid: `action-rename-${action.id}`, onClick: () => onrename(action) }] : []),
Expand Down Expand Up @@ -204,6 +210,14 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
<button
class="p-2 rounded-md action-button"
onclick={() => handleViewHistory(action)}
title={t('actions.history.title', 'History')}
data-testid={`action-view-history-${action.id}`}
>
<History class="h-5 w-5" />
</button>
<button
class="p-2 rounded-md action-button"
onclick={() => handleToggle(action)}
Expand Down
Loading
Loading