mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
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:
co-authored by
Claude Opus 5.5
parent
818ddd5d31
commit
fcb2d1a18b
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user