From a3d75ba10ae85e5ecb55351555879be7fe0bfcca Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 4 Feb 2026 19:48:51 +0100 Subject: [PATCH] feat: favorite datatable and ducklake tables + asset page nits (#7795) * update cf worker hostname * set remote_url cookie from param * ephemeral backends v1 * nit * Run queue server * ntis * timeout * better db process management * commit hash and worktree * nit use map * nit * err handling * Revert "err handling" This reverts commit 19de00c0c0b1904f87cc41424db36ae2ab866277. * nits * auto cleanup * Ephemeral backend command action * remove checkout * checkout ee repo * nits * process.env.GIT_EE_DEPLOY_KEY_FILE * resumeURLs logic * nit * use windmill flow for ephemeral backend action * fixes * new token * worktree pools * Delete GH secret on cleanup * linux deploy * nit * nit * unhandled promises * nit * fix docker bridge IP on linux * pass cf_frontend_url to wmill flow * git fetch * release worktree when binary started * send error * logger * logging * logging 2 * delete log files periodically * redirect to raw app with logs * CORS * MANAGER_AUTH_TOKEN * Check organization membership * nit * bwrap * nit * return timeoutAt in resumeUrl * nit * Change password * nit remove https * Settings icon instead of plain text * doc link * favorite icon * Better favorites state logic * Asset favorites * nit fixes and better label display * global db manager drawer * Favorites menu open db manager * favorites in assets page + backend refactor * Delete favorite dropdown * parseFavoriteAsset * git fail * nit * git fail * nit * nits * git fail * Table2 instead of pyramid --- ...204152754_add_asset_favorite_kind.down.sql | 1 + ...60204152754_add_asset_favorite_kind.up.sql | 10 ++ backend/windmill-api/openapi.yaml | 28 ++- backend/windmill-api/src/assets.rs | 27 +++ backend/windmill-api/src/favorite.rs | 1 + .../lib/components/CompareWorkspaces.svelte | 6 +- frontend/src/lib/components/DBManager.svelte | 22 ++- .../lib/components/DBManagerContent.svelte | 5 + .../src/lib/components/DBManagerDrawer.svelte | 3 + .../src/lib/components/DucklakePicker.svelte | 7 +- .../src/lib/components/ResourcePicker.svelte | 7 +- frontend/src/lib/components/Star.svelte | 34 +--- .../display/table/theme/windmill-theme.css | 15 +- .../assets/AssetsDropdownButton.svelte | 6 +- .../components/assets/JobAssetsViewer.svelte | 6 +- .../lib/components/common/table/AppRow.svelte | 7 - .../components/common/table/FlowRow.svelte | 7 - .../components/common/table/RawAppRow.svelte | 7 - .../lib/components/common/table/Row.svelte | 8 +- .../components/common/table/ScriptRow.svelte | 7 - .../components/flows/FlowAssetsHandler.svelte | 3 - frontend/src/lib/components/flows/types.ts | 2 - .../graph/renderers/nodes/AssetNode.svelte | 4 +- frontend/src/lib/components/home/Item.svelte | 4 - .../components/meltComponents/MenuItem.svelte | 1 + .../components/sidebar/FavoriteMenu.svelte | 160 ++++++++++++++---- .../components/sidebar/OperatorMenu.svelte | 19 ++- .../DataTableSettings.svelte | 6 +- .../workspaceSettings/DucklakeSettings.svelte | 6 +- frontend/src/lib/stores.ts | 6 +- frontend/src/lib/utils.ts | 32 +++- .../src/routes/(root)/(logged)/+layout.svelte | 97 +++++++---- .../(root)/(logged)/assets/+page.svelte | 107 +++++++++--- .../(logged)/flows/get/[...path]/+page.svelte | 13 +- .../(root)/(logged)/resources/+page.svelte | 12 +- .../scripts/get/[...hash]/+page.svelte | 14 +- 36 files changed, 457 insertions(+), 243 deletions(-) create mode 100644 backend/migrations/20260204152754_add_asset_favorite_kind.down.sql create mode 100644 backend/migrations/20260204152754_add_asset_favorite_kind.up.sql diff --git a/backend/migrations/20260204152754_add_asset_favorite_kind.down.sql b/backend/migrations/20260204152754_add_asset_favorite_kind.down.sql new file mode 100644 index 0000000000..d2f607c5b8 --- /dev/null +++ b/backend/migrations/20260204152754_add_asset_favorite_kind.down.sql @@ -0,0 +1 @@ +-- Add down migration script here diff --git a/backend/migrations/20260204152754_add_asset_favorite_kind.up.sql b/backend/migrations/20260204152754_add_asset_favorite_kind.up.sql new file mode 100644 index 0000000000..fb19331c49 --- /dev/null +++ b/backend/migrations/20260204152754_add_asset_favorite_kind.up.sql @@ -0,0 +1,10 @@ +-- Add up migration script here + +DO +$do$ +BEGIN + ALTER TYPE FAVORITE_KIND ADD VALUE 'asset'; +EXCEPTION WHEN OTHERS THEN + RAISE NOTICE 'Couldn''t create FAVORITE_KIND::asset: %', SQLERRM; +END +$do$; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4ca0fc21f4..b951c4aff7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -15018,7 +15018,7 @@ paths: type: string favorite_kind: type: string - enum: [flow, app, script, raw_app] + enum: [flow, app, script, raw_app, asset] responses: "200": description: star item @@ -15041,7 +15041,7 @@ paths: type: string favorite_kind: type: string - enum: [flow, app, script, raw_app] + enum: [flow, app, script, raw_app, asset] responses: "200": description: unstar item @@ -16671,6 +16671,30 @@ paths: access_type: $ref: "#/components/schemas/AssetUsageAccessType" + /w/{workspace}/assets/list_favorites: + get: + summary: List all favorite assets for the authenticated user + operationId: listFavoriteAssets + tags: + - asset + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: list of favorite assets + content: + application/json: + schema: + type: array + items: + type: object + required: [path] + properties: + path: + type: string + description: The asset path + + /mcp/w/{workspace}/list_tools: get: summary: list available MCP tools diff --git a/backend/windmill-api/src/assets.rs b/backend/windmill-api/src/assets.rs index 7afea4038f..6a53c90ebd 100644 --- a/backend/windmill-api/src/assets.rs +++ b/backend/windmill-api/src/assets.rs @@ -18,6 +18,7 @@ pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_assets)) .route("/list_by_usages", post(list_assets_by_usages)) + .route("/list_favorites", get(list_favorites)) } #[derive(Deserialize)] @@ -286,3 +287,29 @@ async fn list_assets_by_usages( } Ok(Json(assets_vec)) } + +async fn list_favorites( + authed: ApiAuthed, + Path(w_id): Path, + Extension(user_db): Extension, +) -> JsonResult> { + let mut tx = user_db.begin(&authed).await?; + + let favorites = sqlx::query_scalar!( + r#"SELECT + jsonb_strip_nulls(jsonb_build_object( + 'path', favorite.path + )) as "favorite_asset!: _" + FROM favorite + WHERE favorite.workspace_id = $1 + AND favorite.usr = $2 + AND favorite_kind = 'asset' + "#, + &w_id, + &authed.username + ) + .fetch_all(&mut *tx) + .await?; + + Ok(Json(favorites)) +} diff --git a/backend/windmill-api/src/favorite.rs b/backend/windmill-api/src/favorite.rs index 75c7b19bb2..d3e3456e0f 100644 --- a/backend/windmill-api/src/favorite.rs +++ b/backend/windmill-api/src/favorite.rs @@ -31,6 +31,7 @@ pub enum FavoriteKind { App, #[allow(non_camel_case_types)] Raw_App, + Asset, } #[derive(Deserialize)] pub struct Favorite { diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 9e19720a1b..2546868041 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -112,7 +112,6 @@ } else if (kind === 'folder') { const folder = await FolderService.getFolder({ workspace, name: path.slice(2) }) return folder.summary - } } catch (error) { console.error(`Failed to fetch summary for ${kind}:${path}`, error) @@ -122,7 +121,9 @@ async function fetchSummaries(diffs: WorkspaceItemDiff[]) { // Only fetch summaries for scripts, flows, and apps - const itemsToFetch = diffs.filter((diff) => ['script', 'flow', 'app', 'folder'].includes(diff.kind)) + const itemsToFetch = diffs.filter((diff) => + ['script', 'flow', 'app', 'folder'].includes(diff.kind) + ) for (const diff of itemsToFetch) { const key = getItemKey(diff) @@ -915,7 +916,6 @@ kind={diff.kind} canFavorite={false} workspaceId="" - starred={false} > {#snippet customSummary()} {#if oldSummary != newSummary && isSelectable && existsInBothWorkspaces} diff --git a/frontend/src/lib/components/DBManager.svelte b/frontend/src/lib/components/DBManager.svelte index 482288a62f..3000eb2d5f 100644 --- a/frontend/src/lib/components/DBManager.svelte +++ b/frontend/src/lib/components/DBManager.svelte @@ -23,6 +23,8 @@ import { resource } from 'runed' import { capitalize, onlyAlphaNumAndUnderscore, pluralize } from '$lib/utils' import type { DbFeatures } from './apps/components/display/dbtable/dbFeatures' + import Star from './Star.svelte' + import type { Asset } from '$lib/gen' /** Represents a selected table with its schema */ export interface SelectedTable { @@ -50,6 +52,7 @@ /** Tables that are already added and should show as disabled */ disabledTables?: SelectedTable[] features?: DbFeatures + asset?: Asset } let { dbType, @@ -67,7 +70,8 @@ multiSelectMode = false, selectedTables = $bindable([]), disabledTables = [], - features + features, + asset }: Props = $props() // Helper to check if a table is selected in multi-select mode @@ -442,16 +446,26 @@ {:else} {#each filteredTableKeys as tableKey} + - diff --git a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte index 607c9c655f..49dc1066ba 100644 --- a/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DucklakeSettings.svelte @@ -67,10 +67,9 @@ import { SettingService, WorkspaceService } from '$lib/gen' import type { GetSettingsResponse } from '$lib/gen' - import { workspaceStore } from '$lib/stores' + import { globalDbManagerDrawer, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import ExploreAssetButton from '../ExploreAssetButton.svelte' - import DbManagerDrawer from '../DBManagerDrawer.svelte' import Tooltip from '../Tooltip.svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' @@ -179,7 +178,7 @@ 'Where the data is actually stored, in parquet format. You need to configure a workspace storage first' } - let dbManagerDrawer: DbManagerDrawer | undefined = $state() + let dbManagerDrawer = $derived(globalDbManagerDrawer.val) let confirmationModal = createAsyncConfirmationModal() @@ -394,6 +393,5 @@ > Save ducklake settings - diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 4d49133895..534241a21b 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -14,6 +14,7 @@ import { import { getLocalSetting, type StateStore } from './utils' import { createState } from './svelte5Utils.svelte' import { DEFAULT_HUB_BASE_URL } from './hub' +import type DBManagerDrawer from './components/DBManagerDrawer.svelte' export interface UserExt { email: string @@ -77,7 +78,6 @@ export const workspaceStore = writable( export const defaultScripts = writable(undefined) export const dbClockDrift = writable(undefined) export const isPremiumStore = writable(false) -export const starStore = writable(1) export const usersWorkspaceStore = writable(undefined) export const superadmin = writable(undefined) export const devopsRole = writable(undefined) @@ -127,6 +127,10 @@ export const codeCompletionSessionEnabled = writable( export const usedTriggerKinds = writable([]) +export let globalDbManagerDrawer: StateStore = createState({ + val: undefined +}) + type SQLBaseSchema = { [schemaKey: string]: { [tableKey: string]: { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 88befd6733..9f585e41c2 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1783,6 +1783,7 @@ import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' import BarsStaggered from './components/icons/BarsStaggered.svelte' import { GitIcon } from './components/icons' import { Bot, Code, Package } from 'lucide-svelte' +import type { DbInput } from './components/dbTypes' export function getCssColor( color: CssColor, { @@ -1986,6 +1987,23 @@ export function pick(obj: T, keys: readonly return result } +export function parseDbInputFromAssetSyntax(path: string): DbInput | null { + const [p1, _p2] = path.split('://') + const [p2, _p3] = _p2.split('/') + const [p3, p4] = _p3.split('.') + return p1 === 'ducklake' + ? { type: 'ducklake', ducklake: p2 || 'main', specificTable: p4 ?? p3 } + : p1 === 'datatable' + ? { + type: 'database', + resourcePath: `datatable://${p2 || 'main'}`, + resourceType: 'postgresql', + specificTable: p4 ?? p3, + specificSchema: p4 ? p3 : undefined + } + : null +} + /** * Formats memory size in KB to human-readable format with appropriate units * @param sizeInKb - Memory size in kilobytes @@ -1993,11 +2011,17 @@ export function pick(obj: T, keys: readonly * @returns Formatted string with appropriate unit (KB, MB, GB) or object with display and tooltip */ export function formatMemory(sizeInKb: number): string -export function formatMemory(sizeInKb: number, includeTooltip: true): { display: string; tooltip: string } -export function formatMemory(sizeInKb: number, includeTooltip = false): string | { display: string; tooltip: string } { - const precise = `${sizeInKb.toLocaleString()}KB`; +export function formatMemory( + sizeInKb: number, + includeTooltip: true +): { display: string; tooltip: string } +export function formatMemory( + sizeInKb: number, + includeTooltip = false +): string | { display: string; tooltip: string } { + const precise = `${sizeInKb.toLocaleString()}KB` - let display: string; + let display: string if (sizeInKb >= 1024 * 1024) { // Convert to GB for values >= 1GB display = `${(sizeInKb / (1024 * 1024)).toFixed(0)}GB` diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index c6bd465aca..c5bb436064 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -3,6 +3,7 @@ import { AppService, + AssetService, FlowService, OpenAPI, RawAppService, @@ -11,14 +12,19 @@ UserService, WorkspaceService } from '$lib/gen' - import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils' + import { + capitalize, + classNames, + getModifierKey, + parseDbInputFromAssetSyntax, + sendUserToast + } from '$lib/utils' import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte' import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte' import CriticalAlertModal from '$lib/components/sidebar/CriticalAlertModal.svelte' import { enterpriseLicense, isPremiumStore, - starStore, superadmin, usageStore, workspaceUsageStore, @@ -29,7 +35,8 @@ hubBaseUrlStore, usedTriggerKinds, devopsRole, - whitelabelNameStore + whitelabelNameStore, + globalDbManagerDrawer } from '$lib/stores' import CenteredModal from '$lib/components/CenteredModal.svelte' import { afterNavigate, beforeNavigate } from '$app/navigation' @@ -38,7 +45,11 @@ import SuperadminSettings from '$lib/components/SuperadminSettings.svelte' import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte' import { page } from '$app/stores' - import FavoriteMenu from '$lib/components/sidebar/FavoriteMenu.svelte' + import FavoriteMenu, { + favoriteManager, + getFavoriteHref, + getFavoriteLabel + } from '$lib/components/sidebar/FavoriteMenu.svelte' import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings' import { isCloudHosted } from '$lib/cloud' import { syncTutorialsTodos } from '$lib/tutorialUtils' @@ -54,6 +65,8 @@ import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte' import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte' import { DEFAULT_HUB_BASE_URL } from '$lib/hub' + import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte' + import { watchOnce } from 'runed' interface Props { children?: import('svelte').Snippet } @@ -133,14 +146,6 @@ let innerWidth = $state(BROWSER ? window.innerWidth : 2000) - let favoriteLinks = $state( - [] as { - label: string - href: string - kind: 'app' | 'script' | 'flow' | 'raw_app' - }[] - ) - function onLoad() { loadFavorites() loadUsage() @@ -185,26 +190,37 @@ workspace: $workspaceStore ?? '', starredOnly: true }) - favoriteLinks = [ + const assets = await AssetService.listFavoriteAssets({ workspace: $workspaceStore ?? '' }) + favoriteManager.current = [ ...scripts.map((s) => ({ - label: s.summary || s.path, - href: `${base}/scripts/get/${s.hash}`, - kind: 'script' as 'script' + label: s.summary || getFavoriteLabel(s.path, 'script'), + path: s.path, + href: getFavoriteHref(s.path, 'script'), + kind: 'script' as const })), ...flows.map((f) => ({ - label: f.summary || f.path, - href: `${base}/flows/get/${f.path}`, - kind: 'flow' as 'flow' + label: f.summary || getFavoriteLabel(f.path, 'flow'), + path: f.path, + href: getFavoriteHref(f.path, 'flow'), + kind: 'flow' as const })), ...apps.map((f) => ({ - label: f.summary || f.path, - href: `${base}/apps/get/${f.path}`, - kind: 'app' as 'app' + label: f.summary || getFavoriteLabel(f.path, 'app'), + path: f.path, + href: getFavoriteHref(f.path, 'app'), + kind: 'app' as const })), ...raw_apps.map((f) => ({ - label: f.summary || f.path, - href: `${base}/apps/get_raw/${f.version}/${f.path}`, - kind: 'raw_app' as 'raw_app' + label: f.summary || getFavoriteLabel(f.path, 'raw_app'), + path: f.path, + href: getFavoriteHref(f.path, 'raw_app'), + kind: 'raw_app' as const + })), + ...assets.map((a) => ({ + label: getFavoriteLabel(a.path, 'asset'), + path: a.path, + href: getFavoriteHref(a.path, 'asset'), + kind: 'asset' as const })) ] } @@ -369,7 +385,7 @@ untrack(() => updateUserStore($workspaceStore)) }) $effect(() => { - $workspaceStore && $starStore && untrack(() => onLoad()) + $workspaceStore && untrack(() => onLoad()) }) $effect(() => { innerWidth && untrack(() => changeCollapsed()) @@ -409,6 +425,19 @@ } } }) + + watchOnce( + () => globalDbManagerDrawer.val, + () => { + if (!globalDbManagerDrawer.val) return + const hash = window.location.hash + if (hash.startsWith('#dbmanager:')) { + const [_, path] = hash.split('#dbmanager:') + const dbInput = parseDbInputFromAssetSyntax(path) + if (dbInput) globalDbManagerDrawer.val?.openDrawer(dbInput) + } + } + ) @@ -496,7 +525,7 @@ {#snippet children({ createMenu })} - + {/snippet} {#snippet children({ createMenu })} - + {/snippet} - + {/if} @@ -683,7 +716,7 @@ {#snippet children({ createMenu })} - + {/snippet} {/if} + +{#if $workspaceStore} + +{/if} diff --git a/frontend/src/routes/(root)/(logged)/assets/+page.svelte b/frontend/src/routes/(root)/(logged)/assets/+page.svelte index 252507d2f9..380d6dc29a 100644 --- a/frontend/src/routes/(root)/(logged)/assets/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/assets/+page.svelte @@ -1,7 +1,6 @@ {#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.assets} @@ -133,14 +139,36 @@ assetKind: AssetKind data: ResourceReturn<{ label: string; value: string }[]> settingsHref: string + docsHref: string + favorites?: { table: string; schema?: string; assetName: string; path: string }[] })}
-
-

{props.title}

- Settings +
+

{props.title}

+
+ +
{#if props.data.current?.length} -
+
{#each props.data.current ?? [] as item}
{item.label} @@ -153,6 +181,34 @@
{/each}
+ + {#if !props.data.loading && !props.data.error && props.favorites != undefined} +
+

Favorite tables

+
+ {#each props.favorites as fav} + + {/each} +
+ {#if props.favorites.length === 0} +
No favorite table yet
+ {/if} +
+ {/if} {/if} {#if props.data.loading}
@@ -163,37 +219,35 @@ Error loading {props.title.toLowerCase()}
{:else if props.data.current?.length === 0} -
+
No {props.title.toLowerCase()} yet
- {/if}
{/snippet} {@render card({ - title: 'Data tables', + title: 'Data table', data: allDataTables, assetKind: 'datatable', - settingsHref: '/workspace_settings?tab=windmill_data_tables' + settingsHref: '/workspace_settings?tab=windmill_data_tables', + docsHref: 'https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables', + favorites: extractFavorites('datatable') })} {@render card({ - title: 'Ducklakes', + title: 'Ducklake', data: allDucklakes, assetKind: 'ducklake', - settingsHref: '/workspace_settings?tab=windmill_lfs' + settingsHref: '/workspace_settings?tab=windmill_lfs', + docsHref: 'https://www.windmill.dev/docs/core_concepts/persistent_storage/ducklake', + favorites: extractFavorites('ducklake') })} {@render card({ - title: 'Workspace object storages', + title: 'Object storage', data: allS3Storages, assetKind: 's3object', - settingsHref: '/workspace_settings?tab=windmill_lfs' + settingsHref: '/workspace_settings?tab=windmill_lfs', + docsHref: + 'https://www.windmill.dev/docs/core_concepts/persistent_storage/large_data_files' })}
@@ -233,7 +287,6 @@ - {#snippet table()} diff --git a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte index bb9ed9e954..23acc62dd3 100644 --- a/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/get/[...path]/+page.svelte @@ -120,8 +120,6 @@ goto('/') } - let starred: boolean | undefined = $state(undefined) - async function loadTriggersCount() { $triggersCount = await FlowService.getTriggersCountOfFlow({ workspace: $workspaceStore!, @@ -146,7 +144,6 @@ path, withStarredInfo: true }) - starred = flow.starred if (!flow.path.startsWith(`u/${$userStore?.username}`) && flow.path.split('/').length > 2) { invisible_to_owner = flow.visible_to_runner_only } @@ -512,15 +509,7 @@ /> {/snippet} {#if $workspaceStore && flow} - { - starred = newStarred - }} - /> + {/if} {#if flow?.value?.priority != undefined}