feat: show latest runs and attached triggers on home rows

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diego Imbert
2026-10-07 14:01:30 +02:00
co-authored by Claude Opus 5.5
parent 818ddd5d31
commit fcb2d1a18b
10 changed files with 570 additions and 27 deletions
+91
View File
@@ -12779,6 +12779,51 @@ paths:
description: owner prefix (f/<folder> or u/<user>) to count
additionalProperties:
type: integer
/w/{workspace}/runnables/activity:
post:
summary: latest runs and attached triggers of a batch of scripts and flows
operationId: getRunnablesActivity
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
description: script and flow paths to report on (at most 500 in total)
required: true
content:
application/json:
schema:
type: object
properties:
scripts:
type: array
items:
type: string
flows:
type: array
items:
type: string
responses:
"200":
description: >-
activity per path, keyed separately for scripts and flows. Paths the
caller cannot see, and paths with neither runs nor triggers, are omitted.
content:
application/json:
schema:
type: object
required:
- scripts
- flows
properties:
scripts:
type: object
additionalProperties:
$ref: "#/components/schemas/RunnableActivity"
flows:
type: object
additionalProperties:
$ref: "#/components/schemas/RunnableActivity"
/w/{workspace}/flows/list:
get:
summary: list all flows
@@ -33273,6 +33318,52 @@ components:
- http_method
- is_static_website
RunnableActivity:
type: object
required:
- recent_runs
- triggers
properties:
recent_runs:
description: latest started runs, newest first (at most 9)
type: array
items:
type: object
required:
- id
- created_at
- status
properties:
id:
type: string
format: uuid
created_at:
type: string
format: date-time
status:
type: string
enum: [success, failure, canceled, skipped, running]
triggers:
description: schedules and triggers whose target is this runnable
type: array
items:
type: object
required:
- kind
- path
- mode
properties:
kind:
description: >-
schedule, a trigger kind (http, websocket, kafka, nats, postgres,
mqtt, amqp, sqs, gcp, azure, email) or a native service name
type: string
path:
type: string
mode:
type: string
enum: [enabled, disabled, suspended]
TriggersCount:
type: object
properties:
+178 -1
View File
@@ -25,7 +25,7 @@ use crate::db::{ApiAuthed, DB};
use crate::utils::{build_scope_path_filter, ScopePathFilter};
use axum::{
extract::{Extension, Path, Query},
routing::get,
routing::{get, post},
Json, Router,
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
@@ -43,6 +43,7 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_runnables))
.route("/counts", get(count_runnables_by_owner))
.route("/activity", post(get_runnables_activity))
}
#[derive(Deserialize)]
@@ -1086,3 +1087,179 @@ async fn add_draft_counts(
}
Ok(())
}
/// How many of a runnable's latest runs `/activity` returns.
const ACTIVITY_RECENT_RUNS: i64 = 9;
/// Upper bound on the paths one `/activity` call may ask about.
const ACTIVITY_MAX_PATHS: usize = 500;
#[derive(Deserialize)]
struct RunnablesActivityRequest {
#[serde(default)]
scripts: Vec<String>,
#[serde(default)]
flows: Vec<String>,
}
#[derive(Serialize, Default)]
struct RunnableActivity {
recent_runs: Vec<RecentRun>,
triggers: Vec<AttachedTrigger>,
}
#[derive(Serialize, sqlx::FromRow)]
struct RecentRun {
#[serde(skip)]
runnable_path: String,
#[serde(skip)]
is_flow: bool,
id: uuid::Uuid,
created_at: chrono::DateTime<chrono::Utc>,
/// `success` | `failure` | `canceled` | `skipped` | `running`
status: String,
}
#[derive(Serialize, sqlx::FromRow)]
struct AttachedTrigger {
#[serde(skip)]
script_path: String,
#[serde(skip)]
is_flow: bool,
/// `schedule`, a trigger kind (`http`, `kafka`, …) or a native service name.
kind: String,
path: String,
/// `enabled` | `disabled` | `suspended`
mode: String,
}
#[derive(Serialize, Default)]
struct RunnablesActivityResponse {
scripts: HashMap<String, RunnableActivity>,
flows: HashMap<String, RunnableActivity>,
}
/// The latest runs and the attached triggers of a batch of scripts and flows, for
/// the homepage rows. Only paths the caller can see as a deployed script or flow
/// are answered: the `visible` CTE runs under RLS, and `native_trigger`, which has
/// no RLS, is only reached through it.
async fn get_runnables_activity(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
Json(req): Json<RunnablesActivityRequest>,
) -> JsonResult<RunnablesActivityResponse> {
let mut paths: Vec<String> = vec![];
let mut is_flows: Vec<bool> = vec![];
for (list, is_flow) in [(req.scripts, false), (req.flows, true)] {
for p in list {
paths.push(p);
is_flows.push(is_flow);
}
}
if paths.len() > ACTIVITY_MAX_PATHS {
return Err(Error::BadRequest(format!(
"at most {ACTIVITY_MAX_PATHS} paths per request"
)));
}
let mut res = RunnablesActivityResponse::default();
if paths.is_empty() {
return Ok(Json(res));
}
const VISIBLE: &str = "WITH visible AS ( \
SELECT p.path, p.is_flow FROM unnest($2::text[], $3::bool[]) AS p(path, is_flow) \
WHERE (p.is_flow AND EXISTS (SELECT 1 FROM flow f WHERE f.workspace_id = $1 AND f.path = p.path)) \
OR (NOT p.is_flow AND EXISTS (SELECT 1 FROM script s WHERE s.workspace_id = $1 AND s.path = p.path)))";
// A run not yet started is left out, so a schedule's next pending job does not
// show up as activity. The LATERAL walks `ix_job_root_job_index_by_path_2`.
let runs_sql = format!(
"{VISIBLE} \
SELECT v.path AS runnable_path, v.is_flow, j.id, j.created_at, j.status \
FROM visible v CROSS JOIN LATERAL ( \
SELECT job.id, job.created_at, COALESCE(c.status::text, 'running') AS status \
FROM v2_job job \
LEFT JOIN v2_job_completed c ON c.id = job.id \
LEFT JOIN v2_job_queue q ON q.id = job.id \
WHERE job.workspace_id = $1 AND job.runnable_path = v.path AND job.parent_job IS NULL \
AND job.kind = (CASE WHEN v.is_flow THEN 'flow' ELSE 'script' END)::job_kind \
AND (c.id IS NOT NULL OR q.running) \
ORDER BY job.created_at DESC LIMIT $4 \
) j \
ORDER BY j.created_at DESC"
);
let branch = |table: &str, kind: &str, mode: &str| {
format!(
"SELECT '{kind}' AS kind, path, script_path, is_flow, {mode} AS mode FROM {table} \
WHERE workspace_id = $1 AND script_path = ANY($2)"
)
};
let trigger_mode = "mode::text";
let bool_mode = "CASE WHEN enabled THEN 'enabled' ELSE 'disabled' END";
let branches = [
branch("schedule", "schedule", bool_mode),
branch("http_trigger", "http", trigger_mode),
branch("websocket_trigger", "websocket", trigger_mode),
branch("kafka_trigger", "kafka", trigger_mode),
branch("nats_trigger", "nats", trigger_mode),
branch("postgres_trigger", "postgres", trigger_mode),
branch("mqtt_trigger", "mqtt", trigger_mode),
branch("amqp_trigger", "amqp", trigger_mode),
branch("sqs_trigger", "sqs", trigger_mode),
branch("gcp_trigger", "gcp", trigger_mode),
branch("azure_trigger", "azure", trigger_mode),
branch("email_trigger", "email", trigger_mode),
format!(
"SELECT service_name::text AS kind, COALESCE(NULLIF(summary, ''), external_id) AS path, \
script_path, is_flow, {bool_mode} AS mode FROM native_trigger \
WHERE workspace_id = $1 AND script_path = ANY($2)"
),
];
let triggers_sql = format!(
"{VISIBLE} \
SELECT t.* FROM ({}) t JOIN visible v ON v.path = t.script_path AND v.is_flow = t.is_flow \
ORDER BY t.kind, t.path",
branches.join(" UNION ALL ")
);
let mut tx = user_db.begin(&authed).await?;
let runs = sqlx::query_as::<_, RecentRun>(&runs_sql)
.bind(&w_id)
.bind(&paths)
.bind(&is_flows)
.bind(ACTIVITY_RECENT_RUNS)
.fetch_all(&mut *tx)
.await?;
let triggers = sqlx::query_as::<_, AttachedTrigger>(&triggers_sql)
.bind(&w_id)
.bind(&paths)
.bind(&is_flows)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
for r in runs {
let map = if r.is_flow {
&mut res.flows
} else {
&mut res.scripts
};
map.entry(r.runnable_path.clone())
.or_default()
.recent_runs
.push(r);
}
for t in triggers {
let map = if t.is_flow {
&mut res.flows
} else {
&mut res.scripts
};
map.entry(t.script_path.clone())
.or_default()
.triggers
.push(t);
}
Ok(Json(res))
}
@@ -88,10 +88,10 @@
{#if agent.canWrite}
<span class="hidden md:inline-flex">
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
variant="default"
wrapperClasses="w-16"
startIcon={{ icon: Pen }}
unifiedSize="md"
href={editHref}
>
Edit
@@ -122,8 +122,8 @@
{#if app.execution_mode == 'anonymous'}
<Badge small icon={{ icon: Eye }}>Public</Badge>
{/if}
{#if app.raw_app}
<Badge small icon={{ icon: FileJson }}>Raw</Badge>
{#if !app.raw_app}
<Badge small title="Low-code app">Legacy</Badge>
{/if}
<SharedBadge canWrite={app.canWrite} extraPerms={app.extra_perms} />
<DraftBadge
@@ -155,7 +155,7 @@
</div>
{/if}
<InheritedLabels labels={app.inherited_labels} />
<div class="w-8 center-center"></div>
<div class="w-5 center-center"></div>
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
@@ -165,8 +165,8 @@
<Button
aiId={`edit-app-button-${app.summary?.length > 0 ? app.summary : app.path}`}
aiDescription={`Edits the app ${app.summary?.length > 0 ? app.summary : app.path}`}
variant="subtle"
wrapperClasses="w-20"
variant="default"
wrapperClasses="w-16"
startIcon={{ icon: Pen }}
href="{base}/apps{app.raw_app ? '_raw' : ''}/edit/{app.path}"
>
@@ -189,17 +189,17 @@
</div>
{/if}
<InheritedLabels labels={flow.inherited_labels} />
<div class="w-8 center-center"></div>
<div class="w-5 center-center"></div>
{/snippet}
{#snippet actions()}
<span class="hidden md:inline-flex gap-x-1">
{#if !hideForOperator && showEditButton && flow.canWrite && !flow.archived}
<div>
<Button
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
variant="default"
wrapperClasses="w-16"
startIcon={{ icon: Pen }}
unifiedSize="md"
href="{base}/flows/edit/{flow.path}"
aiId={`edit-flow-button-${flow.summary?.length > 0 ? flow.summary : flow.path}`}
aiDescription={`Edits the flow ${flow.summary?.length > 0 ? flow.summary : flow.path}`}
@@ -9,6 +9,7 @@
import Tooltip from '../../meltComponents/Tooltip.svelte'
import Checkbox from '../checkbox/Checkbox.svelte'
import type { RowSelection } from './rowSelection'
import RunnableActivityCells from '$lib/components/home/RunnableActivityCells.svelte'
interface Props {
marked: string | undefined
@@ -255,21 +256,30 @@
{/if}
{#if badges}
<div class="hidden lg:flex flex-row gap-4 items-center">
<div class="hidden lg:flex flex-row gap-1.5 items-center">
{@render badges?.()}
</div>
{/if}
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')}
<div class="center-center h-full text-xs font-semibold text-secondary w-9">
<Star {kind} {path} {workspaceId} {summary} />
</div>
{:else}
<div class="w-9"></div>
{/if}
<div class="flex items-center gap-1.5 shrink-0">
{#if kind == 'script' || kind == 'flow' || kind == 'app' || kind == 'raw_app' || kind == 'agent'}
<RunnableActivityCells
kind={kind == 'script' || kind == 'flow' ? kind : undefined}
{path}
/>
{/if}
<div data-row-actions class="flex gap-1 items-center justify-end pr-2">
{@render actions?.()}
{#if canFavorite && (kind == 'app' || kind == 'raw_app' || kind == 'script' || kind == 'flow')}
<div class="center-center h-full text-xs font-semibold text-secondary w-7">
<Star {kind} {path} {workspaceId} {summary} />
</div>
{:else}
<div class="w-7"></div>
{/if}
<div data-row-actions class="flex gap-1 items-center justify-end pr-2">
{@render actions?.()}
</div>
</div>
</div>
@@ -237,7 +237,7 @@
</div>
{/if}
<InheritedLabels labels={script.inherited_labels} />
<div class="w-8 center-center">
<div class="w-5 center-center">
<LanguageIcon lang={script.language} width={16} height={16} />
</div>
{/snippet}
@@ -257,10 +257,10 @@
<Button
aiId={`edit-script-button-${script.summary?.length > 0 ? script.summary : script.path}`}
aiDescription={`Edits the script ${script.summary?.length > 0 ? script.summary : script.path}`}
variant="subtle"
wrapperClasses="w-20"
unifiedSize="md"
variant="default"
wrapperClasses="w-16"
startIcon={{ icon: Pen }}
unifiedSize="md"
href="{base}/scripts/edit/{script.path}"
>
Edit
@@ -64,6 +64,7 @@
import { base } from '$lib/base'
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
import { HomeActivity, setHomeActivity } from './homeActivity.svelte'
const operatorBuilderFlows = useOperatorBuilderFlows()
@@ -1867,6 +1868,13 @@
$workspaceStore
untrack(() => homeSelection.exit())
})
// Latest runs and attached triggers, fetched in batches for the rows on screen.
const homeActivity = new HomeActivity(() => $workspaceStore)
setHomeActivity(homeActivity)
$effect(() => {
$workspaceStore
untrack(() => homeActivity.reset())
})
// Only folders/user spaces the user owns: a move into any other lands as a
// per-item permission error the user could have been spared.
let moveTargets = $derived(
@@ -0,0 +1,157 @@
<script lang="ts">
import { base } from '$lib/base'
import { Tooltip } from '$lib/components/meltComponents'
import { triggerDisplayNamesMap, triggerIconMapMono } from '$lib/components/triggers/utils'
import { displayDate } from '$lib/utils'
import { twMerge } from 'tailwind-merge'
import { getHomeActivity, type ActivityKind } from './homeActivity.svelte'
interface Props {
/** Undefined for rows that have neither runs nor triggers (apps): the cells
* still take their width so the columns line up across the list. */
kind: ActivityKind | undefined
path: string
}
let { kind, path }: Props = $props()
const GRID_SIZE = 9
// Past this many triggers the last visible slot becomes the `+N` badge.
const MAX_TRIGGER_ICONS = 3
const homeActivity = getHomeActivity()
$effect(() => {
if (kind) homeActivity?.request(kind, path)
})
let activity = $derived(kind ? homeActivity?.get(kind, path) : undefined)
let runs = $derived(activity?.recent_runs ?? [])
let triggers = $derived(activity?.triggers ?? [])
let visibleTriggers = $derived(
triggers.length > MAX_TRIGGER_ICONS ? triggers.slice(0, MAX_TRIGGER_ICONS - 1) : triggers
)
let hiddenTriggers = $derived(triggers.slice(visibleTriggers.length))
type RunStatus = (typeof runs)[number]['status']
const statusClass: Record<RunStatus, string> = {
success: 'bg-green-500',
failure: 'bg-red-500',
canceled: 'bg-gray-400 dark:bg-gray-500',
skipped: 'bg-gray-300 dark:bg-gray-600',
running: 'bg-blue-500 animate-pulse'
}
const statusLabel: Record<RunStatus, string> = {
success: 'Success',
failure: 'Failure',
canceled: 'Canceled',
skipped: 'Skipped',
running: 'Running'
}
function triggerLabel(kind: string): string {
return triggerDisplayNamesMap[kind as keyof typeof triggerDisplayNamesMap] ?? kind
}
function triggerIcon(kind: string) {
return triggerIconMapMono[kind as keyof typeof triggerIconMapMono]
}
</script>
{#if homeActivity}
<div class="hidden lg:flex items-center justify-end gap-0.5 w-[4.5rem] shrink-0">
{#each visibleTriggers as trigger (trigger.kind + trigger.path)}
<Tooltip>
{@render triggerChip(trigger.kind, trigger.mode)}
{#snippet text()}
{@render triggerLine(trigger)}
{/snippet}
</Tooltip>
{/each}
{#if hiddenTriggers.length > 0}
<Tooltip>
<div
class="h-5 min-w-5 px-1 rounded-md bg-surface-secondary text-3xs font-semibold text-secondary center-center"
>
+{hiddenTriggers.length}
</div>
{#snippet text()}
<div class="flex flex-col gap-1">
<span class="font-semibold">{triggers.length} triggers</span>
{#each triggers as trigger (trigger.kind + trigger.path)}
{@render triggerLine(trigger)}
{/each}
</div>
{/snippet}
</Tooltip>
{/if}
</div>
<div class="hidden lg:flex w-5 shrink-0 justify-center ml-1.5">
{#if kind}
<Tooltip>
<a
href="{base}/runs/{path}"
class="grid grid-cols-3 gap-px p-0.5 rounded hover:bg-surface-secondary"
aria-label="Latest runs"
>
{#each Array(GRID_SIZE) as _, i (i)}
{@const run = runs[i]}
<div
class={twMerge(
'w-1 h-1 rounded-[1px]',
run ? statusClass[run.status] : 'bg-surface-secondary'
)}
></div>
{/each}
</a>
{#snippet text()}
{#if runs.length === 0}
<span>No runs yet</span>
{:else}
<div class="flex flex-col gap-1">
<span class="font-semibold">Latest {runs.length} runs</span>
{#each runs as run (run.id)}
<div class="flex items-center gap-2">
<div class={twMerge('w-2 h-2 rounded-[1px]', statusClass[run.status])}></div>
<span class="w-16">{statusLabel[run.status]}</span>
<span class="text-hint">{displayDate(run.created_at, true)}</span>
</div>
{/each}
</div>
{/if}
{/snippet}
</Tooltip>
{/if}
</div>
{/if}
{#snippet triggerChip(kind: string, mode: string)}
{@const Icon = triggerIcon(kind)}
<div
class={twMerge(
'h-5 w-5 rounded-md bg-surface-secondary text-secondary center-center',
mode === 'enabled' ? '' : 'opacity-50'
)}
>
{#if Icon}
<Icon size={12} />
{:else}
<span class="text-3xs font-semibold">{kind.slice(0, 1).toUpperCase()}</span>
{/if}
</div>
{/snippet}
{#snippet triggerLine(trigger: { kind: string; path: string; mode: string })}
{@const Icon = triggerIcon(trigger.kind)}
<div class="flex items-center gap-2">
{#if Icon}
<Icon size={12} />
{/if}
<span class="font-semibold">{triggerLabel(trigger.kind)}</span>
<span>{trigger.path}</span>
{#if trigger.mode !== 'enabled'}
<span class="text-hint">({trigger.mode})</span>
{/if}
</div>
{/snippet}
@@ -0,0 +1,100 @@
import { getContext, setContext } from 'svelte'
import { SvelteMap } from 'svelte/reactivity'
import { ScriptService, type RunnableActivity } from '$lib/gen'
export type ActivityKind = 'script' | 'flow'
const CONTEXT_KEY = 'homeActivity'
// The server caps one call at this many paths.
const MAX_PATHS_PER_CALL = 500
const EMPTY: RunnableActivity = { recent_runs: [], triggers: [] }
/**
* Latest runs and attached triggers of the rows the home list renders. Rows
* `request` their path as they mount; the requests of one tick are sent as a
* single batched call, so a page of rows costs one round trip, not one per row.
*/
export class HomeActivity {
#workspace: () => string | undefined
#data = new SvelteMap<string, RunnableActivity>()
#requested = new Set<string>()
#pending = new Map<string, { kind: ActivityKind; path: string }>()
#flushScheduled = false
#generation = 0
// Read by `request`, so a row's requesting effect re-runs after a reset.
#version = $state(0)
constructor(workspace: () => string | undefined) {
this.#workspace = workspace
}
request(kind: ActivityKind, path: string) {
void this.#version
const key = activityKey(kind, path)
if (this.#requested.has(key)) return
this.#requested.add(key)
this.#pending.set(key, { kind, path })
if (!this.#flushScheduled) {
this.#flushScheduled = true
queueMicrotask(() => this.#flush())
}
}
/** `undefined` until loaded; a loaded path with no activity reads as empty. */
get(kind: ActivityKind, path: string): RunnableActivity | undefined {
return this.#data.get(activityKey(kind, path))
}
/** Drop everything loaded, e.g. on workspace change or after a list reload. */
reset() {
this.#generation++
this.#data.clear()
this.#requested.clear()
this.#pending.clear()
this.#version++
}
async #flush() {
this.#flushScheduled = false
const workspace = this.#workspace()
const batch = [...this.#pending.values()]
this.#pending.clear()
if (!workspace || batch.length === 0) return
const generation = this.#generation
for (let i = 0; i < batch.length; i += MAX_PATHS_PER_CALL) {
const chunk = batch.slice(i, i + MAX_PATHS_PER_CALL)
try {
const res = await ScriptService.getRunnablesActivity({
workspace,
requestBody: {
scripts: chunk.filter((c) => c.kind === 'script').map((c) => c.path),
flows: chunk.filter((c) => c.kind === 'flow').map((c) => c.path)
}
})
if (generation !== this.#generation) return
for (const c of chunk) {
const map = c.kind === 'flow' ? res.flows : res.scripts
this.#data.set(activityKey(c.kind, c.path), map[c.path] ?? EMPTY)
}
} catch (e) {
// The indicators are decorative: leave the rows without them, and let
// a later request retry.
console.error('Could not load runnables activity', e)
for (const c of chunk) this.#requested.delete(activityKey(c.kind, c.path))
}
}
}
}
function activityKey(kind: ActivityKind, path: string): string {
return `${kind}:${path}`
}
export function setHomeActivity(activity: HomeActivity) {
setContext(CONTEXT_KEY, activity)
}
export function getHomeActivity(): HomeActivity | undefined {
return getContext<HomeActivity | undefined>(CONTEXT_KEY)
}