Files
windmill/frontend/src/lib/components/ResourceVersionHistory.svelte
T
hugocasaandClaude Opus 5 c09de594b6 feat: version resource values with history, diff and restore (#10596)
* feat: version resource values with history, diff and restore

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: record resource versions in a trigger so direct writes are covered

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: show the selected version's value and tighten history write access

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: gate resource version recording in trigger WHEN clauses

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat: clear a resource's past versions, and address review nits

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: restore the displayed version and keep author attribution on pooled writes

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: scope history to the selected workspace and gate clearing on ownership

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: gate restore on write access and clearing on the signed-in workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): share the version-history row between script and resource drawers

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: trim resource version history in the monitor sweep, not on write

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): match the script versions drawer shell for resource history

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(frontend): highlight version values instead of mounting monaco

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): match the script drawer's code preview presentation

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: rank version trim in one windowed pass instead of a correlated delete

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(frontend): treat the newest version as current by position

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf: gate the resource version trim to an hourly sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: unnest the version row action and correct the trim cadence docs

* perf: cap the history listing and use sets for reference lookup

* feat: warn when a resource is written more than 60 times a minute

* fix: lower the resource write advisory to 20 per minute

* fix: discard stale history loads and never diff against an unread value

* fix: correct the write advisory boundary and document the eviction lock

* fix: read history and the live value from one snapshot

* refactor: read the drawer's diff baseline from versions, not the live resource

* fix: open the history drawer with no version selected

* fix: disarm the clear confirmation and clear the pane when the selection moves

* fix: explain the missing diff and drop a guard that can no longer fire

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 21:32:18 +02:00

315 lines
11 KiB
Svelte

