feat: pipeline Activity panel grouping, run↔graph highlight, deploy-conflict handling

Activity panel (view mode):
- Group cascade runs by the connected component of the asset-dispatch graph
  (new GET /jobs/asset_dispatch_edges over the dispatch_event table, incl.
  join_pending inputs), headed by the earliest originating run + its trigger,
  with a "+N" chip for joins fed by multiple triggers.
- Success/failure count histogram with drag-to-filter brushing, an always-on
  time axis + per-bar tooltips, a Reset, and Last hour/24h/48h/7/30/90d ranges.
- Node run-count/status badges now derive from the same merged historic+live
  events the panel shows (previously session-only).

Run ↔ graph highlight:
- Hovering a run row (or a group header → the whole cascade) rings the
  node(s), animates their incident edges, and borders the adjacent assets in
  the edge hue (blue write / gray read); expanding a run pins a soft-blue ring.
- Switching edit→view re-surfaces the Activity feed.

Deploy:
- Live-content autosave for the open pipeline draft + an autosave indicator.
- Re-saving a script now chains off the hash just created instead of a stale
  parent_hash (fixes the "lineage must be linear" error on a second save), and
  a genuine concurrent deploy opens a keep-mine / view-latest conflict modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-06-16 17:21:23 +02:00
co-authored by Claude Opus 4.8
parent 1cd2cc5fbd
commit f84c4cf963
9 changed files with 1056 additions and 33 deletions
@@ -0,0 +1,74 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n de.producer_job_id AS \"producer_job_id!\",\n de.child_job_id,\n de.subscriber_path AS \"subscriber_path!\",\n de.outcome::text AS \"outcome!\",\n de.asset_kind AS \"asset_kind!: windmill_common::assets::AssetKind\",\n de.asset_path AS \"asset_path!\",\n de.created_at AS \"created_at!\"\n FROM dispatch_event de\n JOIN v2_job pj ON pj.id = de.producer_job_id\n WHERE de.workspace_id = $1\n AND de.outcome IN ('dispatched', 'join_pending')\n AND de.subscriber_path LIKE $2\n AND ($3::timestamptz IS NULL OR de.created_at >= $3)\n ORDER BY de.created_at DESC, de.id DESC\n LIMIT 4000",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "producer_job_id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "child_job_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "subscriber_path!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "outcome!",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "asset_kind!: windmill_common::assets::AssetKind",
"type_info": {
"Custom": {
"name": "asset_kind",
"kind": {
"Enum": [
"s3object",
"resource",
"variable",
"ducklake",
"datatable",
"volume"
]
}
}
}
},
{
"ordinal": 5,
"name": "asset_path!",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "created_at!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz"
]
},
"nullable": [
false,
true,
false,
null,
false,
false,
false
]
},
"hash": "8cd02a5378bea03012e6cb937f23460b6438d0a320b5554acec0beddbdeb008b"
}
+72
View File
@@ -13006,6 +13006,78 @@ paths:
- outcome
- created_at
/w/{workspace}/jobs/asset_dispatch_edges:
get:
summary: list asset-cascade producer→child job edges for a folder
description: >
Returns the `dispatched` asset-trigger edges (producer job → child job)
whose subscriber lives under `path_start`. Lets a pipeline view
reconstruct the cascade tree of a folder by job id and group connected
runs. Visibility follows the producer job's RLS.
operationId: listAssetDispatchEdges
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: path_start
in: query
required: true
description: Folder path prefix the children live under, e.g. `f/orders/`.
schema:
type: string
- name: created_after
in: query
required: false
description: Only edges dispatched at/after this instant.
schema:
type: string
format: date-time
responses:
"200":
description: asset-cascade edges for the folder
content:
application/json:
schema:
type: array
items:
type: object
properties:
producer_job_id:
type: string
format: uuid
child_job_id:
type: string
format: uuid
description: Set for `dispatched`; absent for `join_pending` inputs.
subscriber_path:
type: string
outcome:
type: string
enum:
- dispatched
- join_pending
asset_kind:
type: string
enum:
- s3object
- resource
- variable
- ducklake
- datatable
- volume
asset_path:
type: string
created_at:
type: string
format: date-time
required:
- producer_job_id
- subscriber_path
- outcome
- asset_kind
- asset_path
- created_at
/w/{workspace}/jobs/completed/delete/{id}:
post:
summary: delete completed job (erase content but keep run id)
+81
View File
@@ -270,6 +270,7 @@ pub fn workspaced_service() -> Router {
)
.route("/run/dynamic_select", post(run_dynamic_select))
.route("/list", get(list_jobs))
.route("/asset_dispatch_edges", get(list_asset_dispatch_edges))
.route(
"/list_selected_job_groups",
// We use post because sending a huge array as a query param can produce
@@ -9145,6 +9146,86 @@ async fn get_dispatch_events(
))
}
/// One asset-cascade dispatch record, for reconstructing the cascade graph of a
/// pipeline folder in the Activity panel. `dispatched` rows carry the resolved
/// `child_job_id` (a real producer→child job edge); `join_pending` rows are the
/// pre-completion inputs of an AND-join (no child yet) — the client links them
/// to the eventual child of the same `subscriber_path` so a join's separate
/// trigger chains merge into one group. `skipped` is omitted.
#[derive(Serialize)]
struct AssetDispatchEdge {
producer_job_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
child_job_id: Option<Uuid>,
subscriber_path: String,
outcome: String,
asset_kind: windmill_common::assets::AssetKind,
asset_path: String,
created_at: chrono::DateTime<Utc>,
}
#[derive(Deserialize)]
struct AssetDispatchEdgesQuery {
/// Folder path prefix the children live under, e.g. `f/orders/`. Matched
/// against `subscriber_path` — every intra-pipeline cascade edge has its
/// child in the folder, so this captures the whole folder's cascades.
path_start: String,
/// Only edges dispatched at/after this instant (align with the activity
/// window the client already loaded). Omit for the default cap.
created_after: Option<chrono::DateTime<Utc>>,
}
/// Asset-cascade edges for a pipeline folder. RLS on the joined `v2_job`
/// producer row limits this to cascades whose producer the caller can already
/// see (same visibility as the folder's job list).
async fn list_asset_dispatch_edges(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Query(query): Query<AssetDispatchEdgesQuery>,
) -> error::JsonResult<Vec<AssetDispatchEdge>> {
let like = format!("{}%", query.path_start);
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
r#"SELECT
de.producer_job_id AS "producer_job_id!",
de.child_job_id,
de.subscriber_path AS "subscriber_path!",
de.outcome::text AS "outcome!",
de.asset_kind AS "asset_kind!: windmill_common::assets::AssetKind",
de.asset_path AS "asset_path!",
de.created_at AS "created_at!"
FROM dispatch_event de
JOIN v2_job pj ON pj.id = de.producer_job_id
WHERE de.workspace_id = $1
AND de.outcome IN ('dispatched', 'join_pending')
AND de.subscriber_path LIKE $2
AND ($3::timestamptz IS NULL OR de.created_at >= $3)
ORDER BY de.created_at DESC, de.id DESC
LIMIT 4000"#,
&w_id,
like,
query.created_after,
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(
rows.into_iter()
.map(|r| AssetDispatchEdge {
producer_job_id: r.producer_job_id,
child_job_id: r.child_job_id,
subscriber_path: r.subscriber_path,
outcome: r.outcome,
asset_kind: r.asset_kind,
asset_path: r.asset_path,
created_at: r.created_at,
})
.collect(),
))
}
async fn get_completed_job_timing(
OptViewToken(view_token): OptViewToken,
OptAuthed(opt_authed): OptAuthed,
@@ -0,0 +1,155 @@
<script lang="ts">
import { isActiveEvent, type PipelineEvent } from './activeRunnables.svelte'
interface Props {
// All runs in the loaded window — bucketed into the bars. Filtering is
// the caller's job; this always shows the whole window so you can brush
// within it.
events: PipelineEvent[]
// Window bounds in ms (start … end). Bars span this range.
from: number
to: number
// Current brushed selection in ms, or undefined for the whole window.
selected?: { from: number; to: number } | undefined
// Emitted on brush end; `undefined` for a click (clears the selection).
onSelect: (range: { from: number; to: number } | undefined) => void
}
let { events, from, to, selected, onSelect }: Props = $props()
const BUCKETS = 48
let bins = $derived.by(() => {
const span = Math.max(1, to - from)
const arr = Array.from({ length: BUCKETS }, () => ({ success: 0, failure: 0 }))
for (const e of events) {
// Future-scheduled queued runs aren't activity.
if (e.status === 'queued' && !isActiveEvent(e)) continue
const t = Date.parse(e.at)
if (isNaN(t) || t < from || t > to) continue
const i = Math.min(BUCKETS - 1, Math.max(0, Math.floor(((t - from) / span) * BUCKETS)))
if (e.status === 'failure') arr[i].failure += 1
else arr[i].success += 1 // success + running/active
}
return arr
})
let maxCount = $derived(Math.max(1, ...bins.map((b) => b.success + b.failure)))
// Selection rendered as a fractional band [0,1] over the bar area.
let selFrac = $derived.by(() => {
if (!selected) return undefined
const span = Math.max(1, to - from)
const a = Math.max(0, Math.min(1, (selected.from - from) / span))
const b = Math.max(0, Math.min(1, (selected.to - from) / span))
return a < b ? { left: a, width: b - a } : undefined
})
let barsEl: HTMLDivElement | undefined = $state()
let dragFrom = $state<number | undefined>(undefined) // fractional x at pointerdown
let dragTo = $state<number | undefined>(undefined)
function fracAt(clientX: number): number {
if (!barsEl) return 0
const r = barsEl.getBoundingClientRect()
return Math.max(0, Math.min(1, (clientX - r.left) / Math.max(1, r.width)))
}
function fracToMs(f: number): number {
return from + f * Math.max(1, to - from)
}
function onPointerDown(e: PointerEvent) {
barsEl?.setPointerCapture(e.pointerId)
dragFrom = fracAt(e.clientX)
dragTo = dragFrom
}
function onPointerMove(e: PointerEvent) {
if (dragFrom == undefined) return
dragTo = fracAt(e.clientX)
}
function onPointerUp(e: PointerEvent) {
if (dragFrom == undefined) return
const a = dragFrom
const b = fracAt(e.clientX)
dragFrom = undefined
dragTo = undefined
// A drag of any meaningful width selects; a click (tiny delta) clears.
if (Math.abs(b - a) < 0.012) {
onSelect(undefined)
return
}
const lo = Math.min(a, b)
const hi = Math.max(a, b)
onSelect({ from: Math.floor(fracToMs(lo)), to: Math.ceil(fracToMs(hi)) })
}
// Live drag band (while dragging) or the committed selection.
let band = $derived.by(() => {
if (dragFrom != undefined && dragTo != undefined) {
const lo = Math.min(dragFrom, dragTo)
const hi = Math.max(dragFrom, dragTo)
return { left: lo, width: hi - lo }
}
return selFrac
})
function bucketRange(i: number): { from: number; to: number } {
const span = Math.max(1, to - from)
return { from: from + (i / BUCKETS) * span, to: from + ((i + 1) / BUCKETS) * span }
}
function fmtTime(ms: number): string {
return new Date(ms).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
</script>
<div class="px-2 pt-1.5 pb-1 select-none">
<div
bind:this={barsEl}
class="relative flex items-end gap-px h-9 cursor-crosshair"
role="presentation"
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
title="Drag to filter to a time range; click to clear"
>
{#each bins as b, i (i)}
{@const total = b.success + b.failure}
{@const br = bucketRange(i)}
<div
class="flex-1 min-w-0 flex flex-col justify-end h-full"
style="opacity: {total ? 1 : 0.35}"
title={`${fmtTime(br.from)} – ${fmtTime(br.to)}${total ? ` · ${b.success} ok${b.failure ? `, ${b.failure} failed` : ''}` : ' · no runs'}`}
>
{#if b.failure > 0}
<div
class="w-full bg-red-400 dark:bg-red-500/80 rounded-t-[1px]"
style="height: {(b.failure / maxCount) * 100}%"
></div>
{/if}
{#if b.success > 0}
<div
class="w-full bg-emerald-400 dark:bg-emerald-500/80"
class:rounded-t-[1px]={b.failure === 0}
style="height: {(b.success / maxCount) * 100}%"
></div>
{/if}
{#if total === 0}
<div class="w-full bg-surface-secondary" style="height: 2px"></div>
{/if}
</div>
{/each}
{#if band}
<div
class="absolute top-0 bottom-0 bg-blue-500/15 border-x border-blue-500/50 pointer-events-none"
style="left: {band.left * 100}%; width: {band.width * 100}%"
></div>
{/if}
</div>
<!-- Always-on window axis: the period the bars span. -->
<div class="flex justify-between text-3xs text-tertiary tabular-nums mt-0.5">
<span>{fmtTime(from)}</span>
<span>now</span>
</div>
</div>
@@ -125,6 +125,13 @@
// form (with the auto-generated S3 picker) for the given script.
// data_upload has no trigger row; it's a UI-first entry point.
onOpenDataUpload?: (scriptPath: string) => void
// Script paths the cursor is over in the Activity panel — a thin neutral
// ring each. One entry for a single run row; the whole cascade's runs
// when hovering a group header. Distinct from `selectedRunPaths`.
hoveredPaths?: string[]
// Script paths of the expanded (pinned) run(s) — a soft blue ring that
// persists until collapsed.
selectedRunPaths?: string[]
}
let {
graph,
@@ -143,9 +150,18 @@
onEditTrigger,
onDeleteTrigger,
onOpenWebhook,
onOpenDataUpload
onOpenDataUpload,
hoveredPaths,
selectedRunPaths
}: Props = $props()
// `${kind}:${path}` ids for the hovered / pinned runs (both script and flow
// variants, since the run row's kind isn't known here).
const runIds = (paths: string[] | undefined): Set<string> =>
new Set((paths ?? []).flatMap((p) => [`script:${p}`, `flow:${p}`]))
let hoveredRunIdSet = $derived(runIds(hoveredPaths))
let selectedRunIdSet = $derived(runIds(selectedRunPaths))
const ADD_NODE_ID = '__add__'
type BuiltEdge = {
@@ -532,11 +548,25 @@
// Compensate for the + node being narrower than its layout slot
// so it visually centers over the node(s) below.
const xShift = n.id === ADD_NODE_ID ? (NODE.width - ADD_NODE_WIDTH) / 2 : 0
// Activity-panel emphasis (purely visual rings, kept off `selected`
// which swaps the details pane). Hover wins over pin so the cursor
// always tracks.
const runClass = hoveredRunIdSet.has(n.id)
? 'wm-run-hover'
: selectedRunIdSet.has(n.id)
? 'wm-run-selected'
: undefined
// Asset border matching the connecting edge's hue (only when no run
// class applies — a node is either a runnable or an asset).
const assetEmph = assetEmphasis.get(n.id)
const assetClass =
assetEmph === 'output' ? 'wm-asset-output' : assetEmph === 'input' ? 'wm-asset-input' : undefined
return {
id: n.id,
type: n.type,
position: { x: p.x + xCenter + xShift, y: p.y + 40 },
data: n.data,
class: runClass ?? assetClass,
selected: n.id === selectedId,
// All nodes non-draggable: the layout is sugiyama-computed,
// dragging would fight the reactive re-layout. Selection is
@@ -562,6 +592,29 @@
...(activeRunnableNodeId ? [activeRunnableNodeId] : [])
])
)
// Node ids emphasized from the Activity panel (hovered or pinned runs).
// Their incident edges get the same flow animation as a live run, so
// hovering a run (or a whole group) traces its data path through the graph.
let emphasizedRunIdSet = $derived(new Set<string>([...hoveredRunIdSet, ...selectedRunIdSet]))
// Asset nodes adjacent to an emphasized run, tagged by edge family so their
// border matches the edge hue: `output` (a write, blue) / `input` (a read or
// asset-trigger, gray). Output wins if an asset is both.
let assetEmphasis = $derived.by<Map<string, 'input' | 'output'>>(() => {
const m = new Map<string, 'input' | 'output'>()
if (emphasizedRunIdSet.size === 0) return m
for (const e of model.edges) {
if (e.kind === 'lineage-write' && emphasizedRunIdSet.has(e.source)) {
m.set(e.target, 'output')
} else if (
(e.kind === 'lineage-read' || e.kind === 'trigger-asset') &&
emphasizedRunIdSet.has(e.target) &&
m.get(e.source) !== 'output'
) {
m.set(e.source, 'input')
}
}
return m
})
let flowEdges = $derived.by(() =>
model.edges
// Anchor edges are layout-only.
@@ -570,6 +623,12 @@
const touchesActiveRunnable =
activeRunnableIdSet.size > 0 &&
(activeRunnableIdSet.has(e.source) || activeRunnableIdSet.has(e.target))
// Edge incident to an Activity-panel hovered/pinned run — animate
// it too so the row↔graph link is unmistakable.
const touchesEmphasis =
emphasizedRunIdSet.size > 0 &&
(emphasizedRunIdSet.has(e.source) || emphasizedRunIdSet.has(e.target))
const flowAnimated = touchesActiveRunnable || touchesEmphasis
let style: string
let animated = false
let markerColor: string | undefined = undefined
@@ -586,16 +645,16 @@
switch (e.kind) {
case 'lineage-write':
style = 'stroke: rgb(59 130 246); stroke-width: 2px;'
animated = touchesActiveRunnable
animated = flowAnimated
markerColor = 'rgb(59 130 246)'
break
case 'lineage-read':
style = 'stroke: rgb(156 163 175); stroke-width: 1.25px;'
animated = touchesActiveRunnable
animated = flowAnimated
break
case 'trigger-asset':
style = 'stroke: rgb(107 114 128); stroke-width: 2px;'
animated = touchesActiveRunnable
animated = flowAnimated
markerColor = 'rgb(107 114 128)'
label = 'triggers'
labelStyle = 'fill: rgb(107 114 128); font-size: 10px; font-weight: 600;'
@@ -743,4 +802,21 @@
:global(.svelte-flow__node.selected .drop-shadow-sm) {
@apply outline outline-2 outline-blue-500;
}
/* Activity-panel emphasis — soft, monochromatic, less prominent than the
blue details selection above. Hover is a thin neutral ring (transient);
pinning an expanded run is a soft-blue ring. */
:global(.svelte-flow__node.wm-run-hover .drop-shadow-sm) {
@apply outline outline-1 outline-gray-400 dark:outline-gray-500;
}
:global(.svelte-flow__node.wm-run-selected .drop-shadow-sm) {
@apply outline outline-2 outline-blue-400/70;
}
/* Assets adjacent to an emphasized run — border hue matches the edge:
blue for a write (output), gray for a read / asset-trigger (input). */
:global(.svelte-flow__node.wm-asset-output .drop-shadow-sm) {
@apply outline outline-2 outline-blue-400/70;
}
:global(.svelte-flow__node.wm-asset-input .drop-shadow-sm) {
@apply outline outline-2 outline-gray-400;
}
</style>
@@ -71,6 +71,12 @@
// edited past the seeded template. Fires on every keystroke that
// changes the inferred set.
onAssetsChange?: (scriptPath: string | undefined, assets: AssetWithAltAccessType[]) => void
// Emits the live editor buffer on every keystroke so the parent can
// autosave the in-flight content WITHOUT waiting for the pane teardown
// (`onDraftPersist`). `onDraftPersist` stays the authoritative commit on
// navigate-away; this is the continuous feed that keeps the draft's
// server copy current while the user is still typing.
onContentChange?: (scriptPath: string | undefined, content: string) => void
// Fires when the user navigates away from a draft (selects another
// node, closes the pane, etc.) — the parent must persist the new
// content + write outputs into its drafts Map. Without this,
@@ -196,6 +202,7 @@
onDiscard,
onAnnotationsChange,
onAssetsChange,
onContentChange,
onDraftPersist,
onScriptRenamed,
onScriptRemoved,
@@ -539,6 +546,10 @@
if (readOnly) return
onAssetsChange?.(script?.path, liveBodyAssets ?? [])
})
$effect(() => {
if (readOnly) return
onContentChange?.(script?.path, script?.content ?? '')
})
async function save() {
if (!script) return
@@ -552,7 +563,7 @@
} catch {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
await ScriptService.createScript({
const newHash = await ScriptService.createScript({
workspace,
requestBody: {
...script,
@@ -579,6 +590,11 @@
assets: (liveBodyAssets ?? []) as any
}
})
// Chain the NEXT save off the version we just created. Without this
// a second save re-sends the now-stale `parent_hash`, which the
// backend rejects as a lineage fork ("no 2 scripts can have the
// same parent").
if (typeof newHash === 'string' && newHash) script.hash = newHash
sendUserToast(`Saved ${script.path}`)
if (isDraft) {
onDraftSaved?.(script.path)
@@ -587,12 +603,56 @@
onPersistedSaved?.(script.path)
}
} catch (e: any) {
sendUserToast(`Save failed: ${e?.body ?? e?.message ?? e}`, true)
const msg = String(e?.body ?? e?.message ?? e)
// The deployed head moved off our `parent_hash` since we opened it —
// someone else (or another tab) deployed in between. Offer an
// explicit resolution instead of a cryptic lineage error.
if (/lineage must be linear|same parent_hash/i.test(msg)) {
conflictOpen = true
} else {
sendUserToast(`Save failed: ${msg}`, true)
}
} finally {
saving = false
}
}
// --- Deploy conflict resolution (another deploy landed since we opened) ---
let conflictOpen = $state(false)
let resolvingConflict = $state(false)
async function overwriteWithMine() {
if (!script) return
resolvingConflict = true
try {
// Re-base onto the current head so the retry is a linear deploy on
// top of theirs (our content wins).
const latest = await ScriptService.getScriptByPath({ workspace, path: script.path })
script.hash = String(latest.hash)
conflictOpen = false
await save()
} catch (e: any) {
sendUserToast(`Could not resolve conflict: ${e?.body ?? e?.message ?? e}`, true)
} finally {
resolvingConflict = false
}
}
async function viewLatestVersion() {
if (!script) return
resolvingConflict = true
try {
const latest = await ScriptService.getScriptByPath({ workspace, path: script.path })
// Replace the editor buffer with the latest deployed version. The
// user's unsaved edits are dropped — surfaced clearly in the modal.
script = structuredClone($state.snapshot(latest) as Script)
conflictOpen = false
sendUserToast('Loaded the latest deployed version')
} catch (e: any) {
sendUserToast(`Could not load the latest version: ${e?.body ?? e?.message ?? e}`, true)
} finally {
resolvingConflict = false
}
}
let isScriptView = $derived(
isDraft || (selection?.kind === 'runnable' && selection.runnable_kind === 'script')
)
@@ -1121,6 +1181,72 @@
</div>
{/if}
{#if conflictOpen}
<!-- Deploy conflict: the path's deployed head moved off our parent_hash
since we opened it (another user / tab deployed in between). -->
<div
transition:fade={{ duration: 100 }}
class="fixed top-0 bottom-0 left-0 right-0 z-[9999]"
role="dialog"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75"></div>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div
class="relative transform overflow-hidden rounded-lg bg-surface px-4 pt-5 pb-4 text-left shadow-xl sm:my-8 sm:w-full sm:max-w-lg sm:p-6"
>
<div class="flex">
<div
class="flex h-12 w-12 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50"
>
<AlertTriangle class="text-amber-500 dark:text-amber-400" />
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-medium text-primary">This script changed since you opened it</h3>
<div class="mt-2 text-sm text-secondary flex flex-col gap-2">
<p>
<span class="font-mono text-xs">{script?.path ?? ''}</span> was deployed by someone else
(or another tab) while you were editing, so saving on top of your version would fork its
lineage.
</p>
<p>
<span class="font-medium">Keep my version</span> deploys your changes on top of the latest.
<span class="font-medium">View latest</span> loads the newly-deployed version, discarding
your unsaved edits.
</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-row-reverse mt-4">
<Button disabled={resolvingConflict} onclick={overwriteWithMine} variant="accent" size="sm">
{#if resolvingConflict}
<Loader2 class="animate-spin" />
{/if}
<span class="min-w-20">Keep my version</span>
</Button>
<Button
disabled={resolvingConflict}
onclick={() => (conflictOpen = false)}
variant="default"
size="sm"
>
Cancel
</Button>
<Button
disabled={resolvingConflict}
onclick={viewLatestVersion}
variant="contained"
size="sm"
>
View latest version
</Button>
</div>
</div>
</div>
</div>
</div>
{/if}
<style>
/* Border-color pulse for the throwaway overlay appended to <body> when the
user clicks a data_upload source node (hence :global). border-box keeps
@@ -20,27 +20,223 @@
} from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
import { isActiveEvent, type PipelineEvent } from './activeRunnables.svelte'
import { isActiveEvent, type EventStatus, type PipelineEvent } from './activeRunnables.svelte'
import type { DispatchEdge } from './pipelineHistory.svelte'
import { RotateCcw, Workflow } from 'lucide-svelte'
import ActivityHistogram from './ActivityHistogram.svelte'
interface Props {
// Merged history + live events, newest-first (page owns the merge —
// the live poll wins on id collisions since it carries fresher status).
events: PipelineEvent[]
// Asset-cascade producer→child edges (by job id) used to group the
// runs of one cascade together.
edges?: DispatchEdge[]
loading?: boolean
// History preload hit its page cap before the days cutoff.
truncated?: boolean
error?: string | undefined
days: number
onDaysChange: (days: number) => void
// Hover a run row → emphasize its node(s) on the canvas; a group header
// passes the whole cascade's paths. `undefined` clears.
onHoverRun?: (paths: string[] | undefined) => void
// Expand/collapse a run → sticky node emphasis (undefined clears).
onSelectRun?: (paths: string[] | undefined) => void
}
let { events, loading = false, truncated = false, error, days, onDaysChange }: Props = $props()
let {
events,
edges = [],
loading = false,
truncated = false,
error,
days,
onDaysChange,
onHoverRun,
onSelectRun
}: Props = $props()
type CascadeGroup = {
// Component representative id (stable per cascade).
key: string
// Earliest originating (non-asset) run — the group header. Undefined when
// the originating run fell outside the loaded window.
root: PipelineEvent | undefined
// Additional originating triggers beyond `root` (joins fed by >1 source).
extraTriggers: number
// All loaded runs of this cascade: roots first, then newest-first.
members: PipelineEvent[]
// Worth grouping (more than one run, or an edge reaches outside window).
isCascade: boolean
// Aggregate status across members (running > failure > success).
status: EventStatus
// Newest member time — drives group ordering.
latestAt: string
}
// Histogram window (last `days`) and the brushed sub-range. The histogram
// always shows the full window; the list + groups filter to the brush.
let windowBounds = $derived.by(() => {
void events // refresh as new runs arrive
const to = Date.now()
return { from: to - days * 86400000, to }
})
let selectedRange = $state<{ from: number; to: number } | undefined>(undefined)
// Drop a stale brush when the day window changes under it.
$effect(() => {
void days
untrack(() => (selectedRange = undefined))
})
let filteredEvents = $derived.by(() => {
const r = selectedRange
if (!r) return events
return events.filter((e) => {
const t = Date.parse(e.at)
return !isNaN(t) && t >= r.from && t <= r.to
})
})
// Quick reset: drop the brush and return to the default 30-day window.
function resetWindow() {
selectedRange = undefined
if (days !== 30) onDaysChange(30)
}
function fmtRange(r: { from: number; to: number }): string {
const opt: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}
return `${new Date(r.from).toLocaleString(undefined, opt)} – ${new Date(r.to).toLocaleString(undefined, opt)}`
}
// Group cascades by the CONNECTED COMPONENT of the dispatch graph, so an
// AND-join (fed by several trigger chains) folds into one group rather than
// fragmenting by whichever input completed it last. Edges come by job id
// from `dispatched` rows; `join_pending` inputs (no child yet) are linked to
// the next firing of the same subscriber so the pre-completion contributors
// join the component too.
let groups = $derived.by<CascadeGroup[]>(() => {
// Producer→child edges (job id). `childSet` = jobs with an incoming edge
// (i.e. not an originating run).
const childEdges: Array<[string, string]> = []
const childSet = new Set<string>()
const inEdges = new Set<string>()
// Firings per subscriber path (a `dispatched` row), time-sorted, to map
// each join_pending input forward to the child it contributed to.
const firingsBySub = new Map<string, Array<{ child: string; at: number }>>()
for (const e of edges) {
if (e.outcome === 'dispatched' && e.child_job_id) {
childEdges.push([e.producer_job_id, e.child_job_id])
childSet.add(e.child_job_id)
const arr = firingsBySub.get(e.subscriber_path)
const f = { child: e.child_job_id, at: Date.parse(e.created_at) }
if (arr) arr.push(f)
else firingsBySub.set(e.subscriber_path, [f])
}
}
for (const arr of firingsBySub.values()) arr.sort((a, b) => a.at - b.at)
for (const e of edges) {
if (e.outcome !== 'join_pending') continue
const firings = firingsBySub.get(e.subscriber_path)
if (!firings) continue
const t = Date.parse(e.created_at)
const firing = firings.find((f) => f.at >= t)
if (firing) {
childEdges.push([e.producer_job_id, firing.child])
childSet.add(firing.child)
}
}
for (const [p, c] of childEdges) {
inEdges.add(p)
inEdges.add(c)
}
// Union-find over the edges.
const parent = new Map<string, string>()
const find = (x: string): string => {
let r = x
while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!
return r
}
const union = (a: string, b: string) => {
if (!parent.has(a)) parent.set(a, a)
if (!parent.has(b)) parent.set(b, b)
const ra = find(a)
const rb = find(b)
if (ra !== rb) parent.set(ra, rb)
}
for (const [p, c] of childEdges) union(p, c)
const byComponent = new Map<string, PipelineEvent[]>()
for (const e of filteredEvents) {
const k = parent.has(e.id) ? find(e.id) : e.id
const arr = byComponent.get(k)
if (arr) arr.push(e)
else byComponent.set(k, [e])
}
const out: CascadeGroup[] = []
for (const [key, members] of byComponent) {
const roots = members
.filter((m) => !childSet.has(m.id))
.sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0))
members.sort((a, b) => {
const aRoot = !childSet.has(a.id)
const bRoot = !childSet.has(b.id)
if (aRoot !== bRoot) return aRoot ? -1 : 1
if (aRoot) return a.at < b.at ? -1 : a.at > b.at ? 1 : 0 // roots earliest-first
return a.at < b.at ? 1 : a.at > b.at ? -1 : 0 // hops newest-first
})
const isCascade = members.length > 1 || members.some((m) => inEdges.has(m.id))
const anyActive = members.some((m) => m.status === 'running' || m.status === 'queued')
const anyFail = members.some((m) => m.status === 'failure')
const status: EventStatus = anyActive ? 'running' : anyFail ? 'failure' : 'success'
const latestAt = members.reduce((mx, m) => (m.at > mx ? m.at : mx), members[0].at)
out.push({
key,
root: roots[0],
extraTriggers: Math.max(0, roots.length - 1),
members,
isCascade,
status,
latestAt
})
}
out.sort((a, b) => (a.latestAt < b.latestAt ? 1 : a.latestAt > b.latestAt ? -1 : 0))
return out
})
// Collapsed cascade groups (members hidden). Default expanded.
let collapsedGroups = $state<Set<string>>(new Set())
function toggleGroup(key: string) {
const next = new Set(collapsedGroups)
if (next.has(key)) next.delete(key)
else next.add(key)
collapsedGroups = next
}
// The cascade's trigger label, shown on the group header — the earliest
// originating run's source. A loaded root is never asset-triggered (an asset
// hop always has a producer), so its `source` is the real origin; an
// unloaded root means the originating run fell outside the window.
function groupTrigger(g: CascadeGroup): string {
if (!g.root) return 'cascade'
return g.root.source === 'schedule' ? 'schedule' : 'run'
}
function groupTitle(g: CascadeGroup): string {
return g.root?.path ?? 'cascade (upstream outside window)'
}
// Window options as fractions of a day (the fetch cutoff is `days * 1d`).
const DAY_OPTIONS = [
{ label: 'Last hour', value: 1 / 24 },
{ label: 'Last 24 hours', value: 1 },
{ label: 'Last 48 hours', value: 2 },
{ label: 'Last 7 days', value: 7 },
{ label: 'Last 30 days', value: 30 },
{ label: 'Last 90 days', value: 90 }
]
let windowLabel = $derived(DAY_OPTIONS.find((o) => o.value === days)?.label ?? `Last ${days} days`)
// Excludes future-scheduled queued jobs (a schedule's next planned run
// is not activity) — see isActiveEvent.
@@ -73,8 +269,11 @@
})
})
function toggle(id: string) {
expandedId = expandedId === id ? undefined : id
function toggle(id: string, path: string) {
const willExpand = expandedId !== id
expandedId = willExpand ? id : undefined
// Expanding a run pins its node emphasis; collapsing clears it.
onSelectRun?.(willExpand ? [path] : undefined)
}
function isRunningJob(j: Job): boolean {
@@ -152,6 +351,33 @@
</div>
</div>
{#if events.length > 0}
<div class="border-b shrink-0">
<ActivityHistogram
{events}
from={windowBounds.from}
to={windowBounds.to}
selected={selectedRange}
onSelect={(r) => (selectedRange = r)}
/>
{#if selectedRange || days !== 30}
<div class="flex items-center justify-between px-2 pb-1.5 text-2xs text-tertiary">
<span class="truncate">
{selectedRange ? fmtRange(selectedRange) : windowLabel}
</span>
<button
type="button"
class="flex items-center gap-1 px-1.5 py-0.5 rounded-sm hover:bg-surface-hover text-secondary shrink-0"
onclick={resetWindow}
title="Reset to the last 30 days"
>
<RotateCcw size={11} /> Reset
</button>
</div>
{/if}
</div>
{/if}
<div class="flex-1 min-h-0 overflow-y-auto px-1 py-1">
{#if error}
<div class="px-3 py-4 text-xs text-red-600 dark:text-red-400">
@@ -164,17 +390,21 @@
</div>
{:else}
<div class="px-3 py-6 text-center text-xs text-tertiary">
No runs in the last {days} days — executions of this pipeline will appear here live.
No runs in this window ({windowLabel.toLowerCase()}) — executions of this pipeline will
appear here live.
</div>
{/if}
{:else}
{#each events as e (e.id)}
{#snippet runRow(e: PipelineEvent, isMember: boolean, showSource: boolean)}
{@const expanded = expandedId === e.id}
<button
type="button"
onclick={() => toggle(e.id)}
onclick={() => toggle(e.id, e.path)}
onmouseenter={() => onHoverRun?.([e.path])}
onmouseleave={() => onHoverRun?.(undefined)}
class={twMerge(
'w-full flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-surface-hover text-xs text-left',
isMember && 'pl-7',
expanded && 'bg-surface-selected'
)}
title={expanded ? 'Collapse run details' : 'Expand run details'}
@@ -205,16 +435,18 @@
<span class="flex-1 min-w-0 truncate font-mono text-2xs" title={e.path}>
{e.path}
</span>
<span
class={twMerge(
'shrink-0 px-1 py-0.5 rounded-sm text-3xs leading-none',
e.source === 'schedule'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
)}
>
{e.source}
</span>
{#if showSource}
<span
class={twMerge(
'shrink-0 px-1 py-0.5 rounded-sm text-3xs leading-none',
e.source === 'schedule'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400'
)}
>
{e.source}
</span>
{/if}
<span class="shrink-0 text-3xs text-tertiary tabular-nums w-14 text-right">
{isFutureScheduled(e) && e.scheduledFor ? inTxt(e.scheduledFor) : ago(e.at)}
</span>
@@ -271,6 +503,71 @@
{/if}
</div>
{/if}
{/snippet}
{#if groups.length === 0}
<div class="px-3 py-6 text-center text-xs text-tertiary">
No runs in the selected time range.
</div>
{/if}
{#each groups as g (g.key)}
{#if g.isCascade}
{@const collapsed = collapsedGroups.has(g.key)}
<!-- Cascade group header: the originating run + its trigger, the
aggregate status, and a member count. Collapses the hops. -->
<button
type="button"
onclick={() => toggleGroup(g.key)}
onmouseenter={() => onHoverRun?.(g.members.map((m) => m.path))}
onmouseleave={() => onHoverRun?.(undefined)}
class="w-full flex items-center gap-2 px-2 py-1 rounded-sm hover:bg-surface-hover text-xs text-left"
title={collapsed ? 'Expand cascade runs' : 'Collapse cascade runs'}
>
<span class="shrink-0 text-tertiary">
{#if collapsed}<ChevronRight size={12} />{:else}<ChevronDown size={12} />{/if}
</span>
<span class="shrink-0">
{#if g.status === 'running'}
<Loader2 size={12} class="animate-spin text-blue-600 dark:text-blue-400" />
{:else if g.status === 'failure'}
<XCircle size={12} class="text-red-600 dark:text-red-400" />
{:else}
<CheckCircle2 size={12} class="text-emerald-600 dark:text-emerald-400" />
{/if}
</span>
<Workflow size={12} class="shrink-0 text-indigo-500" />
<span class="flex-1 min-w-0 truncate font-mono text-2xs" title={groupTitle(g)}>
{groupTitle(g)}
</span>
<span
class={twMerge(
'shrink-0 px-1 py-0.5 rounded-sm text-3xs leading-none',
groupTrigger(g) === 'schedule'
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-indigo-100 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300'
)}
>
{groupTrigger(g)}
</span>
{#if g.extraTriggers > 0}
<span
class="shrink-0 px-1 py-0.5 rounded-sm text-3xs leading-none bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400"
title={`Join fed by ${g.extraTriggers + 1} triggers`}>+{g.extraTriggers}</span
>
{/if}
<span class="shrink-0 text-3xs text-tertiary tabular-nums">{g.members.length} runs</span>
<span class="shrink-0 text-3xs text-tertiary tabular-nums w-14 text-right">
{ago(g.latestAt)}
</span>
</button>
{#if !collapsed}
{#each g.members as m (m.id)}
{@render runRow(m, true, false)}
{/each}
{/if}
{:else}
{@render runRow(g.members[0], false, true)}
{/if}
{/each}
{#if truncated}
<div class="px-3 py-2 text-center text-2xs text-tertiary">
@@ -1,6 +1,21 @@
import { JobService } from '$lib/gen'
import type { PipelineEvent } from './activeRunnables.svelte'
// One asset-cascade dispatch record (from the dispatch_event table). A
// `dispatched` row carries the resolved `child_job_id` (a producer→child job
// edge); a `join_pending` row is a pre-completion AND-join input with no child
// yet — the panel links it to the eventual child of the same subscriber so a
// join's separate trigger chains land in one group.
export type DispatchEdge = {
producer_job_id: string
child_job_id?: string
subscriber_path: string
outcome: 'dispatched' | 'join_pending'
asset_path: string
asset_kind: string
created_at: string
}
// Page size is the API max; 3 pages keeps the preload bounded on noisy
// folders — the panel notes the truncation instead of silently capping.
const PER_PAGE = 100
@@ -21,6 +36,7 @@ export function usePipelineHistory(
getEnabled: () => boolean
) {
let events = $state<PipelineEvent[]>([])
let edges = $state<DispatchEdge[]>([])
let loading = $state(false)
let truncated = $state(false)
let error = $state<string | undefined>(undefined)
@@ -61,7 +77,21 @@ export function usePipelineHistory(
}
sawFullPage = rows.length === PER_PAGE
}
// Cascade edges for the same window. Best-effort: a failure here
// must not blank the activity list, so it only degrades grouping.
let edgeRows: DispatchEdge[] = []
try {
edgeRows = (await JobService.listAssetDispatchEdges({
workspace: ws,
pathStart: prefix,
createdAfter: cutoff
})) as DispatchEdge[]
} catch (e) {
console.warn('failed to load pipeline dispatch edges', e)
}
if (gen !== myGen) return
events = out
edges = edgeRows
truncated = sawFullPage
} catch (e: any) {
if (gen !== myGen) return
@@ -89,6 +119,10 @@ export function usePipelineHistory(
get events() {
return events
},
/** Asset-cascade producer→child edges over the same window. */
get edges() {
return edges
},
get loading() {
return loading
},
@@ -5,8 +5,15 @@
import Button from '$lib/components/common/button/Button.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import AssetGraphCanvas from '$lib/components/assets/AssetGraph/AssetGraphCanvas.svelte'
import { useActiveRunnableIds } from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
import type { PipelineEvent } from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
import {
useActiveRunnableIds,
isActiveEvent
} from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
import type {
PipelineEvent,
RunnableRunState,
RunStatus
} from '$lib/components/assets/AssetGraph/activeRunnables.svelte'
import { usePipelineHistory } from '$lib/components/assets/AssetGraph/pipelineHistory.svelte'
import PipelineEventLog from '$lib/components/assets/AssetGraph/PipelineEventLog.svelte'
import PipelineActivityPanel from '$lib/components/assets/AssetGraph/PipelineActivityPanel.svelte'
@@ -47,6 +54,7 @@
import { decodeState, encodeState } from '$lib/utils'
import { DraftService } from '$lib/gen'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
import { onMount, tick, untrack } from 'svelte'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import {
@@ -113,6 +121,18 @@
let urlMode = $derived(page.url.searchParams.get('mode'))
let mode = $derived<PipelineMode>(isOperator ? 'view' : urlMode === 'edit' ? 'edit' : 'view')
function setMode(m: PipelineMode, opts?: { replace?: boolean }) {
// Switching edit→view re-surfaces the activity feed: drop the edit-mode
// selection / open draft / hidden-pane state so view opens on Activity
// rather than a stale details pane (mirrors toggleActivity's show path).
if (m === 'view' && mode === 'edit') {
selection = undefined
activeDraftPath = undefined
panelHidden = false
liveAnnotations = {
scriptPath: undefined,
annotations: { inPipeline: false, triggerAssets: [], nativeTriggers: [] }
}
}
const url = new URL(page.url)
if (m === 'view') url.searchParams.delete('mode')
else url.searchParams.set('mode', m)
@@ -269,6 +289,9 @@
// just-loaded value itself) isn't re-POSTed.
let draftsHydrated = $state(false)
let lastPersistedBundle: string | undefined = undefined
// True once a bundle was restored from the DB on load — drives the
// AutosaveIndicator's one-shot "Loaded from draft" hint.
let loadedFromDbDraft = $state(false)
function restoreBundle(bundle: PipelineDraftBundle) {
if (Array.isArray(bundle.drafts)) {
@@ -329,6 +352,7 @@
if (row?.value) {
bundle = row.value as PipelineDraftBundle
serverSavedAt = row.created_at
loadedFromDbDraft = true
}
}
// One-time migration: no DB draft yet, but an older build left a
@@ -377,14 +401,29 @@
? extractWrites(liveBodyAssets.assets)
: undefined
const liveWritesPath = liveBodyAssets.scriptPath
// Live editor buffer for the open draft. `onDraftPersist` only commits
// content into the Map on pane teardown, so without this overlay the
// autosave (and a crash reload) would lag a whole editing session behind.
const liveContentPath =
liveContent.scriptPath != undefined && drafts.has(liveContent.scriptPath)
? liveContent.scriptPath
: undefined
const liveContentValue = liveContent.content
const serialized = Array.from(drafts.entries()).map(([p, d]) => {
if (liveWritesSnapshot != undefined && liveWritesPath === p) {
return [
p,
{ ...d, outputAssets: liveWritesSnapshot.length > 0 ? liveWritesSnapshot : undefined }
] as [string, Draft]
const outputAssets =
liveWritesSnapshot != undefined && liveWritesPath === p
? liveWritesSnapshot.length > 0
? liveWritesSnapshot
: undefined
: d.outputAssets
const script =
liveContentPath === p && d.script.content !== liveContentValue
? { ...d.script, content: liveContentValue }
: d.script
if (script === d.script && outputAssets === d.outputAssets) {
return [p, d] as [string, Draft]
}
return [p, d] as [string, Draft]
return [p, { ...d, script, outputAssets }] as [string, Draft]
})
const activePath = activeDraftPath
const key = storageKey
@@ -449,6 +488,16 @@
assets: AssetWithAltAccessType[]
}>({ scriptPath: undefined, assets: [] })
// The open draft's live editor buffer, emitted by the pane on every
// keystroke (`onContentChange`). The persist effect overlays it onto the
// Map's copy so autosave reflects in-progress edits — `onDraftPersist`
// only commits content into the Map on pane teardown, which would leave
// autosave a full editing session behind.
let liveContent = $state<{ scriptPath: string | undefined; content: string }>({
scriptPath: undefined,
content: ''
})
// Only-add cache of (script_path → body content) populated lazily by
// `bodyFetchEffect` for every script in the current folder. We never
// remove entries: stale keys (renamed-away, deleted) are simply ignored
@@ -865,6 +914,9 @@
if (liveBodyAssets.scriptPath === path) {
liveBodyAssets = { scriptPath: undefined, assets: [] }
}
if (liveContent.scriptPath === path) {
liveContent = { scriptPath: undefined, content: '' }
}
clearSaveError(path)
}
@@ -904,6 +956,9 @@
if (liveBodyAssets.scriptPath === oldPath) {
liveBodyAssets = { ...liveBodyAssets, scriptPath: newPath }
}
if (liveContent.scriptPath === oldPath) {
liveContent = { ...liveContent, scriptPath: newPath }
}
// `inferredWritesByPath` / `inferredReadsByPath` /
// `annotatedNativeKindsByPath` are derived from `g.runnables` ×
// the body caches, so a rename naturally re-derives them when the
@@ -1090,6 +1145,9 @@
// next reactive tick.
liveBodyAssets = { scriptPath, assets }
}
function handleContentChange(scriptPath: string | undefined, content: string) {
liveContent = { scriptPath, content }
}
function handleDraftPersist(
p: string,
snapshot: {
@@ -1412,6 +1470,9 @@
}
liveBodyAssets = { scriptPath: undefined, assets: [] }
}
if (liveContent.scriptPath != undefined) {
liveContent = { scriptPath: undefined, content: '' }
}
})
})
// Drafts are only addressable in view mode while the drafts overlay is
@@ -1477,11 +1538,13 @@
// View-mode activity panel: historical runs preloaded for the last N
// days (user-configurable, persisted) merged with the live poll's events.
const ACTIVITY_DAYS_KEY = 'pipeline-activity-days'
// Allowed window sizes in days (fractions for the sub-day ranges).
const ACTIVITY_WINDOW_DAYS = [1 / 24, 1, 2, 7, 30, 90]
let activityDays = $state(30)
onMount(() => {
if (typeof localStorage === 'undefined') return
const stored = Number(localStorage.getItem(ACTIVITY_DAYS_KEY))
if ([7, 30, 90].includes(stored)) activityDays = stored
if (ACTIVITY_WINDOW_DAYS.some((d) => Math.abs(d - stored) < 1e-9)) activityDays = stored
})
function setActivityDays(days: number) {
activityDays = days
@@ -1503,6 +1566,33 @@
for (const e of activeRunnables.events) byId.set(e.id, e)
return Array.from(byId.values()).sort((a, b) => b.at.localeCompare(a.at))
})
// Node run-count/status badges, derived from the SAME merged event set the
// Activity panel shows (historic preload + live poll) so the graph badges
// and the panel never disagree. `activityEvents` is newest-first, so the
// first event per runnable sets the badge status and the rest add to the
// count. Future-scheduled queued runs aren't activity — skip them.
let mergedRunStates = $derived.by<Map<string, RunnableRunState>>(() => {
const m = new Map<string, RunnableRunState>()
for (const e of activityEvents) {
if (e.status === 'queued' && !isActiveEvent(e)) continue
const id = `${e.kind}:${e.path}`
const status: RunStatus =
e.status === 'running' || e.status === 'queued'
? 'running'
: e.status === 'failure'
? 'failure'
: 'success'
const cur = m.get(id)
if (cur) cur.runs += 1
else m.set(id, { status, runs: 1 })
}
return m
})
// Activity-panel → canvas node emphasis. Hover and the pinned expanded run
// are fed to the canvas as separate path sets so they render distinctly; a
// group-header hover passes the whole cascade.
let activityHoverPaths = $state<string[]>([])
let activitySelectPaths = $state<string[]>([])
// Release the zero-latency `activeRunnable` hint for a per-node run of a
// script that isn't open in the pane: such runs have no editor Test callback
// to clear it, so without this the node's badge stays 'running' forever (the
@@ -2166,6 +2256,18 @@
</Popover>
{/if}
{#if mode === 'edit' && drafts.size > 0}
<!-- Draft autosave status for the whole pipeline bundle. Distinct
from "Save all", which DEPLOYS the drafts — this only reflects
that in-flight edits are persisted to the per-user server draft. -->
{#if $workspaceStore}
<AutosaveIndicator
workspace={$workspaceStore}
itemKind="data_pipeline"
path={pipelineDraftPath}
draftOnly
loadedFromDraft={loadedFromDbDraft}
/>
{/if}
<Button
variant="accent"
unifiedSize="sm"
@@ -2219,9 +2321,11 @@
<AssetGraphCanvas
graph={displayGraph}
selection={effectiveSelection}
hoveredPaths={activityHoverPaths}
selectedRunPaths={activitySelectPaths}
{activeRunnable}
activeRunnableIds={activeRunnables.ids}
runStates={activeRunnables.states}
runStates={mergedRunStates}
{pathPrefix}
defaultPathSuffix={DEFAULT_PATH_SUFFIX}
onCreateMissingTrigger={mode === 'edit' ? openMissingTriggerDrawer : undefined}
@@ -2279,11 +2383,14 @@
here. -->
<PipelineActivityPanel
events={activityEvents}
edges={pipelineHistory.edges}
loading={pipelineHistory.loading}
truncated={pipelineHistory.truncated}
error={pipelineHistory.error}
days={activityDays}
onDaysChange={setActivityDays}
onHoverRun={(p) => (activityHoverPaths = p ?? [])}
onSelectRun={(p) => (activitySelectPaths = p ?? [])}
/>
{:else}
<AssetGraphDetailsPane
@@ -2333,6 +2440,7 @@
workspace={$workspaceStore}
onAnnotationsChange={handleAnnotationsChange}
onAssetsChange={handleAssetsChange}
onContentChange={handleContentChange}
onDraftPersist={handleDraftPersist}
onclose={() => {
// Close dismisses the pane but preserves drafts so