mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
fix: show folder item counts on the homepage tree before expansion
This commit is contained in:
@@ -10548,6 +10548,47 @@ paths:
|
||||
$ref: "#/components/schemas/RunnableItem"
|
||||
next_cursor:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/runnables/count_by_owner:
|
||||
get:
|
||||
summary: count visible runnables per top-level owner (f/<folder> or u/<user>)
|
||||
operationId: countRunnablesByOwner
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: kinds
|
||||
in: query
|
||||
description: comma-separated subset of script,flow,app (default all)
|
||||
schema:
|
||||
type: string
|
||||
- name: show_archived
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: include_without_main
|
||||
in: query
|
||||
description: include library scripts (no runnable main)
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: total runnables per owner prefix (owners with no visible items are omitted)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- owner
|
||||
- count
|
||||
properties:
|
||||
owner:
|
||||
type: string
|
||||
description: "top-level owner prefix: f/<folder> or u/<user>"
|
||||
count:
|
||||
type: integer
|
||||
/w/{workspace}/flows/list:
|
||||
get:
|
||||
summary: list all flows
|
||||
|
||||
@@ -38,7 +38,9 @@ use windmill_types::scripts::ScriptHash;
|
||||
use windmill_types::user_drafts::DraftUserRef;
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new().route("/list", get(list_runnables))
|
||||
Router::new()
|
||||
.route("/list", get(list_runnables))
|
||||
.route("/count_by_owner", get(count_runnables_by_owner))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -161,6 +163,40 @@ fn decode_cursor(raw: &str) -> Result<Cursor, Error> {
|
||||
serde_json::from_slice(&bytes).map_err(|_| Error::BadRequest("invalid cursor".to_string()))
|
||||
}
|
||||
|
||||
/// Fine-grained scoped tokens (e.g. `scripts:read:f/foo/*`) must be confined to
|
||||
/// their granted paths. RLS alone doesn't honor token scopes, so push the
|
||||
/// per-domain path grant into SQL (empty grant -> the branch matches nothing).
|
||||
/// Unscoped sessions -> AllowAll -> no predicate. `fixed_params` is the number
|
||||
/// of placeholders bound before `binds` (binds[0] becomes `$fixed_params+1`).
|
||||
fn scope_where(
|
||||
filter: ScopePathFilter,
|
||||
binds: &mut Vec<String>,
|
||||
fixed_params: usize,
|
||||
) -> Option<String> {
|
||||
match filter {
|
||||
ScopePathFilter::AllowAll => None,
|
||||
ScopePathFilter::Restricted { exact, prefix } => {
|
||||
let mut terms: Vec<String> = vec![];
|
||||
for e in exact {
|
||||
binds.push(e);
|
||||
terms.push(format!("o.path = ${}", fixed_params + binds.len()));
|
||||
}
|
||||
for pre in prefix {
|
||||
binds.push(pre.clone());
|
||||
let pe = format!("${}", fixed_params + binds.len());
|
||||
binds.push(format!("{}/%", escape_like(&pre)));
|
||||
let pl = format!("${}", fixed_params + binds.len());
|
||||
terms.push(format!("(o.path = {} OR o.path LIKE {})", pe, pl));
|
||||
}
|
||||
Some(if terms.is_empty() {
|
||||
"false".to_string()
|
||||
} else {
|
||||
format!("({})", terms.join(" OR "))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Escape LIKE/ILIKE wildcards so a caller value (search term, path/scope
|
||||
/// prefix) matches literally. Relies on the default `\` escape character.
|
||||
fn escape_like(s: &str) -> String {
|
||||
@@ -329,34 +365,6 @@ async fn list_runnables(
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Fine-grained scoped tokens (e.g. `scripts:read:f/foo/*`) must be confined to
|
||||
// their granted paths. RLS alone doesn't honor token scopes, so push the
|
||||
// per-domain path grant into SQL (empty grant -> the branch matches nothing).
|
||||
// Unscoped sessions -> AllowAll -> no predicate.
|
||||
let scope_where = |filter: ScopePathFilter, binds: &mut Vec<String>| -> Option<String> {
|
||||
match filter {
|
||||
ScopePathFilter::AllowAll => None,
|
||||
ScopePathFilter::Restricted { exact, prefix } => {
|
||||
let mut terms: Vec<String> = vec![];
|
||||
for e in exact {
|
||||
binds.push(e);
|
||||
terms.push(format!("o.path = ${}", 3 + binds.len()));
|
||||
}
|
||||
for pre in prefix {
|
||||
binds.push(pre.clone());
|
||||
let pe = format!("${}", 3 + binds.len());
|
||||
binds.push(format!("{}/%", escape_like(&pre)));
|
||||
let pl = format!("${}", 3 + binds.len());
|
||||
terms.push(format!("(o.path = {} OR o.path LIKE {})", pe, pl));
|
||||
}
|
||||
Some(if terms.is_empty() {
|
||||
"false".to_string()
|
||||
} else {
|
||||
format!("({})", terms.join(" OR "))
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
// Only push scope binds for kinds whose branch is actually included: a scoped token
|
||||
// with e.g. `kinds=script` omits the flow/app branches, so binding their scope values
|
||||
// (which no SQL references) would make the parameter count mismatch and 500.
|
||||
@@ -364,6 +372,7 @@ async fn list_runnables(
|
||||
scope_where(
|
||||
build_scope_path_filter(&authed, "scripts", "read"),
|
||||
&mut binds,
|
||||
3,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -372,12 +381,17 @@ async fn list_runnables(
|
||||
scope_where(
|
||||
build_scope_path_filter(&authed, "flows", "read"),
|
||||
&mut binds,
|
||||
3,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let app_scope = if kinds.contains(&"app") {
|
||||
scope_where(build_scope_path_filter(&authed, "apps", "read"), &mut binds)
|
||||
scope_where(
|
||||
build_scope_path_filter(&authed, "apps", "read"),
|
||||
&mut binds,
|
||||
3,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -535,3 +549,124 @@ async fn list_runnables(
|
||||
|
||||
Ok(Json(ListRunnablesResponse { items, next_cursor }))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CountByOwnerQuery {
|
||||
/// Comma-separated subset of `script,flow,app`; omitted means all.
|
||||
kinds: Option<String>,
|
||||
show_archived: Option<bool>,
|
||||
/// Include library scripts (no runnable main). Ignored for flows/apps.
|
||||
include_without_main: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
struct OwnerCount {
|
||||
/// Top-level owner prefix: `f/<folder>` or `u/<user>`.
|
||||
owner: String,
|
||||
count: i64,
|
||||
}
|
||||
|
||||
/// Total visible runnables per top-level owner, under the same visibility
|
||||
/// predicates as `list_runnables` (RLS, token scopes, archived/library/kind
|
||||
/// filters). The homepage tree loads owners lazily, so this is what lets a
|
||||
/// collapsed folder/user show its item count before ever being expanded.
|
||||
async fn count_runnables_by_owner(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(q): Query<CountByOwnerQuery>,
|
||||
) -> JsonResult<Vec<OwnerCount>> {
|
||||
let show_archived = q.show_archived.unwrap_or(false);
|
||||
let mut kinds: Vec<&str> = match q.kinds.as_deref() {
|
||||
None | Some("") => vec!["script", "flow", "app"],
|
||||
Some(csv) => csv
|
||||
.split(',')
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| ["script", "flow", "app"].contains(s))
|
||||
.collect(),
|
||||
};
|
||||
// Apps carry no `archived` column and are never listed as archived.
|
||||
if show_archived {
|
||||
kinds.retain(|k| *k != "app");
|
||||
}
|
||||
let archived_pred = if show_archived {
|
||||
"o.archived = true"
|
||||
} else {
|
||||
"o.archived = false"
|
||||
};
|
||||
|
||||
let mut binds: Vec<String> = vec![];
|
||||
let mut branches: Vec<String> = vec![];
|
||||
for kind in &kinds {
|
||||
let mut w: Vec<String> = vec!["o.workspace_id = $1".to_string()];
|
||||
match *kind {
|
||||
"script" => {
|
||||
if !q.include_without_main.unwrap_or(false) || authed.is_operator {
|
||||
w.push("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')".to_string());
|
||||
}
|
||||
w.push(archived_pred.to_string());
|
||||
if show_archived {
|
||||
// Same as list_runnables: only a path whose LATEST version row is
|
||||
// archived belongs in the archived view (superseded versions of an
|
||||
// active path are archived=true too and must not be counted).
|
||||
w.push(
|
||||
"o.ctid = (SELECT ctid FROM script s2 WHERE s2.path = o.path \
|
||||
AND s2.workspace_id = o.workspace_id ORDER BY s2.created_at DESC LIMIT 1)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if let Some(s) = scope_where(
|
||||
build_scope_path_filter(&authed, "scripts", "read"),
|
||||
&mut binds,
|
||||
1,
|
||||
) {
|
||||
w.push(s);
|
||||
}
|
||||
}
|
||||
"flow" => {
|
||||
w.push(archived_pred.to_string());
|
||||
if let Some(s) = scope_where(
|
||||
build_scope_path_filter(&authed, "flows", "read"),
|
||||
&mut binds,
|
||||
1,
|
||||
) {
|
||||
w.push(s);
|
||||
}
|
||||
}
|
||||
"app" => {
|
||||
if let Some(s) = scope_where(
|
||||
build_scope_path_filter(&authed, "apps", "read"),
|
||||
&mut binds,
|
||||
1,
|
||||
) {
|
||||
w.push(s);
|
||||
}
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
branches.push(format!(
|
||||
"SELECT o.path FROM {kind} o WHERE {}",
|
||||
w.join(" AND ")
|
||||
));
|
||||
}
|
||||
if branches.is_empty() {
|
||||
return Ok(Json(vec![]));
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"SELECT split_part(path, '/', 1) || '/' || split_part(path, '/', 2) AS owner, \
|
||||
COUNT(*) AS count \
|
||||
FROM ({}) q GROUP BY 1",
|
||||
branches.join(" UNION ALL ")
|
||||
);
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let mut query = sqlx::query_as::<_, OwnerCount>(&sql).bind(&w_id);
|
||||
for b in &binds {
|
||||
query = query.bind(b);
|
||||
}
|
||||
let rows = query.fetch_all(&mut *tx).await?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(rows))
|
||||
}
|
||||
|
||||
@@ -477,6 +477,8 @@
|
||||
// swap it in place (loadOwnerItems replaces each owner's rows atomically — the
|
||||
// old rows stay visible until the new ones arrive, so nothing blanks mid-reorder).
|
||||
for (const o of toReload) loadOwnerItems(o, false, true)
|
||||
// Row mutations (create/archive/delete/move) change per-owner totals too.
|
||||
if (treeLazyMode) ownerCountsRes.refetch()
|
||||
}
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
@@ -716,6 +718,35 @@
|
||||
// and each folder paginates within itself; users past the window are reached via
|
||||
// their owner chip.
|
||||
let treeGlobalHasMore = $derived(ownerFilter != undefined && !searching ? hasMoreServer : false)
|
||||
// Per-owner totals for the lazy tree, fetched up front (one grouped COUNT, same
|
||||
// visibility filters as the stream) so a collapsed owner's header can show its
|
||||
// item count before it's ever expanded — its rows only load on expand.
|
||||
// `undefined` (pre-load or on failure) keeps headers blank rather than showing
|
||||
// a misleading 0.
|
||||
let ownerCountsRes = resource(
|
||||
() => ({
|
||||
ws: $workspaceStore,
|
||||
lazy: treeLazyMode,
|
||||
archived,
|
||||
includeWithoutMain,
|
||||
itemKind
|
||||
}),
|
||||
async ({ ws, lazy, archived, includeWithoutMain, itemKind }) => {
|
||||
if (!ws || !lazy) return undefined
|
||||
try {
|
||||
const counts = await ScriptService.countRunnablesByOwner({
|
||||
workspace: ws,
|
||||
showArchived: archived ? true : undefined,
|
||||
includeWithoutMain: includeWithoutMain ? true : undefined,
|
||||
kinds: itemKind !== 'all' ? itemKind : undefined
|
||||
})
|
||||
return Object.fromEntries(counts.map((c) => [c.owner, c.count]))
|
||||
} catch {
|
||||
return undefined // best-effort: headers fall back to blank until expanded
|
||||
}
|
||||
}
|
||||
)
|
||||
let ownerCounts = $derived(ownerCountsRes.current)
|
||||
$effect(() => {
|
||||
if ($userStore && $workspaceStore) {
|
||||
;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind]
|
||||
@@ -1466,6 +1497,7 @@
|
||||
allFolders={treeInjectFolders}
|
||||
allUsers={treeInjectUsers}
|
||||
ownerLoad={treeLazyMode ? ownerLoad : undefined}
|
||||
ownerCounts={treeLazyMode ? ownerCounts : undefined}
|
||||
onExpandOwner={treeLazyMode ? loadOwnerItems : undefined}
|
||||
onCollapseOwner={treeLazyMode ? collapseOwner : undefined}
|
||||
isSearching={filter !== ''}
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
string,
|
||||
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean; count: number }
|
||||
>
|
||||
// Server-side total of visible items per owner prefix; lets a collapsed,
|
||||
// not-yet-loaded owner show its item count.
|
||||
ownerCounts?: Record<string, number>
|
||||
onExpandOwner?: (owner: string, more?: boolean) => void
|
||||
onCollapseOwner?: (owner: string) => void
|
||||
// Position of this node among the rendered root nodes; "expand all" only
|
||||
@@ -38,6 +41,7 @@
|
||||
isSearching = false,
|
||||
pipelineFolders,
|
||||
ownerLoad,
|
||||
ownerCounts,
|
||||
onExpandOwner,
|
||||
onCollapseOwner,
|
||||
rootIndex = 0
|
||||
@@ -79,6 +83,21 @@
|
||||
// whose items are already grouped from the loaded window.
|
||||
let isLazyOwner = $derived(ownerKey != undefined && ownerLoad != undefined)
|
||||
let ownerState = $derived(ownerKey != undefined ? ownerLoad?.[ownerKey] : undefined)
|
||||
// Server total for this owner (all descendants). A loaded counts map with no
|
||||
// entry for this owner means it has no visible items — a real 0, not unknown.
|
||||
let totalCount = $derived(
|
||||
ownerKey != undefined && ownerCounts != undefined ? (ownerCounts[ownerKey] ?? 0) : undefined
|
||||
)
|
||||
// Known to have nothing to reveal: zero server-counted items, none loaded, and
|
||||
// no Pipeline entry (a pipeline folder shows a Pipeline row despite 0 counted
|
||||
// items). Such a node is inert — no toggle, no chevron.
|
||||
let isEmptyOwner = $derived(
|
||||
isLazyOwner &&
|
||||
totalCount === 0 &&
|
||||
(isFolder(item) || isUser(item)) &&
|
||||
item.items.length === 0 &&
|
||||
!hasPipeline
|
||||
)
|
||||
|
||||
let showMax = $state(15)
|
||||
// A lazy owner paginates server-side ("Load more"), so when opened on its own it
|
||||
@@ -144,8 +163,11 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
onclick={toggleOwner}
|
||||
class="px-4 py-2 border-b w-full flex flex-row items-center justify-between cursor-pointer"
|
||||
onclick={isEmptyOwner ? undefined : toggleOwner}
|
||||
class={twMerge(
|
||||
'px-4 py-2 border-b w-full flex flex-row items-center justify-between',
|
||||
isEmptyOwner ? '' : 'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class={twMerge('flex flex-row items-center gap-4 text-sm font-semibold')}
|
||||
@@ -166,8 +188,13 @@
|
||||
{#if isUser(item)}u/{item.username}{:else}{#if depth === 0}f/{/if}{item.folderName}{/if}
|
||||
</span>
|
||||
<div class="text-2xs font-normal text-secondary whitespace-nowrap">
|
||||
{#if isLazyOwner && !ownerState?.loaded}
|
||||
<!-- Lazy owner not expanded yet: its true item count is unknown until
|
||||
{#if isLazyOwner && totalCount != undefined}
|
||||
<!-- Lazy owner with a known server total: show it in every state (collapsed
|
||||
or expanded) so the number doesn't jump on expand — item.items.length
|
||||
counts only direct children (a subfolder collapses to one entry). -->
|
||||
({pluralize(totalCount, ' item')})
|
||||
{:else if isLazyOwner && !ownerState?.loaded}
|
||||
<!-- Lazy owner, counts not available: its item count is unknown until
|
||||
loaded, so showing "(0 items)" would be misleading. -->
|
||||
|
||||
{:else if isLazyOwner && ownerState?.hasMore}
|
||||
@@ -178,13 +205,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="w-full flex flex-row-reverse">
|
||||
{#if opened}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{#if !isEmptyOwner}
|
||||
<button class="w-full flex flex-row-reverse">
|
||||
{#if opened}
|
||||
<ChevronUp size={16} />
|
||||
{:else}
|
||||
<ChevronDown size={16} />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if opened || isSearching}
|
||||
<div>
|
||||
@@ -229,10 +258,12 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if ownerKey != undefined}
|
||||
{#if ownerState?.loading && item.items.length === 0}
|
||||
{#if ownerState?.loading && item.items.length === 0 && totalCount !== 0}
|
||||
<!-- Show the spinner only on the first load, when there's nothing yet. A
|
||||
re-sort/re-filter re-fetch keeps the old rows visible and swaps them
|
||||
in place, so flashing "Loading…" under them would just be noise. -->
|
||||
in place, so flashing "Loading…" under them would just be noise.
|
||||
Same for an owner the server counted as empty: its fetch can only
|
||||
come back empty, so "Loading…" then nothing reads as a glitch. -->
|
||||
<div class="text-center text-xs py-2 text-secondary">Loading…</div>
|
||||
{:else if !ownerState?.loading && ownerState?.hasMore && effectiveMax >= item.items.length}
|
||||
<!-- Fetch the next server page only once every already-loaded row is shown
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
string,
|
||||
{ cursor?: string; hasMore: boolean; loading: boolean; loaded: boolean; count: number }
|
||||
>
|
||||
// Total visible items per owner prefix (server-side count), so a not-yet-loaded
|
||||
// owner's header can still show its item count.
|
||||
ownerCounts?: Record<string, number>
|
||||
onExpandOwner?: (owner: string, more?: boolean) => void
|
||||
onCollapseOwner?: (owner: string) => void
|
||||
}
|
||||
@@ -46,6 +49,7 @@
|
||||
allFolders = [],
|
||||
allUsers = [],
|
||||
ownerLoad,
|
||||
ownerCounts,
|
||||
onExpandOwner,
|
||||
onCollapseOwner
|
||||
}: Props = $props()
|
||||
@@ -142,6 +146,7 @@
|
||||
{item}
|
||||
{pipelineFolders}
|
||||
{ownerLoad}
|
||||
{ownerCounts}
|
||||
{onExpandOwner}
|
||||
{onCollapseOwner}
|
||||
on:scriptChanged
|
||||
|
||||
Reference in New Issue
Block a user