<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { ResourceService, type ResourceVersion } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Skeleton } from '$lib/components/common'
import Button from './common/button/Button.svelte'
import HighlightCode from './HighlightCode.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Code, Diff, History, Loader2, RotateCcw, Trash2 } from 'lucide-svelte'
import Alert from './common/alert/Alert.svelte'
import VersionListItem from './VersionListItem.svelte'
import { displayDate } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
let {
path,
workspace = undefined,
canRestore = true,
canClear = false,
onRestore = undefined
}: {
path: string
workspace?: string
canRestore?: boolean
canClear?: boolean
onRestore?: () => void
} = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
let versions = $state<ResourceVersion[] | undefined>(undefined)
// Which row is highlighted, updated on click. `loaded` is dropped the moment the selection
// moves and only reinstated once its own fetch lands, so the pane can never show one version's
// JSON under another version's highlight.
let selectedId = $state<number | undefined>(undefined)
let loaded = $state<{ id: number; value: string; missing: string[] } | undefined>(undefined)
// Undefined until the newest version's value arrives, which is what "Diff with current" needs.
// Fetched without blocking the list, so an absent baseline disables the diff rather than
// holding up the drawer everyone else opened to read.
let currentValue = $state<string | undefined>(undefined)
// The backend decides this, rather than sending the resource type for the UI to test, so the
// list of never-versioned types stays stated once (INTERNAL_RESOURCE_TYPES) instead of being
// restated here in TypeScript as well as in the recording trigger's SQL.
let neverVersioned = $state(false)
// Bumped per load so a response for a path the drawer has since moved off cannot land. The
// component is reused rather than remounted when `path` changes, and `loaded` is what Restore
// acts on, so a late response could otherwise restore the previous resource's version.
let loadGeneration = 0
let view = $state<'value' | 'diff'>('value')
let restoring = $state(false)
// The newest version holds the live value — recording is a database trigger, so every write
// mints one — which makes a diff of it against current necessarily empty.
let isCurrent = $derived(loaded !== undefined && versions?.[0]?.id === loaded.id)
let clearing = $state(false)
let confirmingClear = $state(false)
function pretty(value: unknown): string {
return JSON.stringify(value ?? null, null, 2)
}
async function loadVersions() {
const generation = ++loadGeneration
// Cleared before the first await, not after: everything below is actionable state, and
// leaving it pointing at the previous resource is what lets Restore act on the wrong one.
versions = undefined
loaded = undefined
selectedId = undefined
currentValue = undefined
// Including the confirmation: it is armed for one resource and acts on whichever the
// drawer currently points at, so leaving it up across a retarget would let Confirm delete
// a history the user never asked to clear.
confirmingClear = false
const history = await ResourceService.getResourceHistory({
workspace: effectiveWorkspace,
path
})
if (generation !== loadGeneration) return
neverVersioned = history.versioned === false
// Nothing is selected on open, as in ScriptVersionHistory. Pre-selecting a version would
// fill the pane with JSON that reads as the resource's value without being it, since the
// drawer opens on the value view rather than the diff.
versions = history.versions ?? []
void loadDiffBaseline(versions[0]?.id, generation)
}
/// The diff baseline is the newest version, not the resource's live value, even though the two
/// hold the same thing: versions are immutable and addressed by id, so this cannot disagree
/// with the list it came from, where reading `resource` would reintroduce a mutable second
/// source. Deliberately not awaited by the caller — only the diff view needs it, which is two
/// interactions away, so the list paints as soon as it arrives.
async function loadDiffBaseline(newestId: number | undefined, generation: number) {
if (newestId === undefined) return
try {
const newest = await fetchVersion(newestId)
if (generation === loadGeneration) {
currentValue = newest.value
}
} catch {
// Leaves `currentValue` undefined, which hides the diff rather than offering one
// against nothing.
}
}
async function fetchVersion(id: number) {
const version = await ResourceService.getResourceVersion({
workspace: effectiveWorkspace,
version: id
})
return { id, value: pretty(version.value), missing: version.missing_references ?? [] }
}
async function selectVersion(id: number | undefined, generation = loadGeneration) {
selectedId = id
// Dropped up front rather than left in place while the new value is in flight: keeping it
// would highlight the clicked row while the pane still rendered the previous version, and
// a failed fetch would leave that mismatch on screen indefinitely.
loaded = undefined
if (id === undefined) {
return
}
try {
const version = await fetchVersion(id)
// Assigned as one object so the id keying the editor and the value it displays can
// never be out of step, whatever order the requests come back in.
if (selectedId === id && generation === loadGeneration) {
loaded = version
}
} catch (err) {
if (selectedId === id && generation === loadGeneration) {
selectedId = undefined
sendUserToast(`Could not load version ${id}`, true)
}
}
}
async function restore() {
// loaded.id, never selectedId: restoring what the pane is showing. A selection whose value
// has not arrived leaves `loaded` undefined, so this writes nothing rather than restoring a
// version the user has not seen.
const id = loaded?.id
if (id === undefined) return
restoring = true
try {
await ResourceService.restoreResourceVersion({
workspace: effectiveWorkspace,
version: id
})
sendUserToast(`Restored ${path} to version ${id}`)
onRestore?.()
await loadVersions()
} finally {
restoring = false
}
}
async function clearHistory() {
clearing = true
try {
await ResourceService.clearResourceHistory({ workspace: effectiveWorkspace, path })
sendUserToast(`Cleared past versions of ${path}`)
confirmingClear = false
await loadVersions()
} finally {
clearing = false
}
}
$effect(() => {
if (path && effectiveWorkspace) {
loadVersions()
}
})
</script>
<Splitpanes class="h-full">
<Pane size={20}>
{#if versions === undefined}
<div class="p-2 flex flex-col gap-2">
{#each Array(4) as _}
<Skeleton layout={[[2]]} />
{/each}
</div>
{:else if versions.length === 0}
<div class="p-4 text-tertiary text-xs">
{#if neverVersioned}
Resources of this type are written by every job that uses them, so their values are
deliberately not kept in history.
{:else}
No history yet. Versions are recorded from the next edit onwards.
{/if}
</div>
{:else}
{#if versions.length > 1 && canClear}
<div class="px-3 py-2 border-b">
{#if confirmingClear}
<p class="text-2xs text-tertiary mb-2">
Delete every past version, keeping only the current value? Past values can no longer
be compared or restored.
</p>
<div class="flex flex-row gap-2">
<Button size="xs" variant="accent" disabled={clearing} onclick={clearHistory}>
{clearing ? 'Clearing' : 'Confirm'}
</Button>
<Button size="xs" variant="default" onclick={() => (confirmingClear = false)}>
Cancel
</Button>
</div>
{:else}
<Button
size="xs"
variant="default"
startIcon={{ icon: Trash2 }}
onclick={() => (confirmingClear = true)}
>
Clear past versions
</Button>
{/if}
</div>
{/if}
<div class="flex flex-col gap-1 p-2">
{#each versions as version, index (version.id)}
<VersionListItem
selected={selectedId === version.id}
onclick={() => selectVersion(version.id)}
>
<div class="flex flex-col gap-0.5 truncate">
<div class="text-xs font-medium flex items-center gap-1">
{#if index === 0}
<History size={12} />
{/if}
{index === 0 ? 'Current' : `Version ${version.id}`}
</div>
<div class="text-2xs text-tertiary">
{displayDate(version.created_at)}{version.created_by
? ` by ${version.created_by}`
: ''}
</div>
</div>
</VersionListItem>
{/each}
</div>
{/if}
</Pane>
<Pane size={80}>
{#if loaded === undefined && selectedId !== undefined}
<Loader2 class="animate-spin m-4" />
{:else if loaded === undefined}
<div class="p-4 text-tertiary text-xs">Select a version to inspect its value.</div>
{:else}
<div class="flex flex-col h-full">
<div class="flex flex-row justify-between items-center gap-2 p-2 border-b">
{#if isCurrent}
<span class="text-2xs text-tertiary">This is the current value</span>
{:else if currentValue === undefined}
<span class="text-2xs text-tertiary">
The current value could not be loaded, so there is no diff to show
</span>
{:else}
<ToggleButtonGroup bind:selected={view}>
{#snippet children({ item })}
<ToggleButton small value="value" icon={Code} label="Value" {item} />
<ToggleButton small value="diff" icon={Diff} label="Diff with current" {item} />
{/snippet}
</ToggleButtonGroup>
{/if}
<Button
size="xs"
variant="default"
startIcon={{ icon: RotateCcw }}
disabled={restoring || !canRestore || isCurrent}
onclick={restore}
>
Restore this version
</Button>
</div>
{#if loaded.missing.length > 0}
<div class="px-3 py-2">
<Alert type="warning" size="xs" title="References something that no longer exists">
This version references {loaded.missing.join(', ')}. Restoring it leaves the resource
pointing at something unresolvable until you recreate it.
</Alert>
</div>
{/if}
<div class="grow min-h-0">
{#if view === 'diff' && !isCurrent && currentValue !== undefined}
<!-- Imported on demand: the diff is the only thing here that needs Monaco, and
pulling it in on open would load the editor for everyone who just wants to
read a value. -->
{#await import('./DiffEditor.svelte')}
<Loader2 class="animate-spin m-4" />
{:then Module}
<Module.default
open
automaticLayout
readOnly
defaultLang="json"
defaultOriginal={loaded.value}
defaultModified={currentValue}
className="h-full"
/>
{/await}
{:else}
<div class="h-full overflow-auto px-3 py-2">
<HighlightCode language="json" code={loaded.value} />
</div>
{/if}
</div>
</div>
{/if}
</Pane>
</Splitpanes>