mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 08:00:59 +00:00
iterate on assets
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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([])
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user