feat: add explore button for object storage resources (#10306)

* feat: add explore button for object storage resources in resource list

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FhmfSxPuTck3yAhDpkfcA

* fix: make s3 drawer tooltip reflect explored resource

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FhmfSxPuTck3yAhDpkfcA

* fix: honor workspace prop in global s3 explorer and add resource connection error state

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FhmfSxPuTck3yAhDpkfcA

* fix: use picker's effective workspace in S3FilePreview requests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FhmfSxPuTck3yAhDpkfcA

* fix: pass acting workspace to explore button in ResourcePicker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FhmfSxPuTck3yAhDpkfcA

* chore: update ee-repo-ref to f78df23339e3136e8b6e9148a509508633448dd2

This commit updates the EE repository reference after PR #686 was merged in windmill-ee-private.

Previous ee-repo-ref: efb5e014fec34fc580b9dbb1b260494dd76c5462

New ee-repo-ref: f78df23339e3136e8b6e9148a509508633448dd2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Diego Imbert
2026-07-24 18:41:09 +02:00
committed by GitHub
parent 85008e47b4
commit 28a79ced15
11 changed files with 184 additions and 38 deletions
+1 -1
View File
@@ -1 +1 @@
22abd6d4e229f1206a13ebee8a6a9b808cd82a0d
f78df23339e3136e8b6e9148a509508633448dd2
+30
View File
@@ -20889,6 +20889,11 @@ paths:
in: query
schema:
type: string
- name: s3_resource_path
in: query
description: When set, test the connection of this object storage resource instead of the workspace storage
schema:
type: string
responses:
"200":
description: Connection settings
@@ -20970,6 +20975,11 @@ paths:
in: query
schema:
type: string
- name: s3_resource_path
in: query
description: When set, list the files of this object storage resource instead of the workspace storage
schema:
type: string
responses:
"200":
description: List of file keys
@@ -21006,6 +21016,11 @@ paths:
in: query
schema:
type: string
- name: s3_resource_path
in: query
description: When set, load the file metadata from this object storage resource instead of the workspace storage
schema:
type: string
responses:
"200":
description: FileMetadata
@@ -21055,6 +21070,11 @@ paths:
in: query
schema:
type: string
- name: s3_resource_path
in: query
description: When set, load the file preview from this object storage resource instead of the workspace storage
schema:
type: string
responses:
"200":
description: FilePreview
@@ -21365,6 +21385,11 @@ paths:
in: query
schema:
type: string
- name: s3_resource_path
in: query
description: When set, delete the file from this object storage resource instead of the workspace storage
schema:
type: string
responses:
"200":
description: Confirmation
@@ -21394,6 +21419,11 @@ paths:
in: query
schema:
type: string
- name: s3_resource_path
in: query
description: When set, move the file within this object storage resource instead of the workspace storage
schema:
type: string
responses:
"200":
description: Confirmation
+12 -4
View File
@@ -4191,15 +4191,19 @@ async fn app_load_file_metadata(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<LoadFileMetadataQuery>,
Query(mut query): Query<LoadFileMetadataQuery>,
Query(sig): Query<AppS3Sig>,
) -> Result<Response> {
let path = path.to_path();
let file_query = app_s3_file_query(query.file_key.clone(), query.storage.clone(), sig);
let job_authed =
app_s3_on_behalf_and_provenance(&db, &path, &w_id, &opt_authed, &file_query).await?;
// On-behalf app reads are confined to the workspace storage; a
// viewer-supplied custom resource must not be honored.
query.s3_resource_path = None;
let resp =
crate::job_helpers_oss::load_file_metadata_internal(job_authed, &db, &w_id, query).await?;
crate::job_helpers_oss::load_file_metadata_internal(job_authed, &db, None, &w_id, query)
.await?;
Ok(Json(resp).into_response())
}
@@ -4208,15 +4212,19 @@ async fn app_load_file_preview(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, path)): Path<(String, StripPath)>,
Query(query): Query<LoadFilePreviewQuery>,
Query(mut query): Query<LoadFilePreviewQuery>,
Query(sig): Query<AppS3Sig>,
) -> Result<Response> {
let path = path.to_path();
let file_query = app_s3_file_query(query.file_key.clone(), query.storage.clone(), sig);
let job_authed =
app_s3_on_behalf_and_provenance(&db, &path, &w_id, &opt_authed, &file_query).await?;
// On-behalf app reads are confined to the workspace storage; a
// viewer-supplied custom resource must not be honored.
query.s3_resource_path = None;
let resp =
crate::job_helpers_oss::load_file_preview_internal(job_authed, &db, &w_id, query).await?;
crate::job_helpers_oss::load_file_preview_internal(job_authed, &db, None, &w_id, query)
.await?;
Ok(Json(resp).into_response())
}
+6 -1
View File
@@ -200,7 +200,7 @@ pub async fn read_object_streamable(
pub async fn delete_s3_file_internal(
_authed: OptJobAuthed,
_db: &DB,
_token: &str,
_user_db: Option<windmill_common::db::UserDB>,
_w_id: &str,
_query: DeleteS3FileQuery,
) -> error::Result<()> {
@@ -215,6 +215,7 @@ pub async fn delete_s3_file_internal(
pub struct DeleteS3FileQuery {
pub file_key: String,
pub storage: Option<String>,
pub s3_resource_path: Option<String>,
}
// Stubs for the app-scoped S3 display ops (mirrors the EE `*_internal` helpers +
@@ -231,6 +232,7 @@ mod app_s3_display_stubs {
pub struct LoadFileMetadataQuery {
pub file_key: String,
pub storage: Option<String>,
pub s3_resource_path: Option<String>,
}
#[derive(Serialize)]
@@ -242,6 +244,7 @@ mod app_s3_display_stubs {
#[allow(dead_code)]
pub struct LoadFilePreviewQuery {
pub storage: Option<String>,
pub s3_resource_path: Option<String>,
pub file_key: String,
pub file_size_in_bytes: Option<u64>,
pub file_mime_type: Option<String>,
@@ -281,6 +284,7 @@ mod app_s3_display_stubs {
pub async fn load_file_metadata_internal(
_authed: OptJobAuthed,
_db: &DB,
_user_db: Option<UserDB>,
_w_id: &str,
_query: LoadFileMetadataQuery,
) -> error::Result<LoadFileMetadataResponse> {
@@ -292,6 +296,7 @@ mod app_s3_display_stubs {
pub async fn load_file_preview_internal(
_authed: OptJobAuthed,
_db: &DB,
_user_db: Option<UserDB>,
_w_id: &str,
_query: LoadFilePreviewQuery,
) -> error::Result<LoadFilePreviewResponse> {
@@ -1,4 +1,16 @@
<script module lang="ts">
const OBJECT_STORAGE_RESOURCE_TYPES = [
's3',
'azure_blob',
's3_aws_oidc',
'azure_workload_identity',
'gcloud_storage'
]
export function isObjectStorageResourceType(resourceType: string | undefined): boolean {
return resourceType !== undefined && OBJECT_STORAGE_RESOURCE_TYPES.includes(resourceType)
}
export function assetCanBeExplored(
asset: Asset,
_resourceMetadata?: { resource_type?: string }
@@ -8,7 +20,9 @@
asset.kind === 'datatable' ||
asset.kind === 's3object' ||
asset.kind === 'volume' ||
(asset.kind === 'resource' && isDbType(_resourceMetadata?.resource_type))
(asset.kind === 'resource' &&
(isDbType(_resourceMetadata?.resource_type) ||
isObjectStorageResourceType(_resourceMetadata?.resource_type)))
)
}
</script>
@@ -19,7 +33,12 @@
import { Button, ButtonType } from '$lib/components/common'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { VolumeService } from '$lib/gen'
import { globalDbManagerDrawer, userStore, workspaceStore } from '$lib/stores'
import {
globalDbManagerDrawer,
globalS3FilePickerExplorer,
userStore,
workspaceStore
} from '$lib/stores'
import { isS3Uri } from '$lib/utils'
import { Database, File, HardDriveIcon } from 'lucide-svelte'
import DucklakeIcon from './icons/DucklakeIcon.svelte'
@@ -52,6 +71,12 @@
let dbManagerDrawer = $derived(globalDbManagerDrawer.val)
let ws = $derived(workspace ?? $workspaceStore)
const assetUri = $derived(formatAsset(asset))
// Contexts with a select/upload flow pass their own picker; everything else
// (e.g. the resources list) falls back to the global read-only explorer.
let effectiveS3FilePicker = $derived(s3FilePicker ?? globalS3FilePickerExplorer.val)
let isStorageResource = $derived(
asset.kind === 'resource' && isObjectStorageResourceType(_resourceMetadata?.resource_type)
)
</script>
<Button
@@ -73,11 +98,16 @@
},
ws
)
} else if (isStorageResource) {
effectiveS3FilePicker?.open(undefined, { s3ResourcePath: asset.path, workspace: ws })
} else if (asset.kind === 's3object' && isS3Uri(assetUri)) {
s3FilePicker?.open(assetUri)
effectiveS3FilePicker?.open(assetUri, { workspace: ws })
} else if (asset.kind === 'volume') {
const storage = (await VolumeService.getVolumeStorage({ workspace: ws! })) ?? undefined
s3FilePicker?.open({ s3: `volumes/${ws}/${asset.path}/`, storage })
effectiveS3FilePicker?.open(
{ s3: `volumes/${ws}/${asset.path}/`, storage },
{ workspace: ws }
)
} else if (asset.kind === 'ducklake') {
let ducklake = asset.path.split('/')[0]
let specificTableSplit = asset.path.split('/')[1]?.split('.') as string[] | undefined
@@ -106,7 +136,7 @@
}
onClick?.()
}}
endIcon={asset.kind === 's3object'
endIcon={asset.kind === 's3object' || isStorageResource
? { icon: File }
: asset.kind === 'resource' || asset.kind === 'datatable'
? { icon: Database }
@@ -116,7 +146,7 @@
? { icon: HardDriveIcon }
: undefined}
>
{#if asset.kind === 's3object' || asset.kind === 'volume'}
{#if asset.kind === 's3object' || asset.kind === 'volume' || isStorageResource}
<span class:hidden={noText}>Explore</span>
{:else if asset.kind === 'resource' || asset.kind === 'ducklake' || asset.kind === 'datatable'}
<span class:hidden={noText}>Manage</span>
@@ -331,6 +331,7 @@
class="mt-1"
_resourceMetadata={{ resource_type: resourceType }}
asset={{ kind: 'resource', path: value }}
workspace={effectiveWorkspace}
/>
{/if}
</div>
@@ -38,13 +38,17 @@
onSelectAndClose
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let drawer: Drawer | undefined = $state()
let s3FilePickerInner: S3FilePickerInner | undefined = $state()
let workspaceSettingsInitialized = $state(true)
let storage: string | undefined = $state(undefined)
let s3ResourcePath: string | undefined = $state(undefined)
/** Per-open workspace override, for callers (e.g. the global explorer) whose
* asset lives in a different workspace than this picker was mounted for. */
let workspaceOverride: string | undefined = $state(undefined)
let effectiveWorkspace = $derived(workspaceOverride ?? workspace)
let ws = $derived(effectiveWorkspace ?? $workspaceStore)
let uploadModalOpen = $state(false)
let allFilesByKey: Record<
@@ -66,8 +70,15 @@
{ lazy: true }
)
export async function open(_preSelectedFileKey: S3Object | undefined = undefined) {
secondaryStorageNames.refetch()
export async function open(
_preSelectedFileKey: S3Object | undefined = undefined,
opts: { s3ResourcePath?: string; workspace?: string } = {}
) {
s3ResourcePath = opts.s3ResourcePath
workspaceOverride = opts.workspace
if (!s3ResourcePath) {
secondaryStorageNames.refetch()
}
drawer?.openDrawer?.()
await tick()
@@ -86,12 +97,14 @@
size="1200px"
>
<DrawerContent
title="S3 file browser"
title={s3ResourcePath ? `Exploring ${s3ResourcePath}` : 'S3 file browser'}
on:close={() => {
s3FilePickerInner?.exit?.()
drawer?.closeDrawer?.()
}}
tooltip="Files present in the Workspace S3 bucket. You can set the workspace S3 bucket in the settings."
tooltip={s3ResourcePath
? `Files present in the bucket of the ${s3ResourcePath} resource.`
: 'Files present in the Workspace S3 bucket. You can set the workspace S3 bucket in the settings.'}
documentationLink="https://www.windmill.dev/docs/integrations/s3"
>
<S3FilePickerInner
@@ -109,13 +122,14 @@
bind:storage
bind:allFilesByKey
bind:uploadModalOpen
{s3ResourcePath}
{folderOnly}
{regexFilter}
{workspace}
workspace={effectiveWorkspace}
/>
{#snippet actions()}
<div class="flex gap-1">
{#if secondaryStorageNames.current?.length}
{#if !s3ResourcePath && secondaryStorageNames.current?.length}
<Select
inputClass="h-10 min-w-44 !placeholder-secondary"
items={[
@@ -69,6 +69,8 @@
workspace?: string | undefined
workspaceSettingsInitialized?: boolean
storage?: string | undefined
/** Browse this object storage resource directly instead of the workspace storage. */
s3ResourcePath?: string | undefined
uploadModalOpen?: boolean
allFilesByKey?: Record<
string,
@@ -108,6 +110,7 @@
workspace = undefined,
workspaceSettingsInitialized = $bindable(true),
storage = $bindable(undefined),
s3ResourcePath = undefined,
uploadModalOpen = $bindable(false),
allFilesByKey = $bindable({}),
allowDelete = false,
@@ -192,7 +195,8 @@
maxKeys: maxKeys, // fixed pages of 1000 files for now
marker: page == 0 ? undefined : listMarkers[page - 1],
prefix: rootPath ?? (filter.trim() != '' ? filter : undefined),
storage: storage
storage: storage,
s3ResourcePath
})
if (
availableFiles.restricted_access === null ||
@@ -287,7 +291,8 @@
let fileMetadataRaw = await loadFileMetadataRequest({
workspace: ws!,
fileKey: fileKey,
storage: storage
storage: storage,
s3ResourcePath
})
if (fileMetadataRaw !== undefined) {
@@ -313,7 +318,8 @@
csvHasHeader: csvHasHeader,
readBytesFrom: 0,
readBytesLength: 128 * 1024, // For now static limit of 128Kb per file,
storage: storage
storage: storage,
s3ResourcePath
})
let filePreviewContent = filePreviewRaw.content
@@ -356,7 +362,8 @@
await deleteS3FileRequest({
workspace: ws!,
fileKey: fileKey,
storage: storage
storage: storage,
s3ResourcePath
})
} finally {
fileDeletionInProgress = false
@@ -417,7 +424,8 @@
workspace: ws!,
srcFileKey: srcFileKey,
destFileKey: destFileKey!,
storage: storage
storage: storage,
s3ResourcePath
})
} finally {
fileMoveInProgress = false
@@ -463,7 +471,8 @@
try {
await testConnectionRequest({
workspace: ws!,
storage: storage
storage: storage,
s3ResourcePath
})
workspaceSettingsInitialized = true
} catch (e) {
@@ -554,6 +563,15 @@
<p class="text-clip grow min-w-0"> Double check the S3 resource fields and try again. </p>
</div>
</Alert>
{:else if s3ResourcePath}
<Alert type="error" title="Could not connect to the object storage of {s3ResourcePath}">
<div class="flex flex-row gap-x-1 w-full items-center">
<p class="text-clip grow min-w-0">
Double check the resource fields and that its object storage is reachable, then try again.
</p>
<Button variant="default" on:click={reloadContent} startIcon={{ icon: RotateCw }} />
</div>
</Alert>
{:else}
<Alert type="error" title="Workspace not connected to any S3 storage">
<div class="flex flex-row gap-x-1 w-full items-center">
@@ -721,7 +739,7 @@
{#if filePreview !== undefined && (!hideS3SpecificDetails || !readOnlyMode || allowDelete)}
<div class="flex gap-2 shrink-0">
{#if !hideS3SpecificDetails}
{@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}`}
{@const downloadApiPath = `/w/${ws}/job_helpers/download_s3_file?file_key=${encodeURIComponent(fileMetadata?.fileKey ?? '')}${storage ? `&storage=${storage}` : ''}${s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : ''}`}
{@const downloadName =
fileMetadata?.fileKey.split('/').pop() ?? 'unnamed_download.file'}
{#if shouldDownloadViaClient()}
@@ -789,6 +807,8 @@
<S3FilePreview
fileKey={fileMetadata?.fileKey}
{storage}
{s3ResourcePath}
workspace={ws}
{loadFilePreviewRequest}
{loadFileMetadataRequest}
class="h-full"
@@ -26,6 +26,12 @@
// Optional override of the storage backend (matches the picker's
// `storage` prop). Empty/undefined uses the workspace default.
storage?: string | undefined
// Browse this object storage resource directly instead of the
// workspace storage (matches the picker's `s3ResourcePath` prop).
s3ResourcePath?: string | undefined
// Workspace the previewed object lives in — the acting workspace of the
// surface that opened the preview; defaults to the nav workspace.
workspace?: string | undefined
// Override hooks for non-default backends (e.g. workspace settings
// preview before the storage is committed). Default to the standard
// helpers service — same defaults as S3FilePickerInner.
@@ -48,6 +54,8 @@
let {
fileKey,
storage = undefined,
s3ResourcePath = undefined,
workspace = undefined,
loadFilePreviewRequest = HelpersService.loadFilePreview,
loadFileMetadataRequest = HelpersService.loadFileMetadata,
showMetadata = false,
@@ -103,11 +111,13 @@
// existence after an upstream run completes — moving from the
// "not yet materialized" empty state to the actual preview without
// requiring the user to re-click the asset.
let ws = $derived(workspace ?? $workspaceStore)
$effect(() => {
const key = fileKey
const ws = $workspaceStore
const ws_ = ws
void refreshKey
if (!key || !ws) {
if (!key || !ws_) {
fileMetadata = undefined
filePreview = undefined
notFound = false
@@ -129,9 +139,10 @@
loadError = undefined
try {
const meta = await loadFileMetadataRequest({
workspace: $workspaceStore!,
workspace: ws!,
fileKey: key,
storage
storage,
s3ResourcePath
})
if (meta !== undefined) {
fileMetadata = {
@@ -158,7 +169,7 @@
filePreviewLoading = true
try {
const raw = await loadFilePreviewRequest({
workspace: $workspaceStore!,
workspace: ws!,
fileKey: key,
fileSizeInBytes: size,
fileMimeType: mimeType,
@@ -166,7 +177,8 @@
csvHasHeader: csvHasHeader,
readBytesFrom: 0,
readBytesLength: 128 * 1024,
storage
storage,
s3ResourcePath
})
let content = raw.content
if (content !== null && content !== undefined && content.length >= 128 * 1024) {
@@ -191,8 +203,12 @@
}
// `storage` is keyed by the workspace's S3 storage config name — used as
// a query-string suffix on the image/PDF preview URLs.
let storageQS = $derived(storage ? `&storage=${storage}` : '')
// a query-string suffix on the image/PDF preview URLs, together with the
// optional custom resource override.
let storageQS = $derived(
(storage ? `&storage=${storage}` : '') +
(s3ResourcePath ? `&s3_resource_path=${encodeURIComponent(s3ResourcePath)}` : '')
)
function onCsvControlsChanged() {
if (fileMetadata?.fileKey) {
@@ -245,7 +261,7 @@
{:else if fileMetadata?.fileKey.endsWith('.png') || fileMetadata?.fileKey.endsWith('.jpg') || fileMetadata?.fileKey.endsWith('.jpeg') || fileMetadata?.fileKey.endsWith('.webp')}
<div>
<ExpandableImage
src={`/api/w/${$workspaceStore}/job_helpers/load_image_preview?file_key=${encodeURIComponent(
src={`/api/w/${ws}/job_helpers/load_image_preview?file_key=${encodeURIComponent(
fileMetadata.fileKey
)}${storageQS}`}
alt="S3 preview"
@@ -258,7 +274,7 @@
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
source={`/api/w/${$workspaceStore}/job_helpers/load_image_preview?file_key=${encodeURIComponent(
source={`/api/w/${ws}/job_helpers/load_image_preview?file_key=${encodeURIComponent(
fileMetadata.fileKey
)}${storageQS}`}
/>
+9
View File
@@ -146,6 +146,15 @@ export const aiUserDisabled = writable<boolean>(
export const usedTriggerKinds = writable<string[]>([])
export let globalDbManagerDrawer: StateStore<DbManagerUriState | undefined> = { val: undefined }
/** Read-only S3 file browser (S3FilePicker instance) mounted in the logged
* layout, used by Explore buttons in contexts that don't wire their own picker
* instance. Typed loosely because the component instance type resolves
* differently in .ts and .svelte contexts. */
export let globalS3FilePickerExplorer: StateStore<
{ open: (fileKey?: any, opts?: { s3ResourcePath?: string }) => Promise<void> } | undefined
> = createState({
val: undefined
})
export let globalForkModal: StateStore<GlobalForkModalState | undefined> = createState({
val: undefined
})
@@ -38,7 +38,8 @@
devopsRole,
whitelabelNameStore,
globalDbManagerDrawer,
globalForkModal
globalForkModal,
globalS3FilePickerExplorer
} from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { afterNavigate, beforeNavigate } from '$app/navigation'
@@ -83,6 +84,7 @@
import WorkspaceScopeHeader from '$lib/components/sidebar/WorkspaceScopeHeader.svelte'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
import { useDbManagerUriState } from '$lib/components/dbManagerDrawerModel.svelte'
import Modal2 from '$lib/components/common/modal/Modal2.svelte'
@@ -778,6 +780,13 @@
})
globalDbManagerDrawer.val = useDbManagerUriState()
let globalS3FilePicker: S3FilePicker | undefined = $state()
$effect(() => {
// `as any`: the component instance type is opaque in svelte2tsx context
// and does not match the store's structural type.
globalS3FilePickerExplorer.val = globalS3FilePicker as any
})
</script>
<svelte:window bind:innerWidth />
@@ -1327,6 +1336,10 @@
<DBManagerDrawer uriState={globalDbManagerDrawer.val} />
{/if}
{#if $workspaceStore}
<S3FilePicker bind:this={globalS3FilePicker} readOnlyMode allowDelete />
{/if}
<ForkConflictModal />
<Modal2