iterate on assets

This commit is contained in:
Diego Imbert
2025-06-25 18:34:26 +02:00
parent 3241924c18
commit 7f9a4dee60
11 changed files with 377 additions and 7 deletions
@@ -1,3 +1,3 @@
DROP TABLE assets;
DROP TABLE asset;
DROP TYPE ASSET_USAGE_KIND;
DROP TYPE ASSET_KIND;
@@ -1,7 +1,8 @@
CREATE TYPE ASSET_USAGE_KIND AS ENUM ('script', 'flow', 'flow_step');
CREATE TYPE ASSET_KIND AS ENUM ('s3_object', 'resource');
CREATE TYPE ASSET_USAGE_KIND AS ENUM ('script', 'flow');
CREATE TYPE ASSET_KIND AS ENUM ('s3object', 'resource');
CREATE TABLE assets (
CREATE TABLE asset (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE ON UPDATE CASCADE,
path VARCHAR(255) NOT NULL,
kind ASSET_KIND NOT NULL,
usage_path VARCHAR(255) NOT NULL,
+83
View File
@@ -12942,6 +12942,71 @@ paths:
schema:
type: string
/w/{workspace}/assets/link:
post:
summary: Deletes all current assets of the corresponding entity and updates them to the new ones
operationId: link
tags:
- asset
parameters:
- $ref: '#/components/parameters/WorkspaceId'
requestBody:
description: link assets
required: true
content:
application/json:
schema:
type: object
properties:
assets:
type: array
items:
$ref: '#/components/schemas/Asset'
usage_path:
type: string
usage_kind:
$ref: '#/components/schemas/AssetUsageKind'
required: [assets, usage_path, usage_kind]
responses:
'201':
description: assets linked
/w/{workspace}/assets/list:
get:
summary: List all assets in the workspace
operationId: list
tags:
- asset
parameters:
- $ref: '#/components/parameters/WorkspaceId'
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
'200':
description: assets linked
content:
application/json:
schema:
type: array
items:
type: object
required: [path, kind, usages]
properties:
path:
type: string
kind:
$ref: '#/components/schemas/AssetKind'
usages:
type: array
items:
type: object
required: [usage_path, usage_kind]
properties:
usage_path:
type: string
usage_kind:
$ref: '#/components/schemas/AssetUsageKind'
components:
securitySchemes:
bearerAuth:
@@ -17164,3 +17229,21 @@ components:
type: string
description: Microsoft Teams channel name
minLength: 1
AssetUsageKind:
type: string
enum:
- script
- flow
AssetKind:
type: string
enum:
- s3object
- resource
Asset:
type: object
properties:
path:
type: string
kind:
$ref: '#/components/schemas/AssetKind'
required: [path, kind]
+114
View File
@@ -0,0 +1,114 @@
use axum::{extract::{Path, Query}, routing::{post, get}, Extension, Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::{Postgres, Transaction};
use windmill_common::{db::UserDB, error::{JsonResult, Result}, utils::Pagination};
use crate::db::ApiAuthed;
pub fn workspaced_service() -> Router {
Router::new()
.route("/link", post(link_assets))
.route("/list", get(list_assets))
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Hash, Eq, sqlx::Type)]
#[sqlx(type_name = "ASSET_KIND", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum AssetKind {
S3Object,
Resource,
}
#[derive(Serialize, Deserialize, Debug, PartialEq, Copy, Clone, Hash, Eq, sqlx::Type)]
#[sqlx(type_name = "ASSET_USAGE_KIND", rename_all = "lowercase")]
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
pub enum AssetUsageKind {
Script,
Flow,
}
#[derive(Deserialize)]
pub struct Asset {
pub path: String,
pub kind: AssetKind,
}
#[derive(Deserialize)]
pub struct LinkAssetsBody {
pub assets: Vec<Asset>,
pub usage_path: String,
pub usage_kind: AssetUsageKind,
}
async fn link_assets(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Json(body): Json<LinkAssetsBody>,
) -> JsonResult<()> {
let mut tx = user_db.begin(&authed).await?;
link_assets_internal(&mut tx, w_id, body).await?;
tx.commit().await?;
Ok(Json(()))
}
async fn link_assets_internal(
tx: &mut Transaction<'_, Postgres>,
w_id: String,
body: LinkAssetsBody,
) -> Result<()> {
sqlx::query!(
r#"DELETE FROM asset WHERE workspace_id = $1 AND usage_path = $2 AND usage_kind = $3;"#,
w_id,
body.usage_path,
body.usage_kind as AssetUsageKind
)
.execute(&mut **tx)
.await?;
for asset in body.assets {
sqlx::query!(
r#"INSERT INTO asset (workspace_id, path, kind, usage_path, usage_kind) VALUES ($1, $2, $3, $4, $5);"#,
w_id,
asset.path,
asset.kind as AssetKind,
body.usage_path,
body.usage_kind as AssetUsageKind
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn list_assets(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Query(pagination): Query<Pagination>
) -> JsonResult<Vec<Value>> {
let limit = pagination.per_page.unwrap_or(50).min(100);
let assets = sqlx::query_scalar!(
r#"SELECT
jsonb_build_object(
'path', path,
'kind', kind,
'usages', ARRAY_AGG(jsonb_build_object(
'usage_path', usage_path,
'usage_kind', usage_kind
))
) as "list!: _"
FROM asset
WHERE workspace_id = $1
GROUP BY path, kind
LIMIT $2 OFFSET $3"#,
w_id,
limit as i64,
(pagination.page.unwrap_or(1).saturating_sub(1) * limit) as i64
)
.fetch_all(&mut *user_db.begin(&authed).await?)
.await?;
Ok(Json(assets))
}
+2
View File
@@ -70,6 +70,7 @@ mod agent_workers_oss;
mod ai;
mod apps;
pub mod args;
mod assets;
mod audit;
pub mod auth;
mod capture;
@@ -560,6 +561,7 @@ pub async fn run_server(
// Reordered alphabetically
.nest("/acls", granular_acls::workspaced_service())
.nest("/apps", apps::workspaced_service())
.nest("/assets", assets::workspaced_service())
.nest("/audit", audit::workspaced_service())
.nest("/capture", capture::workspaced_service())
.nest(
@@ -11,9 +11,10 @@
type TriggersCount,
PostgresTriggerService,
CaptureService,
type ScriptLang
type ScriptLang,
AssetService
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { inferArgs, inferAssets } from '$lib/infer'
import { initialCode } from '$lib/script_helpers'
import AIFormSettings from './copilot/AIFormSettings.svelte'
import {
@@ -97,6 +98,7 @@
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import { parseAsset } from './assets/lib'
interface Props {
script: NewScript & { draft_triggers?: Trigger[] }
@@ -562,6 +564,18 @@
)
}
const assets = (await inferAssets(script.language, script.content))
.map(parseAsset)
.filter((a) => !!a)
await AssetService.link({
workspace: $workspaceStore!,
requestBody: {
assets,
usage_kind: 'script',
usage_path: script.path
}
})
const { draft_triggers: _, ...newScript } = structuredClone($state.snapshot(script))
savedScript = structuredClone($state.snapshot(newScript)) as NewScriptWithDraft
setDraftTriggers([])
+20
View File
@@ -0,0 +1,20 @@
import type { AssetKind } from '$lib/gen'
export type Asset = {
path: string
kind: AssetKind
}
export function parseAsset(asset: string): Asset | undefined {
if (asset.startsWith('$res:')) return { path: asset.substring(5), kind: 'resource' }
if (asset.startsWith('s3://')) return { path: asset.substring(5), kind: 's3object' }
}
export function formatAsset(asset: Asset): string {
switch (asset.kind) {
case 'resource':
return `$res:${asset.path}`
case 's3object':
return `s3://${asset.path}`
}
}
@@ -33,7 +33,8 @@
Plus,
Unplug,
AlertCircle,
Database
Database,
Pyramid
} from 'lucide-svelte'
import UserMenu from './UserMenu.svelte'
import DiscordIcon from '../icons/brands/Discord.svelte'
@@ -178,6 +179,14 @@
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-resources',
aiDescription: 'Button to navigate to resources'
},
{
label: 'Assets',
href: `${base}/assets`,
icon: Pyramid,
disabled: $userStore?.operator,
aiId: 'sidebar-menu-link-assets',
aiDescription: 'Button to navigate to assets'
}
])
let defaultExtraTriggerLinks = $derived([
+32
View File
@@ -1,5 +1,6 @@
// https://github.com/sveltejs/svelte/issues/14600
import { untrack } from 'svelte'
import type { StateStore } from './utils'
export function withProps<Component, Props>(component: Component, props: Props) {
@@ -60,3 +61,34 @@ export function usePromise<T>(
return ret
}
export type UsePaginatedResult<T> = {
items: T[]
status: 'loading' | 'error' | 'ok'
currentPage: number
loadMore: () => void
}
export function usePaginated<T>(query: (page: number) => Promise<{ items: T[] }>) {
let s: UsePaginatedResult<T> = $state({
items: [],
status: 'loading',
currentPage: 1,
loadMore: () => {
s.currentPage++
s.status = 'loading'
promise.refresh()
}
})
const promise = usePromise(() => query(s.currentPage))
$effect(() => {
if (promise.status === 'ok') {
untrack(() => {
s.status = promise.status
s.items = [...s.items, ...promise.value.items]
})
}
})
return s
}
@@ -0,0 +1,5 @@
export function load() {
return {
stuff: { title: 'Assets' }
}
}
@@ -0,0 +1,90 @@
<script lang="ts">
import { formatAsset } from '$lib/components/assets/lib'
import CenteredPage from '$lib/components/CenteredPage.svelte'
import { Button, DrawerContent } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import { Cell, DataTable } from '$lib/components/table'
import Head from '$lib/components/table/Head.svelte'
import { AssetService } from '$lib/gen'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { usePaginated } from '$lib/svelte5Utils.svelte'
import { pluralize } from '$lib/utils'
import { RefreshCw } from 'lucide-svelte'
let assets = usePaginated(async (page) => {
return {
items: await AssetService.list({
workspace: $workspaceStore ?? '',
page,
perPage: 50
})
}
})
let viewOccurences: (typeof assets)['items'][number] | undefined = $state()
</script>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources}
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
<p class="font-bold">Unauthorized</p>
<p>Page not available for operators</p>
</div>
{:else}
<CenteredPage>
<PageHeader
title="Assets"
tooltip="Track where your assets are used in Windmill."
documentationLink="https://www.windmill.dev/docs/core_concepts/assets"
/>
<DataTable>
<Head>
<tr>
<Cell head first>Asset name</Cell>
<Cell head></Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#each assets.items as item}
{@const assetUri = formatAsset(item)}
<tr>
<Cell first>{assetUri}</Cell>
<Cell>
<a href={`#${assetUri}`} onclick={() => (viewOccurences = item)}>
{pluralize(item.usages.length, 'occurrence')}
</a>
</Cell>
</tr>
{/each}
<tr class="w-full">
<td colspan={99} class="p-1">
<Button
wrapperClasses="mx-auto"
size="xs"
startIcon={{ icon: RefreshCw }}
color="light"
on:click={() => assets.loadMore()}
>
Load more
</Button>
</td>
</tr>
</tbody>
</DataTable>
</CenteredPage>
{/if}
<Drawer
open={viewOccurences !== undefined}
size="900px"
on:close={() => (viewOccurences = undefined)}
>
<DrawerContent title="Asset occurrences" on:close={() => (viewOccurences = undefined)}>
{#each viewOccurences?.usages ?? [] as u}
<div class="p-4">
<h3 class="text-lg font-semibold mb-2">{u.usage_kind}</h3>
<p class="text-sm text-gray-600 mb-2">{u.usage_path}</p>
</div>
{/each}
</DrawerContent>
</Drawer>