mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +00:00
The completion listener that refreshes and closes hosted editors runs only for a tool that returned, so a draft cleanup throwing after the entity was deployed or deleted left the tab open on state that no longer exists. The cleanup cannot undo the mutation either, so both tools now report it in their result instead of throwing it as if nothing had happened. Releasing a cell recorded its value for every resource and variable, though only a settle waiting on a write in flight ever reads one — routine editor use retained large resource values and decrypted secrets for the tab's lifetime. The editors now open a window around their save, and only a release inside one is remembered; closing it drops what it held. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
474 lines
17 KiB
Svelte
474 lines
17 KiB
Svelte
<script lang="ts">
|
|
import type { Schema } from '$lib/common'
|
|
import { ResourceService, WorkspaceService, type Resource, type ResourceType } from '$lib/gen'
|
|
import { canWrite } from '$lib/utils'
|
|
import { createEventDispatcher, untrack } from 'svelte'
|
|
import { userStore, workspaceStore } from '$lib/stores'
|
|
import { sendUserToast } from '$lib/toast'
|
|
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
|
|
import ResourceForm from './ResourceForm.svelte'
|
|
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
|
import Alert from './common/alert/Alert.svelte'
|
|
import { resource } from 'runed'
|
|
import { getUserExt } from '$lib/user'
|
|
import type { UserExt } from '$lib/stores'
|
|
import {
|
|
UserDraft,
|
|
draftValuesEqual,
|
|
flushDraftDelete,
|
|
beginDraftSettleWindow,
|
|
settleDraftAfterWrite,
|
|
type UserDraftHandle
|
|
} from '$lib/userDraft.svelte'
|
|
import { setLocalDraftHint } from '$lib/localDraftHints.svelte'
|
|
|
|
interface Props {
|
|
canSave?: boolean
|
|
resource_type?: string | undefined
|
|
path?: string
|
|
hidePath?: boolean
|
|
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
|
|
defaultValues?: Record<string, any> | undefined
|
|
workspace?: string | undefined
|
|
selected?: string | undefined
|
|
/** Show the value as JSON rather than as the resource type's form. Bindable so a caller can
|
|
* choose the view a given resource opens on, and the in-form toggle still works. */
|
|
viewJsonSchema?: boolean
|
|
/** Notifies the parent drawer whether a local draft for the selected
|
|
* workspace diverges from the deployed baseline, so it can show the
|
|
* "unsaved changes" banner below its header. */
|
|
onDraftStateChange?: (hasDraft: boolean) => void
|
|
/** Notifies the parent drawer of write-access for the selected workspace,
|
|
* so it can hide the banner's Discard button in read-only mode (matches
|
|
* the trigger editors' `disabled={!can_write}` wiring). */
|
|
onCanWriteChange?: (canWrite: boolean) => void
|
|
}
|
|
|
|
let {
|
|
canSave = $bindable(true),
|
|
resource_type = $bindable(undefined),
|
|
path = $bindable(''),
|
|
hidePath = false,
|
|
onChange,
|
|
defaultValues = undefined,
|
|
workspace = undefined,
|
|
selected: selectedProp = $bindable(),
|
|
viewJsonSchema = $bindable(),
|
|
onDraftStateChange,
|
|
onCanWriteChange
|
|
}: Props = $props()
|
|
|
|
type ResourceState = {
|
|
path: string
|
|
description: string
|
|
args: Record<string, any>
|
|
labels: string[] | undefined
|
|
wsSpecific: boolean
|
|
}
|
|
|
|
const dispatch = createEventDispatcher()
|
|
|
|
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
|
|
// Fallback to `effectiveWorkspace` insulates against reactify-style
|
|
// parents that re-spread props without `selected` — otherwise it
|
|
// transiently resets and the form below remounts on every keystroke.
|
|
let selected = $derived(selectedProp ?? effectiveWorkspace)
|
|
let initialPath = path
|
|
|
|
// Per-workspace handles are driven by `useMany`. We track the workspace
|
|
// IDs (and their seeded defaults) in a parallel `$state` array; on every
|
|
// mutation `useMany` reconciles, acquiring entries for new workspaces and
|
|
// releasing them on component teardown. `states` indexes the resulting
|
|
// handles by workspace ID for ergonomic lookup downstream.
|
|
let workspaceSpecs = $state<Array<{ ws: string; defaultValue: ResourceState }>>([])
|
|
let initialStates: Record<string, ResourceState> = $state({})
|
|
let existedInitially: Record<string, boolean> = $state({})
|
|
let fetchedResources: Record<string, Resource> = $state({})
|
|
let perWsUser: Record<string, UserExt | undefined> = $state({})
|
|
|
|
const handlesArray = UserDraft.useMany<ResourceState>(() =>
|
|
workspaceSpecs.map((s) => ({
|
|
itemKind: 'resource' as const,
|
|
path: initialPath ?? '',
|
|
workspace: s.ws,
|
|
defaultValue: s.defaultValue,
|
|
// Autosaves landing back on the deployed value become deletes;
|
|
// `existedInitially` guards draft-only items from self-destructing.
|
|
discardIf: (val) => !!existedInitially[s.ws] && draftValuesEqual(val, initialStates[s.ws])
|
|
}))
|
|
)
|
|
const states = $derived.by(() => {
|
|
const out: Record<string, UserDraftHandle<ResourceState>> = {}
|
|
for (let i = 0; i < workspaceSpecs.length; i++) {
|
|
const handle = handlesArray[i]
|
|
if (handle) out[workspaceSpecs[i].ws] = handle
|
|
}
|
|
return out
|
|
})
|
|
|
|
/** Register a workspace so `useMany` acquires (or reuses) its handle.
|
|
* `defaultValue` is what the handle reports when no autosave is persisted;
|
|
* an existing autosave always wins. The default itself never round-trips
|
|
* to localStorage — only the user's first real edit triggers a write. */
|
|
function ensureHandle(ws: string, defaultValue: ResourceState): void {
|
|
if (workspaceSpecs.some((s) => s.ws === ws)) return
|
|
workspaceSpecs.push({ ws, defaultValue })
|
|
}
|
|
|
|
let isValid = $state(true)
|
|
let jsonError = $state('')
|
|
let perWsValid: Record<string, boolean> = $state({})
|
|
|
|
const deployToResource = resource(
|
|
() => selected,
|
|
async (ws) =>
|
|
ws ? (await WorkspaceService.getDeployTo({ workspace: ws })).deploy_to : undefined
|
|
)
|
|
const resourceTypeResource = resource(
|
|
[() => resource_type, () => effectiveWorkspace],
|
|
async ([rt, ws]) =>
|
|
rt && ws ? ResourceService.getResourceType({ path: rt, workspace: ws }) : null
|
|
)
|
|
|
|
let deployTo = $derived(deployToResource.current)
|
|
let resourceTypeInfo: ResourceType | undefined = $derived(
|
|
resourceTypeResource.current ?? undefined
|
|
)
|
|
let resourceSchema: Schema | undefined = $derived.by(() => {
|
|
const rt = resourceTypeResource.current
|
|
if (!rt?.schema) return undefined
|
|
const schema = rt.schema as Schema
|
|
return {
|
|
...schema,
|
|
// A resource type may declare no properties at all — `dbt_profile` is a
|
|
// `profiles.yml` block whose keys are its adapter's, not Windmill's — and
|
|
// the form renders those as one JSON editor. `Object.keys(undefined)`
|
|
// threw here, which left the editor on its loading skeleton forever.
|
|
order: schema.order ?? Object.keys(schema.properties ?? {}).sort()
|
|
}
|
|
})
|
|
let loadingSchema = $derived(resourceTypeResource.loading)
|
|
|
|
let current = $derived(selected ? states[selected]?.draft : undefined)
|
|
let resourceToEdit: Resource | undefined = $derived(
|
|
selected ? fetchedResources[selected] : undefined
|
|
)
|
|
let can_write = $derived.by(() => {
|
|
if (!selected) return true
|
|
const r = fetchedResources[selected]
|
|
if (!r) return true
|
|
return canWrite(
|
|
current?.path ?? initialPath,
|
|
r.extra_perms ?? {},
|
|
perWsUser[selected] ?? $userStore
|
|
)
|
|
})
|
|
|
|
const dirtyWorkspaces = $derived(
|
|
Object.keys(states).filter((ws) => !draftValuesEqual(states[ws].draft, initialStates[ws]))
|
|
)
|
|
const anyDirty = $derived(dirtyWorkspaces.length > 0)
|
|
|
|
// The syncer owns the list-page `*` hint; the editor only CLEARS it when a
|
|
// workspace is at the deployed baseline (so a draft discarded elsewhere
|
|
// vanishes on reopen). Never SET here. See VariableEditor for the full note.
|
|
$effect(() => {
|
|
const p = initialPath
|
|
const loadedWs = Object.keys(states)
|
|
const dirty = dirtyWorkspaces
|
|
untrack(() => {
|
|
if (!p) return
|
|
for (const ws of loadedWs) {
|
|
if (!dirty.includes(ws)) setLocalDraftHint(ws, 'resource', p, false)
|
|
}
|
|
})
|
|
})
|
|
// Banner is scoped to the selected workspace — the diff/discard only
|
|
// operate on it, so showing it for an unrelated dirty workspace would be
|
|
// misleading. The cross-workspace `otherDirty` alert below still covers
|
|
// that case.
|
|
const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected))
|
|
const otherDirty = $derived(
|
|
dirtyWorkspaces.length == 1
|
|
? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore)
|
|
: dirtyWorkspaces
|
|
)
|
|
const dirtyValid = $derived(dirtyWorkspaces.every((ws) => perWsValid[ws] !== false))
|
|
const dirtyCanWrite = $derived(
|
|
dirtyWorkspaces.every((ws) => {
|
|
const r = fetchedResources[ws]
|
|
return (
|
|
!r ||
|
|
canWrite(
|
|
states[ws]?.draft?.path ?? initialPath,
|
|
r.extra_perms ?? {},
|
|
perWsUser[ws] ?? $userStore
|
|
)
|
|
)
|
|
})
|
|
)
|
|
|
|
// New-resource bootstrap: seed empty state per workspace (edit mode
|
|
// is seeded by the lazy-fetch effect below).
|
|
$effect(() => {
|
|
if (!selected) return
|
|
if (initialPath) return
|
|
if (selected in initialStates) return
|
|
untrack(() => {
|
|
const s: ResourceState = {
|
|
path: '',
|
|
description: '',
|
|
args: (defaultValues && Object.keys(defaultValues).length > 0 ? defaultValues : {}) as any,
|
|
labels: undefined,
|
|
wsSpecific: false
|
|
}
|
|
ensureHandle(selected, s)
|
|
initialStates[selected] = structuredClone(s)
|
|
existedInitially[selected] = false
|
|
})
|
|
})
|
|
|
|
// Lazy-fetch the resource for the selected workspace when not already cached
|
|
$effect(() => {
|
|
const ws = selected
|
|
if (!ws || !initialPath) return
|
|
if (ws in states) return
|
|
untrack(() => {
|
|
Promise.all([
|
|
ResourceService.getResource({ workspace: ws, path: initialPath, getDraft: true }),
|
|
getUserExt(ws)
|
|
]).then(([r, user]) => {
|
|
// `.draft` already holds the editor's `ResourceState` shape.
|
|
const savedDraftState = (r as any).draft as ResourceState | undefined
|
|
fetchedResources[ws] = r
|
|
// Deployed baseline as the dirty-check reference, so the banner
|
|
// compares draft-vs-deployed and fires immediately when a draft exists.
|
|
const deployedState: ResourceState = {
|
|
path: r.path,
|
|
description: r.description ?? '',
|
|
args: (r.value ?? {}) as any,
|
|
labels: r.labels ?? undefined,
|
|
wsSpecific: r.ws_specific ?? false
|
|
}
|
|
// Open with the saved draft if present, else the deployed.
|
|
const s: ResourceState = savedDraftState ?? deployedState
|
|
ensureHandle(ws, s)
|
|
initialStates[ws] = structuredClone(deployedState)
|
|
// Draft-only paths (`no_deployed`) have no row — saving must
|
|
// CREATE, not update (update 404s).
|
|
existedInitially[ws] = !(r as any).no_deployed
|
|
perWsUser[ws] = user
|
|
// Keep resource_type in sync for the base workspace (controls the schema)
|
|
if (ws === effectiveWorkspace) {
|
|
resource_type = r.resource_type
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
// Keep current.path bound to the outer `path` prop for consumers
|
|
$effect(() => {
|
|
if (current) path = current.path
|
|
})
|
|
|
|
$effect(() => {
|
|
if (selected !== undefined) {
|
|
perWsValid[selected] = isValid && jsonError == ''
|
|
}
|
|
})
|
|
|
|
$effect(() => {
|
|
canSave = anyDirty && dirtyValid && dirtyCanWrite
|
|
})
|
|
|
|
// Drive the parent drawer's "unsaved changes" banner. The drawer chrome
|
|
// (header + banner slot) lives in ResourceEditorDrawer, above this
|
|
// lazily-imported content, so the state is lifted up via these accessors.
|
|
$effect(() => {
|
|
onDraftStateChange?.(!!initialPath && selectedDirty)
|
|
})
|
|
$effect(() => {
|
|
onCanWriteChange?.(can_write)
|
|
})
|
|
|
|
export function localDraftDeployed(): ResourceState | undefined {
|
|
return selected ? initialStates[selected] : undefined
|
|
}
|
|
export function localDraftCurrent(): ResourceState | undefined {
|
|
return current
|
|
}
|
|
/** Returns whether the resource still exists afterwards. Discarding a draft
|
|
* that was never deployed removes the resource itself — `initialStates` holds a
|
|
* synthesized stand-in, not a baseline to fall back to — so a caller showing it
|
|
* has to stop rather than keep displaying that stand-in. */
|
|
export async function discardLocalDraft(): Promise<boolean> {
|
|
if (!selected) return true
|
|
const ws = selected
|
|
UserDraft.discard('resource', initialPath ?? '', initialStates[ws], { workspace: ws })
|
|
// A delete that never landed leaves the resource where it was.
|
|
const landed = await flushDraftDelete('resource', initialPath ?? '', { workspace: ws })
|
|
return !!existedInitially[ws] || !landed
|
|
}
|
|
|
|
$effect(() => {
|
|
if (current)
|
|
// $state.snapshot deep-reads (so the effect re-runs on nested
|
|
// args mutations) and returns a plain object (React consumers
|
|
// can't diff a $state proxy by reference or JSON.stringify).
|
|
onChange?.({
|
|
path: current.path,
|
|
args: $state.snapshot(current.args) as Record<string, any>,
|
|
description: current.description
|
|
})
|
|
})
|
|
|
|
/** Sole writer of `current.path` — an arg still holding `$var:<the path being
|
|
* replaced>` is the resource's own linked secret, which the backend renames
|
|
* along with the resource, so the reference moves with it. An arg pointing at
|
|
* any other variable was set by the user and is left alone. */
|
|
function setPath(npath: string): void {
|
|
if (!current) return
|
|
const prev = current.path
|
|
// `args` is whatever the raw JSON editor parsed — `null` included.
|
|
for (const [k, v] of Object.entries(current.args ?? {})) {
|
|
if (v === `$var:${prev}`) current.args[k] = `$var:${npath}`
|
|
}
|
|
current.path = npath
|
|
}
|
|
|
|
/**
|
|
* Whether the save completed, as this component's packaged API has always
|
|
* reported it: it is an entry point of `windmill-components`, so callers
|
|
* outside this repo branch on the boolean. A host that has to follow what each
|
|
* workspace wrote takes `saveWritten` instead.
|
|
*/
|
|
export async function save(): Promise<boolean> {
|
|
return (await runSave()).ok
|
|
}
|
|
|
|
/** Every workspace this save wrote, in order, each with the path it wrote there. */
|
|
export async function saveWritten(): Promise<{ ws: string; path: string }[]> {
|
|
return (await runSave()).written
|
|
}
|
|
|
|
async function runSave(): Promise<{ written: { ws: string; path: string }[]; ok: boolean }> {
|
|
// Everything the writes send, read before the first await. The form stays
|
|
// editable while they are in flight, so read later these would be whatever
|
|
// the user has since typed — sent under an earlier workspace's path, and
|
|
// adopted as a baseline the server never saw.
|
|
const from = initialPath ?? ''
|
|
const payloads = dirtyWorkspaces.map((ws) => ({
|
|
ws,
|
|
s: $state.snapshot(states[ws].draft!) as ResourceState,
|
|
ini: $state.snapshot(initialStates[ws]) as ResourceState,
|
|
existed: !!existedInitially[ws]
|
|
}))
|
|
// The workspaces go in sequence and a later one throwing aborts the rest, but
|
|
// what an earlier one wrote is deployed — a caller told nothing about it would
|
|
// stay pointed at a path the item has moved off.
|
|
const written: { ws: string; path: string }[] = []
|
|
// A host left mid-write releases the cell each settle below reads; only inside
|
|
// this window is what it was holding remembered.
|
|
const closeSettleWindows = payloads.map(({ ws }) =>
|
|
beginDraftSettleWindow('resource', from, { workspace: ws })
|
|
)
|
|
try {
|
|
for (const { ws, s, ini, existed } of payloads) {
|
|
if (existed) {
|
|
await ResourceService.updateResource({
|
|
workspace: ws,
|
|
path: ini.path,
|
|
requestBody: {
|
|
path: s.path,
|
|
value: s.args,
|
|
description: s.description,
|
|
labels: s.labels,
|
|
ws_specific: s.wsSpecific
|
|
}
|
|
})
|
|
const fetched = fetchedResources[ws]
|
|
if (fetched?.resource_type === 'json_schema') {
|
|
clearJsonSchemaResourceCache(ini.path, ws)
|
|
}
|
|
} else {
|
|
await ResourceService.createResource({
|
|
workspace: ws,
|
|
requestBody: {
|
|
path: s.path,
|
|
value: s.args,
|
|
description: s.description,
|
|
resource_type: resource_type!,
|
|
labels: s.labels,
|
|
ws_specific: s.wsSpecific
|
|
}
|
|
})
|
|
}
|
|
// Reset the handle to the new deployed baseline via `discard`, not
|
|
// `remove`. See VariableEditor for the full rationale.
|
|
initialStates[ws] = s
|
|
existedInitially[ws] = true
|
|
written.push({ ws, path: s.path })
|
|
// `s.path`, not the reported one: each workspace settles against the path
|
|
// its own write used.
|
|
await settleDraftAfterWrite('resource', s, states[ws]?.draft, from, s.path, {
|
|
workspace: ws
|
|
})
|
|
// Path now exists server-side — drop the autocomplete cache so
|
|
// it shows up immediately instead of after the 60s TTL.
|
|
invalidateWorkspacePaths(ws)
|
|
}
|
|
sendUserToast(
|
|
payloads.length > 1
|
|
? `Saved resource in ${payloads.length} workspaces`
|
|
: `Saved resource`
|
|
)
|
|
dispatch('refresh', written.find((w) => w.ws === effectiveWorkspace)?.path ?? from)
|
|
return { written, ok: true }
|
|
} catch (err) {
|
|
sendUserToast(`Could not save resource: ${err.body ?? err.message}`, true)
|
|
// The workspaces that never got their write keep their drafts at their own
|
|
// paths, listed and editable from there — moving one onto a rename it never
|
|
// wrote would be worse than not showing it here.
|
|
return { written, ok: false }
|
|
} finally {
|
|
for (const close of closeSettleWindows) close()
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div>
|
|
<div class="flex flex-col gap-6 pb-2">
|
|
{#if otherDirty.length > 0}
|
|
<Alert type="warning" title="Editing multiple workspaces">
|
|
You are going to edit the value in: {otherDirty.join(', ')}
|
|
</Alert>
|
|
{/if}
|
|
|
|
{#if current}
|
|
{#key current}
|
|
<ResourceForm
|
|
bind:path={() => current!.path, setPath}
|
|
bind:labels={current.labels}
|
|
bind:description={current.description}
|
|
bind:args={current.args}
|
|
bind:wsSpecific={current.wsSpecific}
|
|
bind:isValid
|
|
bind:viewJsonSchema={() => viewJsonSchema ?? false, (v) => (viewJsonSchema = v)}
|
|
bind:jsonError
|
|
{initialPath}
|
|
{hidePath}
|
|
{deployTo}
|
|
{can_write}
|
|
{resource_type}
|
|
{resourceTypeInfo}
|
|
{resourceSchema}
|
|
{loadingSchema}
|
|
{resourceToEdit}
|
|
onLoadResourceType={() => resourceTypeResource.refetch()}
|
|
workspace={selected}
|
|
/>
|
|
{/key}
|
|
{/if}
|
|
</div>
|
|
</div>
|