Compare commits

..

20 Commits

Author SHA1 Message Date
Alex Petric
2eb7360210 fix: add leaf_job to openapi spec of QueuedJob 2025-05-01 17:56:35 -04:00
centdix
9c55040e47 fix: Ai Chat: do not send tools if empty + respond even if tool fails (#5692)
* do not send tools if empty

* respond to user request even if tool call fails

* remove test line

* add missing await
2025-05-01 15:36:22 +02:00
Diego Imbert
41c15fc78a feat: Database Manager (#5586)
* hack fix dnd with tick

* DBExplorer table left table selector

* kinda works

* correct table metadata

* separated columnDefs creation logic

* Removed dependency on AppDbExplorer

* (tweak) loadTableMetaData much faster

* nit for darkmode

* DBExplorerDrawerButton

* footer

* count footer

* reload

* update

* fix height

* db explorer btn in resources table

* delete row

* InsertRowDrawerButton

* insert

* refresh on insert and delete

* moved db logic to ts file

* better update ux

* moved all IO upwards

* fix: Remaining svelte 5 bugs (#5563)

* hack fix dnd with tick

* fix: infinite loading in CodeDisplay after update to svelte 5

* regen package-lock

* fix tutorial (#5562)

* fix tutorial first part

* fix tutorial

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>

* feat: Implement sending diff to ai (#5510)

* allow mentioning specific files in instructions

* remove not working highlight implementation

* make highlighted text work

* fix tooltip position

* clean code

* cleaning

* use lib for tooltip positioning

* fix logic

* draft for db in context

* use tools for db in context

* fixes

* cleaning and bug fixes

* fix

* cleaning

* fix when script is db type

* simplify logic

* put schema in context if already here

* fix imports

* fix tooltip position and make it scrollable

* remove console logs

* check if selected is in available

* fix tooltip list

* add back lost logic

* last fix

* fix type errors

* use loaded schema from dbSchemas

* fix typing, content and lang are always there

* remove from context if not available anymore

* add not loaded yet mention if schema not loaded

* add missing callback logic

* fix prompt

* fix usage of updateselectedContext function

* fix styling for white theme

* handle tab and arrows

* fix schemas not being refreshed on contexts

* also refresh displayMessages when dbschemas change

* fix duplicate available contexts

* fix logic for new scripts

* fix new lines inside text area

* implement sending diff in context

* add button in deploy options to ask ai about diff

* also visualize change when asking for diff

* better prompt

* add limit to diff size

* put diff mode toggle in editor bar

* add button to see history from editor

* adjustements

* put see diff button in dropdown

* fixes

* better styling

* highlight if diff mode

* format files

* change buttons based on diffmode

* remove diff after sending message

* fix type error

* smaller buttons

* draft

* use existing editor in diff editor

* fix number of db resources fetches

* fix apply and add buttons on diff mode

* cleaning

* undo ai gen button show

* better buttons

* styling asjustements + show diff in badge

* styling

* fix deployed code check

* cleaning and styling

* better quick actions

* dont send code when analyzing

* remove apply in chat if only code and no diff

* fix bad code refactor

---------

Co-authored-by: HugoCasa <hugo@casademont.ch>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>

* fix: prevent invalid returned ai completion object errors (#5564)

* fix(frontend): app builder - force json configuration in rich result (#5565)

* feat: make azure a standalone AI provider (#5558)

* feat: make azure a standalone AI provider

* oups

* nit

* fix: openai/azure oauth

* nit

* nits

* feat(frontend) add flow step result viewer (#5398)

* fix(cli): properly handle enabled/disabled updates of schedules

* fix benchmarks

* feat: handle sending selected lines to ai context (#5527)

* allow mentioning specific files in instructions

* remove not working highlight implementation

* make highlighted text work

* fix tooltip position

* clean code

* cleaning

* use lib for tooltip positioning

* fix logic

* draft for db in context

* use tools for db in context

* fixes

* cleaning and bug fixes

* fix

* cleaning

* fix when script is db type

* simplify logic

* put schema in context if already here

* fix imports

* fix tooltip position and make it scrollable

* remove console logs

* check if selected is in available

* fix tooltip list

* add back lost logic

* last fix

* fix type errors

* use loaded schema from dbSchemas

* fix typing, content and lang are always there

* remove from context if not available anymore

* add not loaded yet mention if schema not loaded

* add missing callback logic

* fix prompt

* fix usage of updateselectedContext function

* fix styling for white theme

* handle tab and arrows

* fix schemas not being refreshed on contexts

* also refresh displayMessages when dbschemas change

* fix duplicate available contexts

* fix logic for new scripts

* fix new lines inside text area

* implement sending diff in context

* add button in deploy options to ask ai about diff

* also visualize change when asking for diff

* better prompt

* add limit to diff size

* put diff mode toggle in editor bar

* add button to see history from editor

* adjustements

* put see diff button in dropdown

* fixes

* better styling

* handle adding code piece to context

* add code piece in context

* draft start end markers

* adapt code

* draft

* apply code pieces before sending request

* cleaning

* highlight if diff mode

* format files

* change buttons based on diffmode

* remove diff after sending message

* fix type error

* smaller buttons

* draft

* use existing editor in diff editor

* fix number of db resources fetches

* fix apply and add buttons on diff mode

* cleaning

* undo ai gen button show

* better buttons

* better prompt

* remove console log

* fix merge

* avoid duplicates

* fix merge

* fix

* fix apply logic

* remove useless if

* focus text area + close chat if no selected lines

---------

Co-authored-by: HugoCasa <hugo@casademont.ch>

* fix: flow editor svelte 5 issues (#5567)

* feat: add diff toggle to flow inline scripts (#5550)

* draft flow diff

* add missing import

* cleaning

* code cleaning

* fix for recursive renderings

* fix typo

* cleaning

---------

Co-authored-by: HugoCasa <hugo@casademont.ch>

* fix(frontend): proper each block binding + better app settings reactivity (#5568)

* fix: properly bind to array elements in Svelte each loops

This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables.

The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]}

Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte

* better app settings panel reactivity

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: HugoCasa <hugo@casademont.ch>

* fix: app editor svelte 5 fixes (#5570)

* fix: properly bind to array elements in Svelte each loops

This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables.

The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]}

Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte

* better app settings panel reactivity

* fix: app editor table svelte 5 fixes

---------

Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>

* select border (#5571)

* fix: properly bind to array elements in Svelte each loops

This commit fixes an issue where binding directly to loop variables in Svelte's #each loops doesn't properly update the original array. Instead of binding directly to the loop variable, we now bind to the array elements using index variables.

The pattern used is: - Change: {#each arr as el} -> {#each arr as _, index} - Change: bind:value={el} -> bind:value={arr[index]}

Modified files: - frontend/src/lib/components/ArrayTypeNarrowing.svelte - frontend/src/lib/components/apps/editor/AppInputs.svelte - frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte

* better app settings panel reactivity

* fix: app editor table svelte 5 fixes

* fix: select border

---------

Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>

* feat: add windmill context to autocomplete (#5548)

* add windmill context to autocomplete

* fix formatting

* remove console log

* do not mention tool call for autocomplete

* apply logic to php

---------

Co-authored-by: HugoCasa <hugo@casademont.ch>

* fix tabs selected behavior change from svelte 5

* fix: fix list jobs by tag

* fix: tenant id to never be undefined on teams (#5572)

* fix: tenant id to never be undefined

* simplify azure oauth

* simplify azure oauth

* update ee ref

* sqlx prepare

* sqlx prepare

* fix: legacy script gen model selection (#5574)

* feat: add wildcards filter for worker/label/tags

* fix: Dynamic select does not work with tag //native (#5576)

closes #5490

* function takes 13 arguments but 14 arguments were supplied (#5577)

* fix(frontend): prevent deploy popover to show if deploy dropdown is open (#5542)

* prevent deploy popover to show if deploy dropdown is open

* wip

* Revert "wip"

This reverts commit 85434654af.

* Revert "prevent deploy popover to show if deploy dropdown is open"

This reverts commit edd9eda156.

* add prop to hide popup fro dropdown

* feat: button can have tooltip

* improve deploy tooltip and dropdown behavior

* rename tooltip to tooltipPopover

* nit

* add deploy button component

* use svelte 5 runes

* use new deploy button for script builder

* add delay to deploy popover when dropdown is open

* add delay to deploy popover when dropdown is open

# Conflicts:
#	frontend/src/lib/components/DeployButton.svelte
#	frontend/src/lib/components/common/button/Button.svelte

* Update frontend/src/lib/components/common/button/Button.svelte

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* remove unsused field

* nit

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* chore: update hub sync script (#5579)

* fix monaco suggestion z-index (#5578)

* fix: validate saved module before passing to flow module editor (#5580)

* fix: freeze when clicking script history diff button (#5581)

* fix: validate saved module before passing to flow module editor

* fix: freeze when clicking script history diff button

* add space (#5582)

* fix: binding not working in nested array script arg (#5585)

* fix: mssql ca_cert deserializing (#5587)

* fix: improve app image picker UX (#5589)

* DBTableAction

* delete table

* fix intempestive error toasts

* fullscreen mode

* rename db explorer to db manager

* use drawer open state instead of oo open method

* create table btn

* factor away sucess text

* basic table creation form

* uniq check

* better select

* better add btn

* extra settings

* create table works in pgsql

* MySQL kinda works

* CRUD works in mysql

* fix lowercase

* allow create table with no schema

* handle default value

* sql repl pane

* execute sql queries

* db ops opt in

* SQL Repl v0

* ux

* UX

* better refresh

* better placeholder sql

* sql code clipboard copy

* handle multiple primary keys in table creation

* fix all fields being required on insert

* fix postgres enum not properly converted

* use InsertRowDrawerButton in App db studio

* insert shortcut

* fix mysql datetime parser for non tz dates

* npm run check

* Revert "fix all fields being required on insert"

This reverts commit 6bec952fb7.

* fk ui

* don't commit .env...

* tweak

* fk ui bindings

* fk dropdowns right values

* schema notation fixes

* handle on delete / update cascade etc

* better loading button without flicker

* fix infinite loop caused by getDbSchemas

* foreign key error validation

* type error

* cache col defs

* fix label hover

* fix fk select overflow

* Fix Select styling

* mssql fixes

* fix wrong typecast failing with mssql

* extract makeLoadTableMetaDataQuery

* Fetch all col defs in one go (mysql)

* loadAllTablesMetaData for postgres

* for some reason factoring transformColumnDefs broke ag infinite table

* mssql loads all coldefs at once

* snowflake preload all col defs

* filter out information schema snowflake

* default schema select

* fix original pg col def logic broken

* Fix ugly flickers

* fix updateGrid before grid ready

* better auto sizing

* smoother CRUD UI refreshes

* fix col defs qury for bigquery

* bigquery works

* nits

* do not change queries used in policies !

* fix runPreviewJobAndPollResult on WINDMILL_TOO_BIG

* select padding

* ellipsis typos

* fix mysterious ugly red flash when mounting ag grid in dark mode only

* Load on click

* Remove schema explorer mode

* repl min size

* fix flash on dark mode

* dirty fix ag grid not refreshing sometimes

* sql repl history

* fix ag theme

* close sql repl result viewer by clicking outside

* Select styling fix in dark mode

* better default query sqlrepl

* Buttons less aggressive

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: centdix <40307056+centdix@users.noreply.github.com>
Co-authored-by: HugoCasa <hugo@casademont.ch>
Co-authored-by: Alexander Petric <alpetric@users.noreply.github.com>
Co-authored-by: Guilhem <guilhemlemouel@gmail.com>
Co-authored-by: Piyush मिश्र <piyushxcoder@gmail.com>
Co-authored-by: Piyush मिश्र <piyush.raj.kit@gmail.com>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-01 10:33:42 +02:00
Ruben Fiszel
09169784bd fix: improve CLI file scanning performances 2025-04-30 19:41:15 +02:00
pyranota
4c660a8869 nit(python): map azure-storage-file-datalake (#5682)
* feat(python): map azure-storage-file-datalake

* add comma
2025-04-30 10:04:10 +02:00
dieriba
dfb947ff37 feat: add run now directly on schedule drawer and duplicate schedule option (#5674)
* add run now on schedule drawer

* add duplicate option for schedules

* fix

* add missing await keywork
2025-04-30 09:36:28 +02:00
Ruben Fiszel
257040ab28 chore(main): release 1.485.3 (#5686)
* chore(main): release 1.485.3

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-04-29 23:59:51 +02:00
Alexander Petric
dda92b5491 agent integration tests (#5684)
* agent integration tests

* agent integration tests

* Update integration_tests/requirements.txt

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-04-29 23:55:38 +02:00
Ruben Fiszel
18dced3c74 fix: improve performance of background cleanup monitoring operations 2025-04-29 23:47:48 +02:00
Ruben Fiszel
c641550eee improve metrics performance 2025-04-29 23:32:30 +02:00
Maxime Thiebaut
fbdaf28fb5 nit(python): add virustotal to imports mapping (#5683) 2025-04-29 22:55:58 +02:00
pyranota
75ee10ea65 fix integration tests failing (#5681)
* fix: make `#(extra_)requirements:` work better with pins

* fix(python): fix integration tests failing
2025-04-29 20:30:34 +02:00
Ruben Fiszel
f120f4ac4b chore(main): release 1.485.2 (#5676)
* chore(main): release 1.485.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2025-04-29 20:21:20 +02:00
Ruben Fiszel
46a3c29c9f improve agent workers 2025-04-29 20:15:49 +02:00
pyranota
1ab41603f4 fix: make #(extra_)requirements: work better with pins (#5680) 2025-04-29 19:59:51 +02:00
Ruben Fiszel
60018aadf6 fix: improve agent workers for deployed scripts 2025-04-29 19:35:52 +02:00
Ruben Fiszel
193186ae75 nits 2025-04-29 15:43:30 +02:00
Ruben Fiszel
e298c8eea5 dark mode nit 2025-04-29 15:42:38 +02:00
Ruben Fiszel
f662cf5d75 fix(python): handle better relative imports with requirements or extra_requirements 2025-04-29 15:23:44 +02:00
centdix
418c36ed62 add current workspace if not in workspace list (#5673) 2025-04-28 21:33:34 +02:00
82 changed files with 3551 additions and 959 deletions

View File

@@ -1,5 +1,21 @@
# Changelog
## [1.485.3](https://github.com/windmill-labs/windmill/compare/v1.485.2...v1.485.3) (2025-04-29)
### Bug Fixes
* improve performance of background cleanup monitoring operations ([18dced3](https://github.com/windmill-labs/windmill/commit/18dced3c748cd5305f0934b26e50d69899563723))
## [1.485.2](https://github.com/windmill-labs/windmill/compare/v1.485.1...v1.485.2) (2025-04-29)
### Bug Fixes
* improve agent workers for deployed scripts ([60018aa](https://github.com/windmill-labs/windmill/commit/60018aadf62cecadf111e019d3600513a89810f1))
* make `#(extra_)requirements:` work better with pins ([#5680](https://github.com/windmill-labs/windmill/issues/5680)) ([1ab4160](https://github.com/windmill-labs/windmill/commit/1ab41603f4fd1526d0c944396ef250b184aed1f4))
* **python:** handle better relative imports with requirements or extra_requirements ([f662cf5](https://github.com/windmill-labs/windmill/commit/f662cf5d75beed8fd114ba171cbe0fa8e4b2773f))
## [1.485.1](https://github.com/windmill-labs/windmill/compare/v1.485.0...v1.485.1) (2025-04-28)

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM metrics WHERE created_at < NOW() - INTERVAL '180 day'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "45dde0dc2da12fe46b5975ed53b520fc4160c008c3d29fe7d99e609c0b6e3e6d"
}

View File

@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
true,
false
false,
true
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"

View File

@@ -41,11 +41,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true
]
},

79
backend/Cargo.lock generated
View File

@@ -1647,9 +1647,9 @@ dependencies = [
[[package]]
name = "bytemuck"
version = "1.22.0"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6b1fc10dbac614ebc03540c9dbd60e83887fda27794998c6528f1782047d540"
checksum = "9134a6ef01ce4b366b50689c94f82c14bc72bc5d0386829828a2e2752ef7958c"
dependencies = [
"bytemuck_derive",
]
@@ -2913,7 +2913,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"serde",
"sourcemap 9.1.2",
"sourcemap 9.2.0",
"swc_atoms",
"swc_common",
"swc_config",
@@ -8960,7 +8960,7 @@ version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit 0.22.25",
"toml_edit 0.22.26",
]
[[package]]
@@ -11048,17 +11048,16 @@ dependencies = [
[[package]]
name = "sourcemap"
version = "9.1.2"
version = "9.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c4ea7042fd1a155ad95335b5d505ab00d5124ea0332a06c8390d200bb1a76a"
checksum = "dd430118acc9fdd838557649b9b43fd0a78e3834d84a283b466f8e84720d6101"
dependencies = [
"base64-simd 0.7.0",
"base64-simd 0.8.0",
"bitvec",
"data-encoding",
"debugid",
"if_chain",
"rustc-hash 1.1.0",
"rustc_version 0.2.3",
"rustc-hash 2.1.1",
"serde",
"serde_json",
"unicode-id-start",
@@ -11553,7 +11552,7 @@ dependencies = [
"rustc-hash 1.1.0",
"serde",
"siphasher 0.3.11",
"sourcemap 9.1.2",
"sourcemap 9.2.0",
"swc_allocator",
"swc_atoms",
"swc_eq_ignore_macros",
@@ -11617,7 +11616,7 @@ dependencies = [
"num-bigint",
"once_cell",
"serde",
"sourcemap 9.1.2",
"sourcemap 9.2.0",
"swc_allocator",
"swc_atoms",
"swc_common",
@@ -12753,9 +12752,9 @@ dependencies = [
[[package]]
name = "toml_edit"
version = "0.22.25"
version = "0.22.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10558ed0bd2a1562e630926a2d1f0b98c827da99fabd3fe20920a59642504485"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
dependencies = [
"indexmap 2.9.0",
"toml_datetime",
@@ -13920,7 +13919,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"axum",
@@ -13967,7 +13966,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"argon2",
@@ -14077,7 +14076,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -14092,7 +14091,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"chrono",
"serde",
@@ -14105,7 +14104,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"serde",
@@ -14119,7 +14118,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"async-stream",
@@ -14183,7 +14182,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"regex",
"serde",
@@ -14197,7 +14196,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"bytes",
@@ -14220,7 +14219,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14232,7 +14231,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14241,7 +14240,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -14253,7 +14252,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"serde_json",
@@ -14265,7 +14264,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"gosyn",
@@ -14277,7 +14276,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -14289,7 +14288,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"serde_json",
@@ -14301,7 +14300,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"nu-parser",
@@ -14312,7 +14311,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14323,7 +14322,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14334,7 +14333,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -14354,7 +14353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -14371,7 +14370,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -14383,7 +14382,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -14401,7 +14400,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"getrandom 0.2.16",
@@ -14425,7 +14424,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"serde_json",
@@ -14435,7 +14434,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -14468,7 +14467,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -14478,7 +14477,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.485.1"
version = "1.485.3"
dependencies = [
"anyhow",
"async-recursion",

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.485.1"
version = "1.485.3"
authors.workspace = true
edition.workspace = true
@@ -32,7 +32,7 @@ members = [
]
[workspace.package]
version = "1.485.1"
version = "1.485.3"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"

View File

@@ -1 +1 @@
649ec8cf49254e24b2c0f21c2ed509b2ab5b4f88
96a2129f585a1bc6567ef08bd27aaa4aca70543f

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,2 @@
-- Add up migration script here
CREATE INDEX IF NOT EXISTS idx_metrics_id_created_at ON public.metrics (id, created_at DESC) WHERE id LIKE 'queue_%';

View File

@@ -0,0 +1 @@
-- Add down migration script here

View File

@@ -0,0 +1,2 @@
-- Add up migration script here
CREATE INDEX IF NOT EXISTS job_stats_id ON job_stats (job_id);

View File

@@ -44,6 +44,7 @@ fn replace_full_import(x: &str) -> Option<String> {
lazy_static! {
static ref RE: Regex = Regex::new(r"^\#\s?(\S+)\s*$").unwrap();
static ref PIN_RE: Regex = Regex::new(r"(?:\s*#\s*(pin|repin):\s*)(\S*)").unwrap();
static ref PKG_RE: Regex = Regex::new(r"^([^!=<>]+)(?:[!=<>]|$)").unwrap();
}
fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<NImport> {
@@ -167,30 +168,28 @@ fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<NImport>>
.take_while(|e| *e != '\n')
.collect::<String>();
if hs.trim_start().is_empty(){
if hs.trim_start().is_empty() {
return None;
}
PIN_RE
.captures(&hs)
.and_then(|x| {
x.get(1).zip(x.get(2)).and_then(|(ty_m, pkg_m)| {
let pkg = pkg_m.as_str().to_owned();
if ty_m.as_str() == "pin" {
Some(vec![NImport::Pin {
pins: vec![ImportPin { pkg, path: path.to_owned() }],
key,
}])
} else if ty_m.as_str() == "repin" {
Some(vec![NImport::Repin {
pin: ImportPin { pkg, path: path.to_owned() },
key,
}])
} else {
None
}
})
PIN_RE.captures(&hs).and_then(|x| {
x.get(1).zip(x.get(2)).and_then(|(ty_m, pkg_m)| {
let pkg = pkg_m.as_str().to_owned();
if ty_m.as_str() == "pin" {
Some(vec![NImport::Pin {
pins: vec![ImportPin { pkg, path: path.to_owned() }],
key,
}])
} else if ty_m.as_str() == "repin" {
Some(vec![NImport::Repin {
pin: ImportPin { pkg, path: path.to_owned() },
key,
}])
} else {
None
}
})
})
};
let mut nimports: Vec<NImport> = ast
@@ -268,7 +267,14 @@ pub async fn parse_python_imports(
Ok(p.pkg)
}).collect_vec(),
NImportResolved::Repin { pin: ImportPin { pkg, .. }, .. } => vec![Ok(pkg)],
NImportResolved::Auto { pkg, ..} => vec![Ok(pkg)],
NImportResolved::Auto { pkg, key } => vec![
if let Some(key) = key {
Ok(format!("{pkg} # (mapped from {key})"))
} else {
Ok(pkg)
}
],
})
.flatten()
.collect::<error::Result<Vec<String>>>()?
@@ -284,6 +290,13 @@ pub async fn parse_python_imports(
Ok((imports, compile_error_hint))
}
fn extract_pkg_name(requirement: &str) -> String {
PKG_RE
.captures(requirement)
.map(|x| x.get(1).map(|m| m.as_str().to_string()).unwrap_or_default())
.unwrap_or_default()
}
#[async_recursion]
async fn parse_python_imports_inner(
code: &str,
@@ -343,11 +356,15 @@ async fn parse_python_imports_inner(
RE.captures(x).and_then(|x| {
x.get(1).map(|m| {
let requirement = m.as_str().to_string();
let key = extract_pkg_name(&requirement);
requirements.insert(
requirement.clone(),
NImportResolved::Repin {
pin: ImportPin { pkg: requirement, path: Default::default() },
key: Default::default(),
key.clone(),
NImportResolved::Pin {
pins: vec![ImportPin {
pkg: requirement.clone(),
path: Default::default(),
}],
key,
},
);
})
@@ -368,9 +385,16 @@ async fn parse_python_imports_inner(
RE.captures(x).and_then(|x| {
x.get(1).map(|m| {
let requirement = m.as_str().to_string();
let key = extract_pkg_name(&requirement);
imports.insert(
requirement.clone(),
NImportResolved::Auto { key: None, pkg: requirement },
key.clone(),
NImportResolved::Pin {
pins: vec![ImportPin {
pkg: requirement,
path: Default::default(),
}],
key,
},
);
})
})

View File

@@ -325,6 +325,7 @@ pub static FULL_IMPORTS_MAP: PyMap = phf_map! {
"azure.mgmt.nspkg" => "azure-mgmt-nspkg",
"azure.keyvault.secrets" => "azure-keyvault-secrets",
"azure.storage.blob" => "azure-storage-blob",
"azure.storage.filedatalake" => "azure-storage-file-datalake",
// Add new entry here ^
};
@@ -376,5 +377,6 @@ pub static SHORT_IMPORTS_MAP: PyMap = phf_map! {
"socks" => "PySocks",
"taiga" => "python-taiga",
"docx" => "python-docx",
"vt" => "vt-py",
// Add new entry here ^
};

View File

@@ -29,7 +29,15 @@ def main():
)
.await?;
// println!("{}", serde_json::to_string(&r)?);
assert_eq!(r, vec!["matplotlib", "wmill", "zanzibar"]);
assert_eq!(
r,
vec![
"matplotlib # (mapped from matplotlib.pyplot)",
"wmill",
"zanzibar # (mapped from zanzibar.estonie)"
]
);
Ok(())
}

View File

@@ -4004,10 +4004,11 @@ async fn test_pins_python(db: Pool<Postgres>) {
# py311
# extra_requirements:
# tiny==0.1.3
# bottle==0.13.2
import f.system.requirements
import f.system.pins
import tiny # repin: bottle==0.13.0
import tiny # repin: tiny==0.1.3
import simplejson
def main():
@@ -4021,7 +4022,7 @@ def main():
ScriptLang::Python3,
vec![
"# py311",
"bottle==0.13.0",
"bottle==0.13.2",
"microdot==2.2.0",
"simplejson==3.19.3",
"tiny==0.1.3",

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.485.1
version: 1.485.3
title: Windmill API
contact:
@@ -13430,6 +13430,8 @@ components:
type: string
raw_code:
type: string
leaf_job:
$ref: "#/components/schemas/LeafJob"
canceled:
type: boolean
canceled_by:
@@ -16729,4 +16731,30 @@ components:
channel_name:
type: string
description: Microsoft Teams channel name
minLength: 1
minLength: 1
LeafJob:
type: object
additionalProperties:
oneOf:
- type: object
properties:
SingleJob:
type: string
format: uuid
description: UUID of an individual job
required:
- SingleJob
additionalProperties: false
- type: object
properties:
ListJob:
type: array
items:
type: string
format: uuid
description: Array of UUIDs for multiple related jobs
required:
- ListJob
additionalProperties: false
description: Map of keys to JobResult, which can be either SingleJob (individual UUID) or ListJob (array of UUIDs)

View File

@@ -570,26 +570,6 @@ pub async fn run_server(
.nest("/ai", ai::global_service())
.route_layer(from_extractor::<ApiAuthed>())
.route_layer(from_extractor::<users::Tokened>())
.nest("/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{
agent_workers_ee::global_service().layer(Extension(agent_cache.clone()))
}
#[cfg(not(feature = "agent_worker_server"))]
{
Router::new()
}
})
.nest("/w/:workspace_id/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{
agent_workers_router.layer(Extension(agent_cache.clone()))
}
#[cfg(not(feature = "agent_worker_server"))]
{
Router::new()
}
})
.nest("/jobs", jobs::global_root_service())
.nest(
"/srch/w/:workspace_id/index",
@@ -627,6 +607,26 @@ pub async fn run_server(
)
.nest("/mcp/w/:workspace_id", mcp_router)
.layer(from_extractor::<OptAuthed>())
.nest("/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{
agent_workers_ee::global_service().layer(Extension(agent_cache.clone()))
}
#[cfg(not(feature = "agent_worker_server"))]
{
Router::new()
}
})
.nest("/w/:workspace_id/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{
agent_workers_router.layer(Extension(agent_cache.clone()))
}
#[cfg(not(feature = "agent_worker_server"))]
{
Router::new()
}
})
.nest(
"/w/:workspace_id/jobs_u",
jobs::workspace_unauthed_service().layer(cors.clone()),

View File

@@ -570,15 +570,18 @@ pub mod script {
let fut = CACHE.get_or_insert_async(hash, async move {
match conn {
Connection::Sql(db) => fetch_script_from_db(&db, hash, loc).await,
Connection::Http(_) => Err(error::Error::InternalErr(format!(
"Cannot fetch script in HTTP mode"
))),
Connection::Http(client) => {
let r = client
.get::<RawScriptApi>(&format!("/api/agent_workers/script/{}", hash.0))
.await?;
Ok(r.into())
}
}
});
fut.map_ok(|ScriptFull { data, meta }| (data, meta))
}
async fn fetch_script_from_db(
pub async fn fetch_script_from_db(
db: &DB,
hash: ScriptHash,
loc: &'static Location<'_>,

View File

@@ -164,7 +164,7 @@ pub async fn cancel_single_job<'c>(
let username = username.to_string();
let w_id = w_id.to_string();
let db = db.clone();
tracing::info!("cancelling job {:?}", db);
tracing::info!("cancelling job {:?}", job_running.id);
let job_running = job_running.clone();
tokio::task::spawn(async move {
let reason: String = reason

View File

@@ -1269,7 +1269,7 @@ pub async fn run_worker(
if job.is_err() && !same_worker_job.recoverable {
tracing::error!(
worker = %worker_name, hostname = %hostname,
"failed to fetch same_worker job on a non recoverable job, exiting"
"failed to fetch same_worker job on a non recoverable job, exiting: {job:?}",
);
job_completed_tx
.kill()

View File

@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.485.1";
export const VERSION = "v1.485.3";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({

View File

@@ -63,7 +63,7 @@ export {
// }
// });
export const VERSION = "1.485.1";
export const VERSION = "1.485.3";
const command = new Command()
.name("wmill")

View File

@@ -609,11 +609,16 @@ export async function* readDirRecursiveWithIgnore(
while (stack.length > 0) {
const e = stack.pop()!;
// console.log(e.path);
yield e;
for await (const e2 of e.c()) {
if (e2.path.startsWith(".git" + SEP)) {
continue;
if (e2.isDirectory) {
const dirName = e2.path.split(SEP).pop();
if (dirName == "node_modules" || dirName?.startsWith(".")) {
continue;
}
}
// console.log(e2.path);
stack.push({
path: e2.path,
ignored: e.ignored || ignore(e2.path, e2.isDirectory),

View File

@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.485.1",
"version": "1.485.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.485.1",
"version": "1.485.3",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -11220,8 +11220,6 @@
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.485.1",
"version": "1.485.3",
"scripts": {
"dev": "vite dev",
"build": "vite build",

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { type DBSchema } from '$lib/stores'
import { MoreVertical, Plus, Table2 } from 'lucide-svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { ClearableInput, Drawer, DrawerContent } from './common'
import { sendUserToast } from '$lib/toast'
import { type ColumnDef } from './apps/components/display/dbtable/utils'
import DBTable from './DBTable.svelte'
import type { DbTableActionFactory, IDbTableOps } from './dbOps'
import DropdownV2 from './DropdownV2.svelte'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import Button from './common/button/Button.svelte'
import DbTableEditor, { type DBTableEditorProps } from './DBTableEditor.svelte'
type Props = {
dbSchema: DBSchema
dbSupportsSchemas: boolean
getColDefs: (tableKey: string) => Promise<ColumnDef[]>
dbTableOpsFactory: (params: { colDefs: ColumnDef[]; tableKey: string }) => IDbTableOps
dbTableActionsFactory?: DbTableActionFactory[]
refresh?: () => void
dbTableEditorPropsFactory?: (params: { selectedSchemaKey?: string }) => DBTableEditorProps
}
let {
dbSchema,
dbTableOpsFactory,
getColDefs,
dbTableActionsFactory,
refresh,
dbTableEditorPropsFactory,
dbSupportsSchemas
}: Props = $props()
let schemaKeys = $derived(Object.keys(dbSchema.schema))
let search = $state('')
let selected: {
schemaKey?: undefined | string
tableKey?: undefined | string
} = $state({})
$effect(() => {
if (!selected.schemaKey && schemaKeys.length) {
selected = {
schemaKey:
'public' in dbSchema.schema ? 'public' : 'dbo' in dbSchema.schema ? 'dbo' : schemaKeys[0]
}
}
})
let tableKeys = $derived.by(() => {
if (dbSchema.lang === 'graphql') {
sendUserToast('graphql not supported by DBExplorerTable', true)
return []
}
if (!selected.schemaKey) return []
return Object.keys(dbSchema.schema[selected.schemaKey])
})
$effect(() => {
if (tableKeys.length && !selected.tableKey) {
selected.tableKey = filteredTableKeys[0]
}
})
let filteredTableKeys = $derived.by(() => {
const l = tableKeys.filter((tk) => tk.includes(search))
l.sort()
return l
})
let tableKey = $derived(
dbSupportsSchemas && selected.schemaKey
? `${selected.schemaKey}.${selected.tableKey}`
: selected.tableKey
)
let askingForConfirmation:
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void })
| undefined = $state()
let dbTableEditorState: { open: boolean } = $state({ open: false })
let dbTableEditorProps = $derived(
dbTableEditorPropsFactory?.({ selectedSchemaKey: selected.schemaKey })
)
</script>
<Splitpanes>
<Pane size={24} class="relative flex flex-col">
<div class="mx-3 mt-3">
{#if dbSupportsSchemas}
<select
value={selected.schemaKey}
onchange={(e) => {
selected = { schemaKey: e.currentTarget.value }
}}
>
{#each schemaKeys as schemaKey}
<option value={schemaKey}>{schemaKey}</option>
{/each}
</select>
{/if}
<ClearableInput wrapperClass="mt-3" bind:value={search} placeholder="Search table..." />
</div>
<div class="overflow-x-clip overflow-y-auto relative mt-3 border-y flex-1">
{#each filteredTableKeys as tableKey}
<button
class={'w-full text-sm font-normal flex gap-2 items-center h-10 cursor-pointer pl-3 pr-1 ' +
(selected.tableKey === tableKey ? 'bg-gray-500/25' : 'hover:bg-gray-500/10')}
onclick={() => (selected.tableKey = tableKey)}
>
<Table2 class="text-gray-500/40 shrink-0" size={16} />
<p class="truncate text-ellipsis grow text-left">{tableKey}</p>
{#if dbTableActionsFactory}
{@const dbTableActions = dbTableActionsFactory.map((f) =>
f({
tableKey: `${selected.schemaKey}.${tableKey}`,
refresh: refresh ?? (() => {})
})
)}
<DropdownV2
items={() =>
dbTableActions.map((tableAction) => ({
displayName: tableAction.displayName,
...(tableAction.icon ? { icon: tableAction.icon } : {}),
action: () =>
(askingForConfirmation = {
title: tableAction.confirmTitle ?? 'Are you sure ?',
confirmationText: tableAction.confirmBtnText ?? 'Confirm',
open: true,
onConfirm: async () => {
askingForConfirmation && (askingForConfirmation.loading = true)
try {
await tableAction.action()
tableAction.successText && sendUserToast(tableAction.successText)
} catch (e) {
let msg: string | undefined = (e as Error).message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : undefined
sendUserToast(msg ?? 'Action failed!', true)
}
askingForConfirmation = undefined
}
})
}))}
class="w-fit"
>
<svelte:fragment slot="buttonReplacement">
<MoreVertical
size={8}
class="w-8 h-8 p-2 hover:bg-surface-hover cursor-pointer rounded-md"
/>
</svelte:fragment>
</DropdownV2>
{/if}
</button>
{/each}
</div>
<Button
variant="border"
on:click={() => (dbTableEditorState = { open: true })}
wrapperClasses="mx-2 my-2 text-sm"
startIcon={{ icon: Plus }}
>
New table
</Button>
</Pane>
<Pane class="p-3 pt-1">
{#if tableKey}
{#await getColDefs(tableKey) then colDefs}
{#if colDefs && colDefs?.length}
{@const dbTableOps = dbTableOpsFactory({ colDefs, tableKey })}
<DBTable {dbTableOps} />
{/if}
{/await}
{/if}
</Pane>
</Splitpanes>
<ConfirmationModal
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
on:canceled={() => (askingForConfirmation = undefined)}
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
/>
{#if dbTableEditorProps}
<Drawer
size="600px"
open={dbTableEditorState.open}
on:close={() => (dbTableEditorState = { open: false })}
>
<DrawerContent
on:close={() => (dbTableEditorState = { open: false })}
title="Create a new table"
>
<DbTableEditor
{...dbTableEditorProps}
{dbSchema}
currentSchema={selected.schemaKey}
onConfirm={async (values) => {
await dbTableEditorProps.onConfirm(values)
dbTableEditorState = { open: false }
}}
/>
</DrawerContent>
</Drawer>
{/if}

View File

@@ -0,0 +1,272 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { sendUserToast, sortArray } from '$lib/utils'
import { ArrowLeft, Database, Expand, Loader2, Minimize, RefreshCcw } from 'lucide-svelte'
import {
dbSupportsSchemas,
getDbSchemas,
getLanguageByResourceType,
loadAllTablesMetaData,
loadTableMetaData,
type DbType,
type TableMetadata
} from './apps/components/display/dbtable/utils'
import DbManager from './DBManager.svelte'
import { Alert } from './common'
import { dbDeleteTableActionWithPreviewScript, dbTableOpsWithPreviewScripts } from './dbOps'
import { makeCreateTableQuery } from './apps/components/display/dbtable/queries/createTable'
import { runPreviewJobAndPollResult } from './jobs/utils'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import SqlRepl from './SqlRepl.svelte'
import SimpleAgTable from './SimpleAgTable.svelte'
import { untrack } from 'svelte'
type Props = {
resourceType: DbType
resourcePath: string
class?: string
}
let { resourceType, resourcePath }: Props = $props()
let dbSchema: DBSchema | undefined = $derived(
resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
)
let isDrawerOpen: boolean = $state(false)
let shouldDisplayError = $derived(
resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
)
// `refreshCount` is a derived state. `refreshing` is the source of truth
let refreshCount = $state(0)
$effect(() => {
if (refreshing) untrack(() => (refreshCount += 1))
})
let refreshing = $state(false)
$effect(() => {
if (refreshing) getSchema()
})
const refresh = () => !refreshing && (refreshing = true)
let expand = $state(false)
$effect(() => {
if (!isDrawerOpen) expand = false
})
async function getSchema() {
if ($dbSchemas[resourcePath] && !refreshing) return
try {
const oldDbSchema = $dbSchemas[resourcePath]
await getDbSchemas(
resourceType,
resourcePath,
$workspaceStore,
$dbSchemas,
(message: string) => {
if (isDrawerOpen) {
sendUserToast(message, true)
}
}
)
// avoid infinite loop on error due to the way getDbSchemas is implemented
// and relying on an assignement side effect
if (oldDbSchema !== $dbSchemas[resourcePath]) $dbSchemas = $dbSchemas
} catch (e) {
console.error(e)
} finally {
refreshing = false
}
}
let windowWidth = $state(window.innerWidth)
let replPanelSize = $state(36)
const REPL_MIN_SIZE = 1.5
let replResultData: undefined | Record<string, any>[] = $state(undefined)
let cachedColDefs: Record<string, TableMetadata> = {}
let cachedLastRefreshCount = 0
async function getColDefs(tableKey: string) {
if (cachedLastRefreshCount !== refreshCount) cachedColDefs = {}
cachedLastRefreshCount = refreshCount
if (cachedColDefs[tableKey]) {
return cachedColDefs[tableKey]
}
try {
cachedColDefs =
(await loadAllTablesMetaData('$res:' + resourcePath, $workspaceStore, resourceType)) ??
cachedColDefs
return cachedColDefs[tableKey]
} catch (e) {
const result = await loadTableMetaData(
'$res:' + resourcePath,
$workspaceStore,
tableKey,
resourceType
)
if (result) cachedColDefs[tableKey] = result
return result ?? []
}
}
</script>
<svelte:window
bind:innerWidth={windowWidth}
onkeydown={(e) => {
if (e.key === 'Escape') {
if (replResultData) {
replResultData = undefined
}
}
}}
/>
{#if shouldDisplayError}
<Alert type="error" size="xs" title="Schema not available" class="mt-2">
Schema could not be loaded. Please check the permissions of the resource.
</Alert>
{:else}
<Button
size="xs"
variant="border"
spacingSize="xs2"
btnClasses="mt-1 w-24"
on:click={async () => {
if (!dbSchema || !$workspaceStore) refreshing = true
isDrawerOpen = true
}}
>
<Database size={18} /> Manager
</Button>
<Drawer bind:open={isDrawerOpen} size={expand ? `${windowWidth}px` : '1200px'} preventEscape>
<DrawerContent
title={replResultData ? 'Query Result' : 'Database Manager'}
on:close={() => {
if (replResultData) {
replResultData = undefined
} else {
isDrawerOpen = false
}
}}
CloseIcon={replResultData ? ArrowLeft : undefined}
noPadding
>
{#if dbSchema && $workspaceStore}
<Splitpanes horizontal>
<Pane class="relative">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={'absolute inset-0 z-10 p-8 ' +
(replResultData
? 'bg-surface/90'
: 'transition-colors bg-transparent pointer-events-none select-none')}
onclick={(e) => {
// Only proceed if the click is directly on this div and not on the child elements
if (e.target === e.currentTarget) {
replResultData = undefined
}
}}
>
{#if replResultData}
{#key replResultData}
<SimpleAgTable data={replResultData} class="animate-zoom-in" />
{/key}
{/if}
</div>
<DbManager
dbSupportsSchemas={dbSupportsSchemas(resourceType)}
{dbSchema}
{getColDefs}
dbTableOpsFactory={({ colDefs, tableKey }) =>
dbTableOpsWithPreviewScripts({
colDefs,
tableKey,
resourcePath,
resourceType,
workspace: $workspaceStore
})}
dbTableActionsFactory={[
dbDeleteTableActionWithPreviewScript({
resourcePath,
resourceType,
workspace: $workspaceStore
})
]}
{refresh}
dbTableEditorPropsFactory={({ selectedSchemaKey }) => ({
resourceType,
previewSql: (values) =>
makeCreateTableQuery(values, resourceType, selectedSchemaKey),
async onConfirm(values) {
await runPreviewJobAndPollResult({
workspace: $workspaceStore,
requestBody: {
args: { database: '$res:' + resourcePath },
content: makeCreateTableQuery(values, resourceType, selectedSchemaKey),
language: getLanguageByResourceType(resourceType)
}
})
refresh()
}
})}
/>
</Pane>
<Pane bind:size={replPanelSize} minSize={REPL_MIN_SIZE} class="relative">
<SqlRepl
{resourcePath}
{resourceType}
onData={(data) => {
replResultData = data
}}
placeholderTableName={sortArray(
Object.keys(
dbSchema?.schema[
'public' in dbSchema?.schema
? 'public'
: 'dbo' in dbSchema?.schema
? 'dbo'
: Object.keys(dbSchema?.schema)?.[0]
]
)
)?.[0]}
/>
</Pane>
</Splitpanes>
{:else}
<Splitpanes>
<Pane class="relative flex justify-center items-center">
<Loader2 class="animate-spin" size={32} />
</Pane>
</Splitpanes>
{/if}
<svelte:fragment slot="actions">
<Button
loading={refreshing}
on:click={() => refresh()}
startIcon={{ icon: RefreshCcw }}
size="xs"
color="light"
>
Refresh
</Button>
<Button
on:click={() => (expand = !expand)}
startIcon={{ icon: expand ? Minimize : Expand }}
size="xs"
color="light"
/>
</svelte:fragment>
</DrawerContent>
</Drawer>
{/if}

View File

@@ -1,58 +1,16 @@
<script lang="ts">
import { dbSchemas, workspaceStore, type DBSchema } from '$lib/stores'
import Button from './common/button/Button.svelte'
import Drawer from './common/drawer/Drawer.svelte'
import DrawerContent from './common/drawer/DrawerContent.svelte'
import { type DBSchema } from '$lib/stores'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { sendUserToast } from '$lib/utils'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import { Loader2, RefreshCcw } from 'lucide-svelte'
import {
formatGraphqlSchema,
formatSchema,
getDbSchemas,
scripts
} from './apps/components/display/dbtable/utils'
import Alert from './common/alert/Alert.svelte'
import { Loader2 } from 'lucide-svelte'
import { formatGraphqlSchema, formatSchema } from './apps/components/display/dbtable/utils'
export let resourceType: string | undefined
export let resourcePath: string | undefined = undefined
let dbSchema: DBSchema | undefined = undefined
let loading = false
let drawer: Drawer | undefined
async function getSchema() {
if (!resourceType || !resourcePath) return
if ($dbSchemas[resourcePath]) return
loading = true
try {
await getDbSchemas(
resourceType,
resourcePath,
$workspaceStore,
$dbSchemas,
(message: string) => {
if (drawer?.isOpen()) {
sendUserToast(message, true)
}
}
)
$dbSchemas = $dbSchemas
} catch (e) {
console.error(e)
}
loading = false
type Props = {
dbSchema: DBSchema
}
$: resourcePath && Object.keys(scripts).includes(resourceType || '') && getSchema()
$: dbSchema = resourcePath && resourcePath in $dbSchemas ? $dbSchemas[resourcePath] : undefined
$: shouldDisplayError = resourcePath && resourcePath in $dbSchemas && !$dbSchemas[resourcePath]
let { dbSchema }: Props = $props()
function handleSelected({ detail }: CustomEvent<string>) {
if (dbSchema && dbSchema.lang !== 'graphql') {
@@ -61,60 +19,23 @@
}
</script>
{#if loading}
<Loader2 size={14} class="animate-spin " />
{/if}
{#if dbSchema}
<Button
size="xs"
variant="border"
color="blue"
spacingSize="xs2"
btnClasses="mt-1"
on:click={drawer?.openDrawer ?? (() => {})}
{#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)}
<ToggleButtonGroup
class="mb-4"
selected={dbSchema.publicOnly ? 'dbo' : 'all'}
on:selected={handleSelected}
let:item
>
Explore schema
</Button>
<Drawer bind:this={drawer}>
<DrawerContent title="Schema Explorer" on:close={drawer.closeDrawer}>
<svelte:fragment slot="actions">
<Button
on:click={getSchema}
startIcon={{
icon: RefreshCcw
}}
{loading}
size="xs"
color="light"
>
Refresh
</Button>
</svelte:fragment>
{#if dbSchema.lang !== 'graphql' && (dbSchema.schema?.public || dbSchema.schema?.PUBLIC || dbSchema.schema?.dbo)}
<ToggleButtonGroup
class="mb-4"
selected={dbSchema.publicOnly ? 'dbo' : 'all'}
on:selected={handleSelected}
let:item
>
<ToggleButton value="dbo" label={dbSchema.schema.dbo ? 'Dbo' : 'Public'} {item} />
<ToggleButton value="all" label="All" {item} />
</ToggleButtonGroup>
{/if}
{#if dbSchema.lang === 'graphql'}
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default code={formatGraphqlSchema(dbSchema.schema)} class="h-full" />
{/await}
{:else}
<ObjectViewer json={formatSchema(dbSchema)} pureViewer collapseLevel={1} />
{/if}
</DrawerContent>
</Drawer>
{:else if shouldDisplayError}
<Alert type="error" size="xs" title="Schema not available" class="mt-2">
Schema could not be loaded. Please check the permissions of the resource.
</Alert>
<ToggleButton value="dbo" label={dbSchema.schema.dbo ? 'Dbo' : 'Public'} {item} />
<ToggleButton value="all" label="All" {item} />
</ToggleButtonGroup>
{/if}
{#if dbSchema.lang === 'graphql'}
{#await import('$lib/components/GraphqlSchemaViewer.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default code={formatGraphqlSchema(dbSchema.schema)} class="h-full" />
{/await}
{:else}
<ObjectViewer json={formatSchema(dbSchema)} pureViewer collapseLevel={1} />
{/if}

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { createGrid, type GridApi, type IDatasource } from 'ag-grid-community'
import { transformColumnDefs } from './apps/components/display/table/utils'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Button } from './common'
import { Download } from 'lucide-svelte'
import Popover from './Popover.svelte'
import DebouncedInput from './apps/components/helpers/DebouncedInput.svelte'
import InsertRowDrawerButton from './apps/components/display/InsertRowDrawerButton.svelte'
import type { IDbTableOps } from './dbOps'
import { deepEqual } from 'fast-equals'
import 'ag-grid-community/styles/ag-grid.css'
import '$lib/components/apps/components/display/table/theme/windmill-theme.css'
type Props = {
dbTableOps: IDbTableOps
}
let { dbTableOps }: Props = $props()
let [clientHeight, clientWidth, darkMode, firstRow, lastRow] = $state([0, 0, false, -1, -1])
let quicksearch = $state('')
let api: GridApi<any> | undefined = $state()
let eGui: HTMLDivElement | undefined = $state()
let datasource: IDatasource = {
getRows: async function (params) {
if (!$workspaceStore) return params.failCallback()
let lastRow = rowCount && rowCount <= params.endRow ? rowCount : -1
const items = await dbTableOps.getRows({
offset: params.startRow,
limit: params.endRow - params.startRow,
quicksearch: params.context.quicksearch,
order_by: params.sortModel?.[0]?.colId ?? dbTableOps.colDefs[0]?.field,
is_desc: params.sortModel?.[0]?.sort === 'desc'
})
if (items.length < params.endRow - params.startRow) lastRow = params.startRow + items.length
params.successCallback(items, lastRow)
}
}
let rowCount = $state(0)
let refreshCount = $state(0)
const refresh = () => (refreshCount += 1)
$effect(() => eGui && mountGrid())
function mountGrid() {
if (eGui && !api) {
createGrid(eGui, {
rowModelType: 'infinite',
pagination: false,
...(dbTableOps.onUpdate && {
defaultColDef: {
flex: 1,
minWidth: 160,
editable: true,
onCellValueChanged: (e) => {
if (!$workspaceStore) return
const colDef = e.colDef as unknown as { field: string; datatype: string }
dbTableOps
.onUpdate?.(
{ values: { ...(e.data as object), [colDef.field]: e.oldValue } },
colDef,
e.newValue
)
.then(() => {
sendUserToast('Value updated')
})
.catch(() => {
sendUserToast('Error updating value', true)
refresh?.()
})
}
}
}),
onViewportChanged: (e) => ([firstRow, lastRow] = [e.firstRow, e.lastRow]),
infiniteInitialRowCount: 100,
cacheBlockSize: 100,
cacheOverflowSize: 10,
maxBlocksInCache: 20,
suppressColumnMoveAnimation: true,
suppressDragLeaveHidesColumns: true,
onGridReady: (e) => {
api = e.api
}
})
}
}
let prevUpdateKey: any = undefined
$effect(() => {
if (!$workspaceStore || !api) return
const key = { quicksearch, colDefs: dbTableOps.colDefs, refreshCount }
if (deepEqual(key, prevUpdateKey)) return
prevUpdateKey = key
updateGrid()
})
function updateGrid() {
dbTableOps.getCount({ quicksearch }).then((result) => (rowCount = result))
api?.purgeInfiniteCache()
api?.updateGridOptions({
datasource,
columnDefs: transformColumnDefs({
columnDefs: dbTableOps.colDefs ?? [],
...(dbTableOps.onDelete && {
onDelete: (values) => {
if (!$workspaceStore) return
dbTableOps
.onDelete?.({ values })
.then(() => {
refresh?.()
sendUserToast('Row deleted')
})
.catch(() => {
sendUserToast('Error deleting row', true)
})
}
})
}),
context: {
quicksearch
}
})
}
</script>
<DarkModeObserver bind:darkMode />
<div class="h-full relative flex flex-col">
<div class="flex py-2 h-12 justify-between gap-4">
<DebouncedInput
class="w-full max-w-[300px]"
type="text"
bind:value={quicksearch}
placeholder="Search..."
/>
{#if dbTableOps.onInsert}
<InsertRowDrawerButton
columnDefs={dbTableOps.colDefs ?? []}
dbType={dbTableOps.resourceType}
onInsert={(values) => {
if (!$workspaceStore) return
dbTableOps.onInsert?.({ values }).then((result) => {
refresh?.()
sendUserToast('Row inserted')
})
}}
/>
{/if}
</div>
<div
class={'flex flex-col flex-1 component-wrapper divide-y wm-aggrid-container'}
bind:clientHeight
bind:clientWidth
>
<div
style:height="{clientHeight}px"
style:width="{clientWidth}px"
class="ag-theme-alpine"
class:ag-theme-alpine-dark={darkMode}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={eGui}
class={api ? '' : 'opacity-0'}
style:height="100%"
onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
const selectedCell = api?.getFocusedCell()
if (selectedCell) {
const rowIndex = selectedCell.rowIndex
const colId = selectedCell.column?.getId()
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
const selectedValue = rowNode?.data?.[colId]
navigator.clipboard.writeText(selectedValue)
sendUserToast('Copied cell value to clipboard', false)
}
}
}}
></div>
</div>
<div class="flex gap-1 w-full justify-between items-center text-xs text-primary p-2">
<div>
<Popover>
<svelte:fragment slot="text">Download</svelte:fragment>
<Button
startIcon={{ icon: Download }}
color="light"
size="xs2"
on:click={() => api?.exportDataAsCsv()}
iconOnly
/>
</Popover>
</div>
{#if rowCount}
{firstRow}{'->'}{lastRow + 1} of {rowCount} rows
{:else}
{firstRow}{'->'}{lastRow + 1}
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,435 @@
<script lang="ts" module>
function validate(values: CreateTableValues, dbSchema?: DBSchema) {
const columnNamesErrs = values.columns.flatMap((column) => {
const isUnique = values.columns.filter((c) => c.name === column.name).length === 1
return !column.name.length || !isUnique ? [column.name] : []
})
const fkErrs = values.foreignKeys.map((fk) => ({
emptyTarget: !fk.targetTable,
nonExistingSourceColumns: fk.columns
.map((c) => c.sourceColumn)
.filter((sc) => values.columns.every((c) => c.name !== sc)),
nonExistingTargetColumns: fk.columns
.map((c) => c.targetColumn)
.filter(
(tc) =>
!tc ||
!Object.keys(
dbSchema?.schema?.[fk.targetTable?.split('.')?.[0] ?? '']?.[
fk.targetTable?.split('.')[1]
] ?? {}
).includes(tc)
)
}))
const someFkErr = fkErrs.some(
(fkErr) =>
fkErr.emptyTarget ||
fkErr.nonExistingSourceColumns.length ||
fkErr.nonExistingTargetColumns.length
)
const errs = {
name: !values.name.length,
columns: columnNamesErrs.length ? columnNamesErrs : undefined,
foreignKeys: someFkErr ? fkErrs : undefined
}
if (Object.values(errs).every((v) => !v)) return undefined
return errs
}
export type DBTableEditorProps = {
onConfirm: (values: CreateTableValues) => void | Promise<void>
previewSql?: (values: CreateTableValues) => string
resourceType: DbType
dbSchema?: DBSchema
currentSchema?: string
}
</script>
<script lang="ts">
import { ArrowRight, ClipboardCopy, Info, Plus, Settings, X } from 'lucide-svelte'
import { Button } from './common'
import { Cell } from './table'
import DataTable from './table/DataTable.svelte'
import Head from './table/Head.svelte'
import {
datatypeHasLength,
dbSupportsSchemas,
type DbType
} from './apps/components/display/dbtable/utils'
import { DB_TYPES } from '$lib/consts'
import Select from './apps/svelte-select/lib/Select.svelte'
import Popover from './meltComponents/Popover.svelte'
import Tooltip from './meltComponents/Tooltip.svelte'
import {
datatypeDefaultLength,
type CreateTableValues
} from './apps/components/display/dbtable/queries/createTable'
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
import { sendUserToast } from '$lib/toast'
import { copyToClipboard } from '$lib/utils'
import { getFlatTableNamesFromSchema, type DBSchema } from '$lib/stores'
import { twMerge } from 'tailwind-merge'
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
import DarkModeObserver from './DarkModeObserver.svelte'
const { onConfirm, resourceType, previewSql, dbSchema, currentSchema }: DBTableEditorProps =
$props()
const columnTypes = DB_TYPES[resourceType]
const defaultColumnType = (
{
postgresql: 'VARCHAR',
snowflake: 'varchar',
ms_sql_server: 'varchar',
bigquery: 'string',
mysql: 'varchar'
} satisfies Record<DbType, string>
)[resourceType]
const values: CreateTableValues = $state({
name: '',
columns: [],
foreignKeys: []
})
function addColumn({ name, primaryKey }: { name: string; primaryKey?: boolean }) {
values.columns.push({
name,
datatype: defaultColumnType,
...(datatypeHasLength(defaultColumnType) && {
datatype_length: datatypeDefaultLength(defaultColumnType)
}),
...(primaryKey && { primaryKey })
})
}
addColumn({ name: 'id', primaryKey: true })
const errors: ReturnType<typeof validate> = $derived(validate(values, dbSchema))
let askingForConfirmation:
| (ConfirmationModal['$$prop_def'] & { onConfirm: () => void; codeContent?: string })
| undefined = $state()
let darkMode = $state(false)
</script>
<DarkModeObserver bind:darkMode />
<div class="flex flex-col h-full">
<div class="flex-1 overflow-y-auto flex flex-col gap-6">
<label>
Name
<input
type="text"
placeholder="my_table"
class={errors?.name ? 'border !border-red-600/60' : ''}
bind:value={values.name}
/>
</label>
<div class="flex flex-col">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>Columns</label>
<DataTable>
<Head>
<tr>
<Cell head first>Name</Cell>
<Cell head>Type</Cell>
<Cell head last>Primary</Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#each values.columns as column, i}
<tr>
<Cell first>
<input
type="text"
class={'h-10 ' +
(errors?.columns?.includes(column.name) ? 'border !border-red-600/60' : '')}
style="height: 2rem;"
placeholder="column_name"
bind:value={column.name}
/>
</Cell>
<Cell>
<Select
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
class="!w-48"
value={column.datatype}
on:change={(e) => {
column.datatype = e.detail.value
if (datatypeHasLength(column.datatype)) {
column.datatype_length = datatypeDefaultLength(column.datatype)
} else {
column.datatype_length = undefined
}
}}
items={columnTypes}
clearable={false}
/>
</Cell>
<Cell last class="flex items-center mt-1.5">
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.primaryKey} />
<Popover class="ml-8" contentClasses="py-3 px-5 flex flex-col gap-6">
{#snippet trigger()}
<Settings size={18} />
{/snippet}
{#snippet content()}
{#if datatypeHasLength(column.datatype)}
<label class="text-xs">
Length
<input type="number" placeholder="0" bind:value={column.datatype_length} />
</label>
{/if}
<label class="text-xs">
<span class="flex gap-1 mb-1">
Default value
<Tooltip>
<Info size={14} />
{#snippet text()}
Surround your expressions with curly brackets:
<code>
{'{NOW()}'}
</code>.
<br />
By default, it will be parsed as a literal
{/snippet}
</Tooltip>
</span>
<input type="text" placeholder="NULL" bind:value={column.defaultValue} />
</label>
{#if !column.primaryKey}
<label class="flex gap-2 items-center text-xs">
<input type="checkbox" class="!w-4 !h-4" bind:checked={column.not_null} />
Not nullable
</label>
{/if}
{/snippet}
</Popover>
<Button
color="light"
startIcon={{ icon: X }}
wrapperClasses="w-fit ml-2"
btnClasses="p-0"
on:click={() => values.columns.splice(i, 1)}
/>
</Cell>
</tr>
{/each}
<tr class="w-full">
<td colspan={99} class="p-1">
<Button
wrapperClasses="mx-auto"
startIcon={{ icon: Plus }}
color="light"
on:click={() => addColumn({ name: '' })}
>
Add
</Button>
</td>
</tr>
</tbody>
</DataTable>
</div>
<div class="flex flex-col">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label>Foreign Keys</label>
<DataTable>
<Head>
<tr>
<Cell head first>Table</Cell>
<Cell head last>Columns</Cell>
</tr>
</Head>
<tbody class="divide-y bg-surface">
{#each values.foreignKeys as foreignKey, foreignKeyIndex}
{@const fkErrors = errors?.foreignKeys?.[foreignKeyIndex]}
<tr>
<Cell first class="flex">
<Select
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
class={twMerge('!w-48', fkErrors?.emptyTarget ? 'border !border-red-600/60' : '')}
placeholder=""
value={foreignKey.targetTable}
on:change={(e) => (foreignKey.targetTable = e.detail.value)}
items={getFlatTableNamesFromSchema(dbSchema).map((o) => ({
value: o,
label:
(currentSchema && o.startsWith(currentSchema)) ||
!dbSupportsSchemas(resourceType)
? o.split('.')[1]
: o
}))}
clearable={false}
/>
</Cell>
<Cell>
<div class="flex flex-col gap-2">
{#each foreignKey.columns as column, columnIndex}
<div class="flex">
<div class="flex items-center gap-1 w-60">
<!-- Div wrappers with absolute select are to prevent the Select content
from overflowing -->
<div class="grow h-[2rem] relative">
<Select
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
class={twMerge(
'!absolute inset-0',
fkErrors?.nonExistingSourceColumns.includes(column.sourceColumn)
? 'border !border-red-600/60'
: ''
)}
placeholder=""
value={column.sourceColumn}
on:change={(e) => (column.sourceColumn = e.detail.value)}
items={values.columns.map((c) => c.name)}
clearable={false}
/>
</div>
<ArrowRight size={16} class="h-fit shrink-0" />
<div class="grow h-[2rem] relative">
<Select
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
class={twMerge(
'!absolute inset-0',
fkErrors?.nonExistingTargetColumns.includes(column.targetColumn)
? 'border !border-red-600/60'
: ''
)}
placeholder=""
value={column.targetColumn}
on:change={(e) => (column.targetColumn = e.detail.value)}
items={Object.keys(
dbSchema?.schema?.[foreignKey.targetTable?.split('.')?.[0] ?? '']?.[
foreignKey.targetTable?.split('.')[1]
] ?? {}
)}
clearable={false}
/>
</div>
</div>
<div class="ml-auto flex">
{#if columnIndex === 0}
<Popover contentClasses="py-3 px-5 w-52 flex flex-col gap-6">
{#snippet trigger()}
<Settings size={18} />
{/snippet}
{#snippet content()}
<span>
ON DELETE <select bind:value={foreignKey.onDelete}>
<option value="NO ACTION" selected>NO ACTION</option>
<option value="CASCADE" selected>CASCADE</option>
<option value="SET NULL" selected>SET NULL</option>
</select>
</span>
<span>
ON UPDATE <select bind:value={foreignKey.onUpdate}>
<option value="NO ACTION" selected>NO ACTION</option>
<option value="CASCADE" selected>CASCADE</option>
<option value="SET NULL" selected>SET NULL</option>
</select>
</span>
{/snippet}
</Popover>
{/if}
<Button
color="light"
startIcon={{ icon: X }}
wrapperClasses="w-fit ml-2"
btnClasses="p-0"
on:click={foreignKey.columns.length > 1
? () => foreignKey.columns.splice(columnIndex, 1)
: () => values.foreignKeys.splice(foreignKeyIndex, 1)}
/>
</div>
</div>
{/each}
<button
class="w-60 border-dashed dark:border-gray-600 border-2 rounded-md flex justify-center items-center py-1 gap-2 text-primary-500 font-normal"
onclick={() => foreignKey.columns.push({})}
>
<Plus class="h-fit" size={12} /> Add
</button>
</div></Cell
>
</tr>
{/each}
<tr class="w-full">
<td colspan={99} class="p-1">
<Button
wrapperClasses="mx-auto"
startIcon={{ icon: Plus }}
color="light"
on:click={() =>
values.foreignKeys.push({
columns: [{}],
onDelete: 'NO ACTION',
onUpdate: 'NO ACTION'
})}
>
Add
</Button>
</td>
</tr>
</tbody>
</DataTable>
</div>
</div>
<Button
disabled={!!errors}
on:click={() =>
(askingForConfirmation = {
onConfirm: async () => {
try {
askingForConfirmation && (askingForConfirmation.loading = true)
await onConfirm(values)
sendUserToast(values.name + ' created!')
} catch (e) {
let msg: string | undefined = (e as Error)?.message
if (typeof msg !== 'string') msg = e ? JSON.stringify(e) : 'An error occurred'
sendUserToast(msg, true)
}
askingForConfirmation = undefined
},
title: 'Confirm running the following:',
confirmationText: 'Create ' + values.name,
open: true,
...(previewSql && { codeContent: previewSql(values) })
})}>Create table</Button
>
</div>
<ConfirmationModal
{...askingForConfirmation ?? { confirmationText: '', title: '' }}
on:canceled={() => (askingForConfirmation = undefined)}
on:confirmed={askingForConfirmation?.onConfirm ?? (() => {})}
>
{#if askingForConfirmation?.codeContent}
<div class="bg-surface-secondary border border-surface-selected rounded-md p-2 relative">
<code class="whitespace-pre-wrap">
{askingForConfirmation.codeContent}
</code>
<Button
on:click={() => copyToClipboard(askingForConfirmation?.codeContent)}
size="xs"
startIcon={{ icon: ClipboardCopy }}
color="none"
wrapperClasses="absolute z-10 top-0 right-0"
></Button>
</div>
{/if}
</ConfirmationModal>

View File

@@ -3,7 +3,7 @@
import { base } from '$lib/base'
import JobStatus from '$lib/components/JobStatus.svelte'
import { displayDate, truncateRev } from '$lib/utils'
import ScheduleEditor from './ScheduleEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import TimeAgo from './TimeAgo.svelte'
import { workspaceStore } from '$lib/stores'
import Tooltip from './Tooltip.svelte'

View File

@@ -6,7 +6,7 @@
import { ListOrdered, PenBox } from 'lucide-svelte'
import JobArgs from './JobArgs.svelte'
import Tooltip from './Tooltip.svelte'
import ScheduleEditor from './ScheduleEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import { type Schedule } from '$lib/gen'
export let schedule: any

View File

@@ -8,10 +8,11 @@
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
import { Button } from './common'
import DBSchemaExplorer from './DBSchemaExplorer.svelte'
import DBManagerDrawerButton from './DBManagerDrawerButton.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import { Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import { isDbType } from './apps/components/display/dbtable/utils'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
const dispatch = createEventDispatcher()
@@ -221,7 +222,7 @@
iconOnly
/>
</div>
{#if showSchemaExplorer}
<DBSchemaExplorer {resourceType} resourcePath={value} />
{#if showSchemaExplorer && isDbType(resourceType) && value}
<DBManagerDrawerButton {resourceType} resourcePath={value} />
{/if}
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import ScheduleEditor from './ScheduleEditor.svelte'
import { Button } from './common'
import { workspaceStore } from '$lib/stores'
import { ScheduleService, type Schedule } from '$lib/gen'
@@ -15,6 +14,7 @@
import { loadSchedules, saveSchedule } from './flows/scheduleUtils'
import { type Writable, writable } from 'svelte/store'
import Description from '$lib/components/Description.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
export let schema: any
export let isFlow: boolean

View File

@@ -0,0 +1,94 @@
<script lang="ts">
import { sendUserToast } from '$lib/toast'
import { createGrid, type GridApi } from 'ag-grid-community'
import DarkModeObserver from './DarkModeObserver.svelte'
type Props = {
data: Record<string, any>[]
class?: string
}
let { data, class: className }: Props = $props()
let api: GridApi<any> | undefined = $state()
let eGui: HTMLDivElement | undefined = $state()
$effect(() => eGui && mountGrid())
function mountGrid() {
if (eGui) {
createGrid(eGui, {
onGridReady: (e) => {
api = e.api
},
autoSizeStrategy: { type: 'fitCellContents' },
...updateGridOptions
})
}
}
let columnDefs = $derived.by(() => {
if (!data.length) return []
const defs: { field: string }[] = []
for (const row of data) {
for (const key in row) {
if (!defs.some((def) => def.field === key)) {
defs.push({ field: key })
}
}
}
return defs
})
let updateGridOptions = $derived({
rowData: data.map((row) => {
const newRow: Record<string, any> = {}
for (const key in row) {
newRow[key] = typeof row[key] === 'string' ? row[key].trim() : JSON.stringify(row[key])
}
return newRow
}),
columnDefs
})
$effect(() => {
api?.updateGridOptions(updateGridOptions)
})
let darkMode = $state(false)
let [clientHeight, clientWidth] = $state([0, 0])
</script>
<DarkModeObserver bind:darkMode />
<div
class={'flex flex-col h-full component-wrapper divide-y wm-aggrid-container ' +
(api ? '' : 'opacity-0 ') +
className}
bind:clientHeight
bind:clientWidth
>
<div
style:height="{clientHeight}px"
style:width="{clientWidth}px"
class="ag-theme-alpine"
class:ag-theme-alpine-dark={darkMode}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
bind:this={eGui}
style:height="100%"
onkeydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
const selectedCell = api?.getFocusedCell()
if (selectedCell) {
const rowIndex = selectedCell.rowIndex
const colId = selectedCell.column?.getId()
const rowNode = api?.getDisplayedRowAtIndex(rowIndex)
const selectedValue = rowNode?.data?.[colId]
navigator.clipboard.writeText(selectedValue)
sendUserToast('Copied cell value to clipboard', false)
}
}
}}
>
</div>
</div>
</div>

View File

@@ -0,0 +1,178 @@
<script module lang="ts">
// code may be composed of many sql statements separated by ';'
// this function splits them while taking into account that ';' may not
// be the end of a statement (string or escaped)
function splitSqlStatements(code: string) {
const statements: string[] = []
let currentStatement = ''
let inSingleQuote = false
let inDoubleQuote = false
let inBacktick = false
for (let i = 0; i < code.length; i++) {
const char = code[i]
const prevChar = i > 0 ? code[i - 1] : null
if (char === "'" && !inDoubleQuote && !inBacktick && prevChar !== '\\') {
inSingleQuote = !inSingleQuote
} else if (char === '"' && !inSingleQuote && !inBacktick && prevChar !== '\\') {
inDoubleQuote = !inDoubleQuote
} else if (char === '`' && !inSingleQuote && !inDoubleQuote && prevChar !== '\\') {
inBacktick = !inBacktick
}
if (char === ';' && !inSingleQuote && !inDoubleQuote && !inBacktick) {
statements.push(currentStatement.trim())
currentStatement = ''
} else {
currentStatement += char
}
}
if (currentStatement.trim()) {
statements.push(currentStatement.trim())
}
return statements
}
</script>
<script lang="ts">
import { CornerDownLeft } from 'lucide-svelte'
import Button from './common/button/Button.svelte'
import Editor from './Editor.svelte'
import { runPreviewJobAndPollResult } from './jobs/utils'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
import { getLanguageByResourceType } from './apps/components/display/dbtable/utils'
import StepHistory, { type StepHistoryData } from './flows/propPicker/StepHistory.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { _ } from 'ag-grid-community'
type Props = {
resourceType: string
resourcePath: string
onData: (data: Record<string, any>[]) => void
placeholderTableName?: string
}
let { resourcePath, resourceType, onData, placeholderTableName }: Props = $props()
const DEFAULT_SQL = 'SELECT * FROM _'
let code = $state(DEFAULT_SQL)
$effect(() => {
const _code = untrack(() => code)
if (placeholderTableName && _code === DEFAULT_SQL) {
code = _code.replace('_', placeholderTableName ?? 'table')
}
})
let isRunning = $state(false)
let runHistory: (StepHistoryData & { code: string; result: Record<string, any>[] })[] = $state([])
async function run({ doPostgresRowToJsonFix }: { doPostgresRowToJsonFix?: boolean } = {}) {
if (isRunning || !$workspaceStore) return
isRunning = true
try {
const statements = splitSqlStatements(code)
// Transform all to JSON in case of select. This fixes the issue of
// custom postgres enum type failing to convert to a rust type in the backend.
// We don't always put the fix by default for row ordering concerns
let transformedCode = code
if (doPostgresRowToJsonFix) {
transformedCode = statements
.map((statement) => {
if (statement.trim().toUpperCase().startsWith('SELECT')) {
return `SELECT row_to_json(__t__) FROM (${statement}) __t__`
}
return statement
})
.join(';')
}
let { job, result } = (await runPreviewJobAndPollResult(
{
workspace: $workspaceStore,
requestBody: {
language: getLanguageByResourceType(resourceType),
content: transformedCode,
args: {
database: '$res:' + resourcePath
}
}
},
{ withJobData: true }
)) as any
if (statements.length > 1) {
result = result[result.length - 1]
}
if (!Array.isArray(result)) {
sendUserToast('Query result is not an array', true)
return
}
if (doPostgresRowToJsonFix) {
result = result.map((row: any) => row['row_to_json'])
}
if (statements[statements.length - 1].toUpperCase().trim().startsWith('SELECT')) {
runHistory.push({
created_at: new Date().toISOString(),
created_by: '',
id: job.id,
success: true,
code,
result
})
onData(result)
}
if (doPostgresRowToJsonFix)
sendUserToast('Query failed but recovered with the row_to_json fix')
else sendUserToast('Query executed')
} catch (e) {
console.error(e)
if (resourceType === 'postgresql' && !doPostgresRowToJsonFix) {
console.error('Error running query, trying with row_to_json fix')
isRunning = false
return await run({ doPostgresRowToJsonFix: true })
}
sendUserToast('Error running query: ' + (e.message ?? e.error.message), true)
} finally {
isRunning = false
}
}
let editor = $state<Editor | null>(null)
</script>
<Splitpanes>
<Pane class="relative">
<Editor
bind:this={editor}
bind:code
lang="sql"
scriptLang="mysql"
class="w-full h-full"
cmdEnterAction={run}
/>
<Button
wrapperClasses="absolute z-10 bottom-2 right-6"
color={isRunning ? 'red' : undefined}
variant="border"
shortCut={{ Icon: CornerDownLeft }}
on:click={() => run()}
>
{isRunning ? 'Running...' : 'Run'}
</Button>
</Pane>
<Pane size={24} minSize={16}>
<StepHistory
staticInputs={runHistory}
on:select={(e) => {
const data = e.detail as (typeof runHistory)[number]
editor?.setCode(data.code)
onData(data.result)
}}
/>
</Pane>
</Splitpanes>

View File

@@ -44,9 +44,10 @@
<Select
{disabled}
placeholder={nullTag ? `default: ${nullTag}` : 'lang default'}
containerStyles={'--height: 30px !important; --input-padding: 0px !important;' + darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
containerStyles={'--height: 30px !important; --input-padding: 0px !important; ' +
(darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles)}
{items}
value={tag ? { value: tag, label: tag } : undefined}
on:clear={() => {

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { Drawer, DrawerContent, Button } from '$lib/components/common'
import { CornerDownLeft, Plus } from 'lucide-svelte'
import InsertRow from './dbtable/InsertRow.svelte'
import type { ColumnDef, DbType } from './dbtable/utils'
type Props = {
columnDefs: ColumnDef[]
dbType: DbType
onInsert: (args: Record<string, any>) => void | Promise<void>
}
let { columnDefs, dbType, onInsert }: Props = $props()
let args: Record<string, any> = $state({})
let insertDrawer: Drawer | undefined = $state()
let isInsertable = $state(false)
const onConfirm = async () => {
await onInsert(args)
insertDrawer?.closeDrawer()
args = {}
}
</script>
<svelte:window
on:keydown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
onConfirm()
}
}}
/>
<Button
startIcon={{ icon: Plus }}
variant="border"
size="xs2"
on:click={() => {
args = {}
insertDrawer?.openDrawer()
}}
>
Insert
</Button>
<Drawer bind:this={insertDrawer} size="800px">
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
<svelte:fragment slot="actions">
<Button
color="dark"
size="xs"
on:click={onConfirm}
disabled={!isInsertable}
shortCut={{ Icon: CornerDownLeft }}
>
Insert
</Button>
</svelte:fragment>
<InsertRow bind:args bind:isInsertable {columnDefs} {dbType} />
</DrawerContent>
</Drawer>

View File

@@ -21,11 +21,7 @@
import { getContext, tick } from 'svelte'
import UpdateCell from './UpdateCell.svelte'
import { workspaceStore, type DBSchemas } from '$lib/stores'
import Button from '$lib/components/common/button/Button.svelte'
import { Plus } from 'lucide-svelte'
import { Drawer, DrawerContent } from '$lib/components/common'
import InsertRow from './InsertRow.svelte'
import Portal from '$lib/components/Portal.svelte'
import { Drawer } from '$lib/components/common'
import { sendUserToast } from '$lib/toast'
import type { AppInput, StaticInput } from '$lib/components/apps/inputType'
@@ -41,6 +37,7 @@
import { CancelablePromise } from '$lib/gen'
import RefreshButton from '$lib/components/apps/components/helpers/RefreshButton.svelte'
import RunnableWrapper from '../../helpers/RunnableWrapper.svelte'
import InsertRowDrawerButton from '../InsertRowDrawerButton.svelte'
export let id: string
export let configuration: RichConfigurations
@@ -173,8 +170,6 @@
)
}
let args: Record<string, any> = {}
let outputs = initOutput($worldStore, id, {
selectedRowIndex: 0,
selectedRow: {},
@@ -189,7 +184,7 @@
let lastResource: string | undefined = undefined
function updateOneOfConfiguration<T, U extends string, V>(
function updateOneOfConfiguration<U extends string, V>(
oneOfConfiguration: OneOfConfiguration,
resolvedConfig: {
configuration: Record<U, V>
@@ -249,7 +244,7 @@
resolvedConfig.type.configuration[resolvedConfig?.type?.selected].resource.split(':')[1],
$workspaceStore,
dbSchemas,
(message: string) => {}
() => {}
)
updateOneOfConfiguration(
@@ -263,7 +258,7 @@
resolvedConfig?.type?.selected,
resource.split(':')[1],
$workspaceStore!
)
)
: [],
loading: false
}
@@ -479,8 +474,6 @@
}, 1500)
}
let isInsertable: boolean = false
$: $worldStore && render && connectToComponents()
function connectToComponents() {
@@ -521,7 +514,7 @@
}
}
async function insert() {
async function insert(args: object) {
try {
const selected = resolvedConfig.type.selected
@@ -539,8 +532,6 @@
} catch (e) {
sendUserToast(e.message, true)
}
args = {}
}
let runnableComponent: RunnableComponent
@@ -651,17 +642,11 @@
<div class="flex flex-row gap-2">
<RefreshButton {id} {loading} />
{#if hideInsert !== true}
<Button
startIcon={{ icon: Plus }}
color="dark"
size="xs2"
on:click={() => {
args = {}
insertDrawer?.openDrawer()
}}
>
Insert
</Button>
<InsertRowDrawerButton
columnDefs={resolvedConfig.columnDefs}
dbType={resolvedConfig.type.selected}
onInsert={(args) => insert(args)}
/>
{/if}
</div>
</div>
@@ -696,7 +681,7 @@
</div>
</RunnableWrapper>
<Portal name="db-explorer">
<!-- <Portal name="db-explorer">
<Drawer bind:this={insertDrawer} size="800px">
<DrawerContent title="Insert row" on:close={insertDrawer.closeDrawer}>
<svelte:fragment slot="actions">
@@ -710,4 +695,4 @@
/>
</DrawerContent>
</Drawer>
</Portal>
</Portal> -->

View File

@@ -2,7 +2,7 @@ import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import { buildParameters, type DbType } from '../utils'
import { getLanguageByResourceType, type ColumnDef, buildVisibleFieldList } from '../utils'
function makeCountQuery(
export function makeCountQuery(
dbType: DbType,
table: string,
whereClause: string | undefined = undefined,

View File

@@ -0,0 +1,93 @@
import { dbSupportsSchemas, type DbType } from '../utils'
export type CreateTableValues = {
name: string
columns: CreateTableValuesColumn[]
foreignKeys: {
targetTable?: string
columns: {
sourceColumn?: string
targetColumn?: string
}[]
onDelete: 'CASCADE' | 'SET NULL' | 'NO ACTION'
onUpdate: 'CASCADE' | 'SET NULL' | 'NO ACTION'
}[]
}
type CreateTableValuesColumn = {
name: string
datatype: string
primaryKey?: boolean
defaultValue?: string
not_null?: boolean
datatype_length?: number // e.g varchar(255)
}
export function makeCreateTableQuery(
values: CreateTableValues,
resourceType: DbType,
schema?: string
) {
const pkCount = values.columns.reduce((p, c) => p + (c.primaryKey ? 1 : 0), 0)
function transformColumn(c: CreateTableValuesColumn): string {
const datatype = c.datatype_length ? `${c.datatype}(${c.datatype_length})` : c.datatype
const defValue = c.defaultValue && formatDefaultValue(c.defaultValue, datatype, resourceType)
let str = ` ${c.name} ${datatype}`
if (c.not_null) str += ' NOT NULL'
if (defValue) str += ` DEFAULT ${defValue}`
if (pkCount === 1 && c.primaryKey) str += ' PRIMARY KEY'
return str
}
function transformFk(fk: CreateTableValues['foreignKeys'][number]): string {
const sourceColumns = fk.columns.map((c) => c.sourceColumn).filter(Boolean)
const targetColumns = fk.columns.map((c) => c.targetColumn).filter(Boolean)
const targetTable =
useSchema || !fk.targetTable?.includes('.')
? fk.targetTable
: fk.targetTable?.split('.').pop()
let l = ` FOREIGN KEY (${sourceColumns.join(', ')}) REFERENCES ${targetTable} (${targetColumns.join(
', '
)})`
if (fk.onDelete !== 'NO ACTION') l += ` ON DELETE ${fk.onDelete}`
if (fk.onUpdate !== 'NO ACTION') l += ` ON UPDATE ${fk.onUpdate}`
return l
}
const useSchema = dbSupportsSchemas(resourceType)
const lines = values.columns.map(transformColumn)
lines.push(...values.foreignKeys.map(transformFk))
if (pkCount > 1) {
const pks = values.columns.filter((c) => c.primaryKey)
lines.push(` PRIMARY KEY (${pks.map((c) => c.name).join(', ')})`)
}
return `CREATE TABLE ${useSchema && schema ? schema.trim() + '.' : ''}${values.name.trim()} (
${lines.join(',\n')}
);`
}
function formatDefaultValue(str: string, datatype: string, resourceType: DbType): string {
if (!str) return ''
if (str.startsWith('{') && str.endsWith('}')) {
return str.slice(1, str.length - 1)
}
if (resourceType === 'postgresql') {
return `CAST('${str}' AS ${datatype})`
}
return `'${str}'`
}
export function datatypeDefaultLength(datatype: string): number {
datatype = datatype.toLowerCase()
if (datatype == 'bit') return 1
if (['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary'].includes(datatype)) {
return 255
} else {
return 10
}
}

View File

@@ -1,7 +1,7 @@
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils'
function deleteWithAllValues(table: string, columns: ColumnDef[], dbType: DbType) {
export function makeDeleteQuery(table: string, columns: ColumnDef[], dbType: DbType) {
let query = buildParameters(
dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns,
dbType
@@ -71,7 +71,7 @@ export function getDeleteInput(
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: deleteWithAllValues(table, columns, dbType),
content: makeDeleteQuery(table, columns, dbType),
language: getLanguageByResourceType(dbType),
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',

View File

@@ -0,0 +1,6 @@
import type { DbType } from '../utils'
export function makeDeleteTableQuery(tableKey: string, resourceType: DbType): string {
// same for all sql dbs
return `DROP TABLE ${tableKey};`
}

View File

@@ -79,7 +79,7 @@ function makeSnowflakeSelectQuery(
return query
}
function makeSelectQuery(
export function makeSelectQuery(
table: string,
columnDefs: ColumnDef[],
whereClause: string | undefined,
@@ -155,6 +155,9 @@ CASE WHEN :order_by = '${column.field}' AND :is_desc IS true THEN \`${column.fie
case 'ms_sql_server':
// MSSQL uses CONCAT for string concatenation and supports OFFSET FETCH for pagination
// Note: MSSQL does not have a built-in ILIKE function, so we use LIKE with a case-insensitive collation if needed
//
// Note 2: CONCAT in mssql requires 2 to 254 arguments. But we can't change this query without breaking
// existing policies
const orderBy = columnDefs
.map((column) => {
return `

View File

@@ -1,18 +1,15 @@
import type { AppInput, RunnableByName } from '$lib/components/apps/inputType'
import { getLanguageByResourceType, type ColumnDef, buildParameters, type DbType } from '../utils'
function updateWithAllValues(
export function makeUpdateQuery(
table: string,
column: ColumnDef,
columns: ColumnDef[],
column: { datatype: string; field: string },
columns: { datatype: string; field: string }[],
dbType: DbType
) {
let query = buildParameters(
[
{
field: 'value_to_update',
datatype: column.datatype
},
{ field: 'value_to_update', datatype: column.datatype },
...(dbType === 'snowflake' ? columns.flatMap((c) => [c, c]) : columns)
],
dbType
@@ -85,7 +82,7 @@ export function getUpdateInput(
name: 'AppDbExplorer',
type: 'runnableByName',
inlineScript: {
content: updateWithAllValues(table, column, columns, dbType),
content: makeUpdateQuery(table, column, columns, dbType),
language: getLanguageByResourceType(dbType),
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',

View File

@@ -8,6 +8,7 @@ import {
} from 'graphql'
import { tryEvery } from '$lib/utils'
import { stringifySchema } from '$lib/components/copilot/lib'
import { runPreviewJobAndPollResult } from '$lib/components/jobs/utils'
export enum ColumnIdentity {
ByDefault = 'By Default',
@@ -61,15 +62,99 @@ export async function loadTableMetaData(
if (!resource || !table || !workspace) {
return undefined
}
const job = await JobService.runScriptPreview({
workspace: workspace,
requestBody: {
language: getLanguageByResourceType(resourceType),
content: await makeLoadTableMetaDataQuery(resource, workspace, table, resourceType),
args: {
database: resource
}
}
})
let code: string = ''
const maxRetries = 8
let attempts = 0
while (attempts < maxRetries) {
try {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempts || 0.6)))
const testResult = (await JobService.getCompletedJob({
workspace: workspace,
id: job
})) as any
if (testResult.success) {
attempts = maxRetries
if (resourceType === 'ms_sql_server') {
return testResult.result[0].map(lowercaseKeys)
} else {
return testResult.result.map(lowercaseKeys)
}
} else {
attempts++
}
} catch (error) {
attempts++
}
}
console.error('Failed to load table metadata after maximum retries.')
return undefined
}
export async function loadAllTablesMetaData(
resource: string,
workspace: string | undefined,
resourceType: string
): Promise<Record<string, TableMetadata> | undefined> {
if (!resource || !workspace) {
return undefined
}
try {
let result = (await runPreviewJobAndPollResult({
workspace: workspace,
requestBody: {
language: getLanguageByResourceType(resourceType),
content: await makeLoadTableMetaDataQuery(resource, workspace, undefined, resourceType),
args: {
database: resource
}
}
})) as ({ table_name: string; schema_name?: string } & object)[]
if (resourceType === 'ms_sql_server') {
result = (result as any)[0]
}
const map: Record<string, TableMetadata> = {}
for (const _col of result) {
const col = lowercaseKeys(_col)
const tableKey = col.schema_name ? `${col.schema_name}.${col.table_name}` : col.table_name
if (!(tableKey in map)) {
map[tableKey] = []
}
map[tableKey].push(col)
}
return map
} catch (e) {
throw new Error('Error loading all tables metadata: ' + e)
}
}
async function makeLoadTableMetaDataQuery(
resource: string,
workspace: string,
table: string | undefined,
resourceType: string
): Promise<string> {
if (resourceType === 'mysql') {
const resourceObj = (await ResourceService.getResourceValue({
workspace,
path: resource.split(':')[1]
})) as any
code = `
return `
SELECT
COLUMN_NAME as field,
COLUMN_TYPE as DataType,
@@ -77,19 +162,31 @@ export async function loadTableMetaData(
CASE WHEN COLUMN_KEY = 'PRI' THEN 'YES' ELSE 'NO' END as IsPrimaryKey,
CASE WHEN EXTRA like '%auto_increment%' THEN 'YES' ELSE 'NO' END as IsIdentity,
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum
CASE WHEN DATA_TYPE = 'enum' THEN true ELSE false END as IsEnum${
table
? ''
: `,
TABLE_NAME as table_name`
}
FROM
INFORMATION_SCHEMA.COLUMNS
WHERE
INFORMATION_SCHEMA.COLUMNS${
table
? `
WHERE
TABLE_NAME = '${table.split('.').reverse()[0]}' AND TABLE_SCHEMA = '${
table.split('.').reverse()[1] ?? resourceObj?.database ?? ''
}'
ORDER BY
table.split('.').reverse()[1] ?? resourceObj?.database ?? ''
}'`
: `
WHERE
TABLE_SCHEMA NOT IN ('mysql', 'performance_schema', 'information_schema', 'sys')`
}
ORDER BY
TABLE_NAME,
ORDINAL_POSITION;
`
} else if (resourceType === 'postgresql') {
code = `
SELECT
return `
SELECT
a.attname as field,
pg_catalog.format_type(a.atttypid, a.atttypmod) as DataType,
(SELECT substring(pg_catalog.pg_get_expr(d.adbin, d.adrelid, true) for 128)
@@ -110,17 +207,32 @@ export async function loadTableMetaData(
END as IsNullable,
(SELECT true
FROM pg_catalog.pg_enum e
WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum
FROM pg_catalog.pg_attribute a
WHERE e.enumtypid = a.atttypid FETCH FIRST ROW ONLY) as IsEnum${
table
? ''
: `,
ns.nspname AS schema_name,
c.relname AS table_name`
}
FROM pg_catalog.pg_attribute a${
table
? `
WHERE a.attrelid = (SELECT c.oid FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid WHERE relname = '${
table.split('.').reverse()[0]
}' AND ns.nspname = '${table.split('.').reverse()[1] ?? 'public'}')
AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum;
`
: `
JOIN pg_catalog.pg_class c ON a.attrelid = c.oid
JOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid
WHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped
AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'`
}
ORDER BY ${table ? 'a.attnum' : 'ns.nspname, c.relname, a.attnum'};
`
} else if (resourceType === 'ms_sql_server') {
code = `
return `
SELECT
COLUMN_NAME as field,
DATA_TYPE as DataType,
@@ -128,32 +240,52 @@ export async function loadTableMetaData(
CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 'By Default' ELSE 'No' END as IsIdentity,
CASE WHEN COLUMNPROPERTY(OBJECT_ID(TABLE_NAME), COLUMN_NAME, 'IsIdentity') = 1 THEN 1 ELSE 0 END as IsPrimaryKey, -- This line still needs correction for primary key identification
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${
table
? ''
: `,
TABLE_NAME as table_name`
}
FROM
INFORMATION_SCHEMA.COLUMNS
INFORMATION_SCHEMA.COLUMNS${
table
? `
WHERE
TABLE_NAME = '${table}'
TABLE_NAME = '${table}'`
: ''
}
ORDER BY
ORDINAL_POSITION;
`
} else if (resourceType === 'snowflake' || resourceType === 'snowflake_oauth') {
code = `
return `
select COLUMN_NAME as field,
DATA_TYPE as DataType,
COLUMN_DEFAULT as DefaultValue,
CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 'By Default' ELSE 'No' END as IsIdentity,
CASE WHEN COLUMN_DEFAULT like 'AUTOINCREMENT%' THEN 1 ELSE 0 END as IsPrimaryKey,
CASE WHEN IS_NULLABLE = 'YES' THEN 'YES' ELSE 'NO' END as IsNullable,
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum
from information_schema.columns
CASE WHEN DATA_TYPE = 'enum' THEN 1 ELSE 0 END as IsEnum${
table
? ''
: `,
table_name as table_name,
table_schema as schema_name`
}
from information_schema.columns${
table
? `
where table_name = '${table.split('.').reverse()[0]}' and table_schema = '${
table.split('.').reverse()[1] ?? 'PUBLIC'
}'
table.split('.').reverse()[1] ?? 'PUBLIC'
}'`
: "\nwhere table_schema <> 'INFORMATION_SCHEMA'\n"
}
order by ORDINAL_POSITION;
`
} else if (resourceType === 'bigquery') {
code = `SELECT
// TODO: find a solution for this (query uses hardcoded dataset name)
if (!table) throw new Error('Table name is required for BigQuery')
return `SELECT
c.COLUMN_NAME as field,
DATA_TYPE as DataType,
CASE WHEN COLUMN_DEFAULT = 'NULL' THEN '' ELSE COLUMN_DEFAULT END as DefaultValue,
@@ -164,58 +296,14 @@ ORDER BY
FROM
${table.split('.')[0]}.INFORMATION_SCHEMA.COLUMNS c
LEFT JOIN
test_dataset.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
${table.split('.')[0]}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE p
on c.table_name = p.table_name AND c.column_name = p.COLUMN_NAME
WHERE
c.TABLE_NAME = "${table.split('.')[1]}"
c.TABLE_NAME = '${table.split('.')[1]}'
order by c.ORDINAL_POSITION;`
} else {
throw new Error('Unsupported database type:' + resourceType)
}
const maxRetries = 3
let attempts = 0
while (attempts < maxRetries) {
try {
const job = await JobService.runScriptPreview({
workspace: workspace,
requestBody: {
language: getLanguageByResourceType(resourceType),
content: code,
args: {
database: resource
}
}
})
await new Promise((resolve) => setTimeout(resolve, 3000))
const testResult = (await JobService.getCompletedJob({
workspace: workspace,
id: job
})) as any
if (testResult.success) {
attempts = maxRetries
if (resourceType === 'ms_sql_server') {
return lowercaseKeys(testResult.result[0])
} else {
return lowercaseKeys(testResult.result)
}
} else {
attempts++
}
} catch (error) {
attempts++
}
// Exponential back-off
await new Promise((resolve) => setTimeout(resolve, 2000 * attempts))
}
console.error('Failed to load table metadata after maximum retries.')
return undefined
}
export function resourceTypeToLang(rt: string) {
@@ -226,14 +314,12 @@ export function resourceTypeToLang(rt: string) {
}
}
function lowercaseKeys(arr: Array<Record<string, any>>): Array<any> {
return arr.map((obj) => {
const newObj = {}
Object.keys(obj).forEach((key) => {
newObj[key.toLowerCase()] = obj[key]
})
return newObj
function lowercaseKeys(obj: Record<string, any>): any {
const newObj = {}
Object.keys(obj).forEach((key) => {
newObj[key.toLowerCase()] = obj[key]
})
return newObj
}
const scripts: Record<
@@ -569,7 +655,9 @@ export function formatGraphqlSchema(schema: IntrospectionQuery): string {
return printSchema(buildClientSchema(schema))
}
export type DbType = 'mysql' | 'ms_sql_server' | 'postgresql' | 'snowflake' | 'bigquery'
export type DbType = (typeof dbTypes)[number]
export const dbTypes = ['mysql', 'ms_sql_server', 'postgresql', 'snowflake', 'bigquery'] as const
export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType)
export function buildVisibleFieldList(columnDefs: ColumnDef[], dbType: DbType) {
// Filter out hidden columns to avoid counting the wrong number of rows
@@ -714,3 +802,13 @@ export async function getTablesByResource(
return []
}
}
export function dbSupportsSchemas(dbType: DbType): boolean {
return dbType === 'postgresql' || dbType === 'snowflake' || dbType === 'bigquery'
}
export function datatypeHasLength(datatype: string): boolean {
datatype = datatype.toLowerCase()
const lengthDataTypes = ['varchar', 'char', 'nvarchar', 'nchar', 'varbinary', 'binary', 'bit']
return lengthDataTypes.some((type) => datatype === type)
}

View File

@@ -2,7 +2,12 @@
* Base class for embedding a svelte component within an AGGrid call.
* See: https://stackoverflow.com/a/72608215
*/
import type { ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
import type { ColDef, ColGroupDef, ICellRendererComp, ICellRendererParams } from 'ag-grid-community'
import { ColumnIdentity, type ColumnDef } from '../dbtable/utils'
import type { TableAction } from '$lib/components/apps/editor/component'
import { mount, unmount } from 'svelte'
import { Button } from '$lib/components/common'
import { Trash2 } from 'lucide-svelte'
/**
* Class for defining a cell renderer.
@@ -110,3 +115,139 @@ export function defaultCellRenderer(cellRendererType: string) {
return undefined
}
}
export function transformColumnDefs({
columnDefs,
actions,
customActionsHeader,
wrapActions,
tableActionsFactory,
onDelete,
onInvalidColumnDefs
}: {
columnDefs: ColumnDef[]
actions?: TableAction[]
customActionsHeader?: string
wrapActions?: boolean
tableActionsFactory?: ReturnType<typeof cellRendererFactory>
onDelete?: (values: object) => void
onInvalidColumnDefs?: (errors: string[]) => void
}): (ColDef<unknown, any> | ColGroupDef<unknown>)[] {
if (!columnDefs) {
return []
}
const { isValid, errors } = validateColumnDefs(columnDefs)
if (!isValid) {
onInvalidColumnDefs?.(errors)
return []
}
let r: any[] = columnDefs?.filter((x) => x && !x.ignored) ?? []
if (onDelete) {
r.push({
field: 'delete',
headerName: 'Delete',
cellRenderer: cellRendererFactory((c, p) => {
const btnComponent = mount(Button, {
target: c.eGui,
props: {
btnClasses: 'w-12',
wrapperClasses: 'flex justify-end items-center h-full',
color: 'light',
size: 'sm',
variant: 'contained',
iconOnly: true,
startIcon: { icon: Trash2 },
nonCaptureEvent: true
}
})
return {
destroy: () => {
unmount(btnComponent)
},
refresh(params) {
//
}
}
}),
cellRendererParams: {
onClick: (e) => {
onDelete?.(e)
}
},
lockPosition: 'right',
editable: false,
flex: 0,
width: 100
})
}
if (actions?.length) {
r.push({
headerName: customActionsHeader ?? 'Actions',
cellRenderer: tableActionsFactory,
autoHeight: true,
cellStyle: { textAlign: 'center' },
cellClass: 'grid-cell-centered',
lockPosition: 'right',
...(!wrapActions ? { minWidth: 130 * actions?.length } : {})
})
}
return r.map((fields) => {
let cr = defaultCellRenderer(fields.cellRendererType)
return {
...fields,
...(cr ? { cellRenderer: cr } : {})
}
})
}
export function validateColumnDefs(columnDefs: ColumnDef[]): {
isValid: boolean
errors: string[]
} {
let isValid = true
const errors: string[] = []
if (!Array.isArray(columnDefs)) {
return { isValid: false, errors: ['Column definitions must be an array.'] }
}
// Validate each column definition
columnDefs.forEach((colDef, index) => {
let noField = !colDef.field || typeof colDef.field !== 'string' || colDef.field.trim() === ''
if (
(colDef.isidentity === ColumnIdentity.ByDefault ||
colDef.isidentity === ColumnIdentity.Always) &&
colDef.hideInsert == undefined
) {
colDef.hideInsert = true
}
// Check if 'field' property exists and is a non-empty string
if (noField && !(colDef.children && Array.isArray(colDef.children))) {
isValid = false
errors.push(
`Column at index ${index} is missing a valid 'field' property nor having any children.`
)
}
if (colDef.children && Array.isArray(colDef.children)) {
const { isValid: isChildrenValid, errors: childrenErrors } = validateColumnDefs(
colDef.children
)
if (!isChildrenValid) {
isValid = false
errors.push(...childrenErrors.map((err) => `Error in children at index ${index}: ${err}`))
}
}
})
return { isValid, errors }
}

View File

@@ -6,12 +6,13 @@
export let noBg = false
export let small: boolean = false
export let Icon: any | undefined = undefined
const dispatch = createEventDispatcher()
</script>
<Button
on:click={() => dispatch('close')}
startIcon={{ icon: X }}
startIcon={{ icon: Icon ?? X }}
iconOnly
size="sm"
color="light"

View File

@@ -9,6 +9,7 @@
export let forceOverflowVisible = false
export let tooltip: string = ''
export let documentationLink: string | undefined = undefined
export let CloseIcon: any | undefined = undefined
export let fullScreen: boolean = true
</script>
@@ -16,7 +17,7 @@
<div class={classNames('flex flex-col divide-y', fullScreen ? 'h-screen max-h-screen' : 'h-full')}>
<div class="flex justify-between w-full items-center px-4 py-2 gap-2">
<div class="flex items-center gap-2 w-full truncate">
<CloseButton on:close />
<CloseButton on:close Icon={CloseIcon} />
<span class="font-semibold truncate text-primary !text-lg max-w-sm"
>{title ?? ''}

View File

@@ -3,7 +3,7 @@
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow, DraftService } from '$lib/gen'
@@ -204,7 +204,7 @@
disabled: archived,
hide: $userStore?.operator
}
]
]
: []),
{
displayName: 'Deployments',
@@ -258,7 +258,7 @@
disabled: !owner,
hide: $userStore?.operator
}
]
]
: []),
{
displayName: 'Delete',

View File

@@ -3,7 +3,7 @@
import { base } from '$lib/base'
import Dropdown from '$lib/components/DropdownV2.svelte'
import type MoveDrawer from '$lib/components/MoveDrawer.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import type ShareModal from '$lib/components/ShareModal.svelte'

View File

@@ -487,6 +487,31 @@ async function callTool(
}
}
async function processToolCall(
toolCall: ChatCompletionMessageToolCall,
messages: ChatCompletionMessageParam[],
lang: ScriptLang | 'bunnative'
) {
try {
const args = JSON.parse(toolCall.function.arguments)
let result = ''
try {
result = await callTool(toolCall.function.name, args, lang, get(workspaceStore) ?? '')
} catch (err) {
console.error(err)
result =
'Error while calling tool, MUST tell the user to check the browser console for more details, and then respond as much as possible to the original request'
}
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result
})
} catch (err) {
console.error(err)
}
}
export async function chatRequest(
messages: ChatCompletionMessageParam[],
abortController: AbortController,
@@ -554,23 +579,7 @@ export async function chatRequest(
tool_calls: toolCalls
})
for (const toolCall of toolCalls) {
try {
const args = JSON.parse(toolCall.function.arguments)
const result = await callTool(
toolCall.function.name,
args,
lang,
get(workspaceStore) ?? ''
)
messages.push({
role: 'tool',
tool_call_id: toolCall.id,
content: result
})
} catch (err) {
console.error(err)
throw new Error('Error while calling tool')
}
await processToolCall(toolCall, messages, lang)
}
} else {
break

View File

@@ -385,7 +385,7 @@ function getProviderAndCompletionConfig<K extends boolean>({
: {
model: modelProvider.model,
temperature: 0,
tools
...(tools && tools.length > 0 ? { tools } : {})
}),
max_tokens: getModelMaxTokens(modelProvider.model),
messages: processedMessages,

View File

@@ -0,0 +1,169 @@
import {
getLanguageByResourceType,
type ColumnDef,
type DbType
} from './apps/components/display/dbtable/utils'
import { makeSelectQuery } from './apps/components/display/dbtable/queries/select'
import { runPreviewJobAndPollResult } from './jobs/utils'
import { makeCountQuery } from './apps/components/display/dbtable/queries/count'
import { makeUpdateQuery } from './apps/components/display/dbtable/queries/update'
import { makeDeleteQuery } from './apps/components/display/dbtable/queries/delete'
import { makeInsertQuery } from './apps/components/display/dbtable/queries/insert'
import { Trash2 } from 'lucide-svelte'
import { makeDeleteTableQuery } from './apps/components/display/dbtable/queries/deleteTable'
export type IDbTableOps = {
resourcePath: string
resourceType: DbType
tableKey: string
colDefs: ColumnDef[]
getRows: (params: {
offset: number
limit: number
quicksearch: string
order_by: string
is_desc: boolean
}) => Promise<unknown[]>
getCount: (params: { quicksearch: string }) => Promise<number>
onUpdate?: (
row: { values: object },
colDef: { field: string; datatype: string },
newValue: string
) => Promise<void>
onDelete?: (row: { values: object }) => Promise<void>
onInsert?: (row: { values: object }) => Promise<void>
}
export function dbTableOpsWithPreviewScripts({
resourcePath,
resourceType,
tableKey,
colDefs,
workspace
}: {
resourcePath: string
resourceType: DbType
tableKey: string
colDefs: ColumnDef[]
workspace: string
}): IDbTableOps {
return {
resourcePath,
resourceType,
tableKey,
colDefs,
getCount: async ({ quicksearch }) => {
const countQuery = makeCountQuery(resourceType, tableKey, undefined, colDefs)
const result = await runPreviewJobAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, quicksearch },
language: getLanguageByResourceType(resourceType),
content: countQuery
}
})
const count = result?.[0].count as number
return count
},
getRows: async (params) => {
const query = makeSelectQuery(tableKey, colDefs, undefined, resourceType as DbType)
let items = (await runPreviewJobAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, ...params },
language: getLanguageByResourceType(resourceType),
content: query
}
})) as unknown[]
if (resourceType === 'ms_sql_server') items = items?.[0] as unknown[]
if (!items || !Array.isArray(items)) {
throw 'items is not an array'
}
return items
},
onUpdate: async ({ values }, colDef, newValue) => {
const updateQuery = makeUpdateQuery(tableKey, colDef, colDefs, resourceType)
await runPreviewJobAndPollResult({
workspace,
requestBody: {
args: {
database: '$res:' + resourcePath,
value_to_update: newValue,
...values
},
language: getLanguageByResourceType(resourceType),
content: updateQuery
}
})
},
onDelete: async ({ values }) => {
const deleteQuery = makeDeleteQuery(tableKey, colDefs, resourceType)
await runPreviewJobAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, ...values },
language: getLanguageByResourceType(resourceType),
content: deleteQuery
}
})
},
onInsert: async ({ values }) => {
const insertQuery = makeInsertQuery(tableKey, colDefs, resourceType)
runPreviewJobAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath, ...values },
language: getLanguageByResourceType(resourceType),
content: insertQuery
}
})
}
}
}
export type DbTableAction = {
action: () => void | Promise<void>
displayName: string
confirmTitle?: string
confirmBtnText?: string
icon?: any
successText?: string
}
export type DbTableActionFactory = (params: {
tableKey: string
refresh: () => void
}) => DbTableAction
export function dbDeleteTableActionWithPreviewScript({
workspace,
resourcePath,
resourceType
}: {
workspace: string
resourcePath: string
resourceType: DbType
}): DbTableActionFactory {
return ({ tableKey, refresh }) => ({
confirmTitle: `Are you sure you want to delete '${tableKey}' ? This action is irreversible`,
displayName: 'Delete',
confirmBtnText: `Delete permanently`,
icon: Trash2,
successText: `Table '${tableKey}' deleted successfully`,
action: async () => {
const deleteQuery = makeDeleteTableQuery(tableKey, resourceType)
await runPreviewJobAndPollResult({
workspace,
requestBody: {
args: { database: '$res:' + resourcePath },
language: getLanguageByResourceType(resourceType),
content: deleteQuery
}
})
refresh()
}
})
}

View File

@@ -1,3 +1,12 @@
<script module lang="ts">
export type StepHistoryData = {
id: string
created_at: string
created_by: string
success: boolean
}
</script>
<script lang="ts">
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { JobService } from '$lib/gen'
@@ -14,16 +23,20 @@
export let mockValue: any = undefined
export let mockEnabled: boolean = false
export let path: string = ''
export let staticInputs: undefined | StepHistoryData[] = undefined
export let noHistory: 'isLoop' | 'isInsideLoop' | undefined = undefined
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { pathStore } = getContext<FlowEditorContext>('FlowEditorContext') ?? {}
const dispatch = createEventDispatcher()
let infiniteList: InfiniteList | undefined = undefined
let loadInputsPageFn: ((page: number, perPage: number) => Promise<any>) | undefined = undefined
let loadInputsPageFn:
| ((page: number, perPage: number) => Promise<StepHistoryData[]>)
| undefined = undefined
function initLoadInputs() {
loadInputsPageFn = async (page: number, perPage: number) => {
if (staticInputs) return staticInputs
const previousJobs = await JobService.listCompletedJobs({
workspace: $workspaceStore!,
scriptPathExact: path === '' ? $pathStore + '/' + moduleId : path,
@@ -42,6 +55,8 @@
infiniteList?.setLoader(loadInputsPageFn)
}
$: staticInputs && infiniteList?.loadData('forceRefresh')
async function getJobResultAndLogs(jobId: string, noLogs: boolean) {
try {
const job = await JobService.getJob({

View File

@@ -0,0 +1,39 @@
import { JobService, type RunScriptPreviewData } from '$lib/gen'
export async function runPreviewJobAndPollResult(
data: RunScriptPreviewData,
{ maxRetries = 7, withJobData }: { maxRetries?: number; withJobData?: boolean } = {}
): Promise<unknown> {
const uuid = await JobService.runScriptPreview(data)
let attempts = 0
while (attempts < maxRetries) {
try {
await new Promise((resolve) => setTimeout(resolve, 500 * (attempts || 0.75)))
const job = await JobService.getCompletedJobResultMaybe({
id: uuid,
workspace: data.workspace
})
if (job.success) {
if (withJobData) {
return { job: { id: uuid }, result: job.result }
} else {
return job.result as any
}
} else if (job.completed) {
attempts = maxRetries
let errorMsg: string | undefined = (job?.result as any)?.error?.message
if (typeof errorMsg !== 'string') errorMsg = undefined
console.error('JOB FAILED', job.result)
throw new Error(errorMsg ?? 'Job failed')
}
} catch (e) {
if (attempts == maxRetries) {
throw e
}
attempts++
}
}
throw 'Could not get job result, should not get here'
}

View File

@@ -12,7 +12,7 @@
isJobSelectable
} from '$lib/utils'
import { Badge, Button } from '../common'
import ScheduleEditor from '../ScheduleEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import {

View File

@@ -5,7 +5,7 @@
import { UserService } from '$lib/gen'
import { Button } from '$lib/components/common'
import { Clipboard, Plus } from 'lucide-svelte'
import { workspaceStore, userWorkspaces } from '$lib/stores'
import { workspaceStore, userWorkspaces, type UserWorkspace } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
@@ -42,6 +42,21 @@
let newMcpScope = $state('favorites')
let newMcpToken = $state<string | undefined>(undefined)
function ensureCurrentWorkspaceIncluded(
workspacesList: UserWorkspace[],
currentWorkspace: string | undefined
) {
if (!currentWorkspace) {
return workspacesList
}
const hasCurrentWorkspace = workspacesList.some((w) => w.id === currentWorkspace)
if (hasCurrentWorkspace) {
return workspacesList
}
return [{ id: currentWorkspace, name: currentWorkspace }, ...workspacesList]
}
const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $workspaceStore))
const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/sse?token=`)
const dispatch = createEventDispatcher()
@@ -221,8 +236,8 @@
</div>
<div class="flex flex-col">
<label for="label">Workspace</label>
<select bind:value={newTokenWorkspace} disabled={$userWorkspaces.length === 1}>
{#each $userWorkspaces as workspace}
<select bind:value={newTokenWorkspace} disabled={workspaces.length === 1}>
{#each workspaces as workspace}
<option value={workspace.id}>{workspace.name}</option>
{/each}
</select>

View File

@@ -0,0 +1,34 @@
import { JobService, ScheduleService } from "$lib/gen"
import { goto } from "$lib/navigation"
import { sendUserToast } from "$lib/utils"
export async function runScheduleNow(
path: string,
schedulePath: string,
isFlow: boolean,
workspace_id: string
): Promise<void> {
try {
const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath
const args = (
await ScheduleService.getSchedule({
workspace: workspace_id,
path: schedulePath
})
).args
const run = await runByPath({
path,
requestBody: args ?? {},
workspace: workspace_id
})
sendUserToast(`Schedule ${path} will run now`, false, [
{
label: 'Go to the run page',
callback: () => goto('/run/' + run + '?workspace=' + workspace_id)
}
])
} catch (err) {
sendUserToast(`Cannot run schedule now: ${err.body}`, true)
}
}

View File

@@ -9,10 +9,14 @@
drawer?.openEdit(ePath, isFlow)
}
export async function openNew(is_flow: boolean, initial_script_path?: string) {
export async function openNew(
is_flow: boolean,
initial_script_path?: string,
schedule_path?: string
) {
open = true
await tick()
drawer?.openNew(is_flow, initial_script_path)
drawer?.openNew(is_flow, initial_script_path, schedule_path)
}
let drawer: ScheduleEditorInner

View File

@@ -17,7 +17,8 @@
ScriptService,
type Flow,
SettingService,
type Retry
type Retry,
type Schedule
} from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { canWrite, emptyString, formatCron, sendUserToast, cronV1toV2 } from '$lib/utils'
@@ -25,11 +26,12 @@
import { createEventDispatcher } from 'svelte'
import Section from '$lib/components/Section.svelte'
import { List, Loader2, Save, AlertTriangle } from 'lucide-svelte'
import FlowRetries from './flows/content/FlowRetries.svelte'
import WorkerTagPicker from './WorkerTagPicker.svelte'
import Label from './Label.svelte'
import DateTimeInput from './DateTimeInput.svelte'
import autosize from '$lib/autosize'
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import FlowRetries from '$lib/components/flows/content/FlowRetries.svelte'
import Label from '$lib/components/Label.svelte'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import { runScheduleNow } from '../scheduled/utils'
let optionTabSelected: 'error_handler' | 'recovery_handler' | 'success_handler' | 'retries' =
'error_handler'
@@ -65,6 +67,7 @@
let failedTimes = 1
let failedExact = false
let recoveredTimes = 1
let duplicate = false
let retry: Retry | undefined = undefined
let script_path = ''
@@ -94,16 +97,58 @@
}
}
export async function openNew(nis_flow: boolean, initial_script_path?: string) {
drawerLoading = true
try {
drawer?.openDrawer()
args = {}
runnable = undefined
is_flow = nis_flow
schedule = '0 0 12 * *'
paused_until = undefined
showPauseUntil = false
async function setScheduleHandler(s?: Schedule) {
if (s) {
if (s.on_failure) {
let splitted = s.on_failure.split('/')
errorHandleritemKind = splitted[0] as 'flow' | 'script'
errorHandlerPath = splitted.slice(1)?.join('/')
errorHandlerCustomInitialPath = errorHandlerPath
failedTimes = s.on_failure_times ?? 1
failedExact = s.on_failure_exact ?? false
errorHandlerExtraArgs = s.on_failure_extra_args ?? {}
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
} else {
errorHandlerPath = undefined
errorHandleritemKind = 'script'
errorHandlerCustomInitialPath = undefined
errorHandlerExtraArgs = {}
failedExact = false
failedTimes = 1
errorHandlerSelected = 'slack'
}
if (s.on_recovery) {
let splitted = s.on_recovery.split('/')
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
recoveryHandlerPath = splitted.slice(1)?.join('/')
recoveryHandlerCustomInitialPath = recoveryHandlerPath
recoveredTimes = s.on_recovery_times ?? 1
recoveryHandlerExtraArgs = s.on_recovery_extra_args ?? {}
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
} else {
recoveryHandlerPath = undefined
recoveryHandlerItemKind = 'script'
recoveryHandlerCustomInitialPath = undefined
recoveredTimes = 1
recoveryHandlerSelected = 'slack'
recoveryHandlerExtraArgs = {}
}
if (s.on_success) {
let splitted = s.on_success.split('/')
successHandlerItemKind = splitted[0] as 'flow' | 'script'
successHandlerPath = splitted.slice(1)?.join('/')
successHandlerCustomInitialPath = successHandlerPath
successHandlerExtraArgs = s.on_success_extra_args ?? {}
successHandlerSelected = getHandlerType('success', successHandlerPath)
} else {
successHandlerPath = undefined
successHandlerItemKind = 'script'
successHandlerCustomInitialPath = undefined
successHandlerSelected = 'slack'
successHandlerExtraArgs = {}
}
}
else {
let defaultErrorHandlerMaybe = undefined
let defaultRecoveryHandlerMaybe = undefined
let defaultSuccessHandlerMaybe = undefined
@@ -119,17 +164,6 @@
})) as any
}
edit = false
itemKind = nis_flow ? 'flow' : 'script'
initialScriptPath = initial_script_path ?? ''
summary = ''
description = ''
no_flow_overlap = false
path = initialScriptPath
initialPath = initialScriptPath
script_path = initialScriptPath
await loadScript(script_path)
if (defaultErrorHandlerMaybe !== undefined && defaultErrorHandlerMaybe !== null) {
wsErrorHandlerMuted = defaultErrorHandlerMaybe['wsErrorHandlerMuted']
let splitted = (defaultErrorHandlerMaybe['errorHandlerPath'] as string).split('/')
@@ -181,7 +215,54 @@
successHandlerCustomInitialPath = undefined
successHandlerSelected = 'slack'
}
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
}
}
export async function openNew(
nis_flow: boolean,
initial_script_path?: string,
schedule_path?: string
) {
drawerLoading = true
try {
let s: Schedule | undefined
if (schedule_path) {
s = await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: schedule_path
})
duplicate = true
}
drawer?.openDrawer()
runnable = undefined
is_flow = s?.is_flow ?? nis_flow
edit = false
itemKind = is_flow ? 'flow' : 'script'
initialScriptPath = initial_script_path ?? ''
path = duplicate === true ? '' : initialScriptPath
initialPath = path
cronVersion = s?.cron_version ?? 'v2'
initialCronVersion = cronVersion
isLatestCron = cronVersion == 'v2'
schedule = s?.schedule ?? '0 0 12 * *'
initialSchedule = schedule
timezone = s?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone
paused_until = s?.paused_until
showPauseUntil = paused_until !== undefined
summary = s?.summary ?? ''
description = s?.description ?? ''
script_path = s?.script_path ?? initialScriptPath
args = s?.args ?? {}
tag = s?.tag
await loadScript(script_path)
no_flow_overlap = s?.no_flow_overlap ?? false
wsErrorHandlerMuted = s?.ws_error_handler_muted ?? false
retry = s?.retry
await setScheduleHandler(s)
} finally {
drawerLoading = false
}
@@ -322,6 +403,7 @@
workspace: $workspaceStore!,
path: initialPath
})
is_flow = s.is_flow
cronVersion = s.cron_version ?? 'v2'
initialCronVersion = cronVersion
isLatestCron = cronVersion == 'v2'
@@ -334,63 +416,16 @@
summary = s.summary ?? ''
description = s.description ?? ''
script_path = s.script_path ?? ''
await loadScript(script_path)
is_flow = s.is_flow
no_flow_overlap = s.no_flow_overlap ?? false
wsErrorHandlerMuted = s.ws_error_handler_muted ?? false
retry = s.retry
if (s.on_failure) {
let splitted = s.on_failure.split('/')
errorHandleritemKind = splitted[0] as 'flow' | 'script'
errorHandlerPath = splitted.slice(1)?.join('/')
errorHandlerCustomInitialPath = errorHandlerPath
failedTimes = s.on_failure_times ?? 1
failedExact = s.on_failure_exact ?? false
errorHandlerExtraArgs = s.on_failure_extra_args ?? {}
errorHandlerSelected = getHandlerType('error', errorHandlerPath)
} else {
errorHandlerPath = undefined
errorHandleritemKind = 'script'
errorHandlerCustomInitialPath = undefined
errorHandlerExtraArgs = {}
failedExact = false
failedTimes = 1
errorHandlerSelected = 'slack'
}
if (s.on_recovery) {
let splitted = s.on_recovery.split('/')
recoveryHandlerItemKind = splitted[0] as 'flow' | 'script'
recoveryHandlerPath = splitted.slice(1)?.join('/')
recoveryHandlerCustomInitialPath = recoveryHandlerPath
recoveredTimes = s.on_recovery_times ?? 1
recoveryHandlerExtraArgs = s.on_recovery_extra_args ?? {}
recoveryHandlerSelected = getHandlerType('recovery', recoveryHandlerPath)
} else {
recoveryHandlerPath = undefined
recoveryHandlerItemKind = 'script'
recoveryHandlerCustomInitialPath = undefined
recoveredTimes = 1
recoveryHandlerSelected = 'slack'
recoveryHandlerExtraArgs = {}
}
if (s.on_success) {
let splitted = s.on_success.split('/')
successHandlerItemKind = splitted[0] as 'flow' | 'script'
successHandlerPath = splitted.slice(1)?.join('/')
successHandlerCustomInitialPath = successHandlerPath
successHandlerExtraArgs = s.on_success_extra_args ?? {}
successHandlerSelected = getHandlerType('success', successHandlerPath)
} else {
successHandlerPath = undefined
successHandlerItemKind = 'script'
successHandlerCustomInitialPath = undefined
successHandlerSelected = 'slack'
successHandlerExtraArgs = {}
}
args = s.args ?? {}
can_write = canWrite(s.path, s.extra_perms, $userStore)
tag = s.tag
await loadScript(script_path)
no_flow_overlap = s.no_flow_overlap ?? false
wsErrorHandlerMuted = s.ws_error_handler_muted ?? false
retry = s.retry
await setScheduleHandler(s)
} catch (err) {
sendUserToast(`Could not load schedule: ${err}`, true)
}
@@ -571,7 +606,7 @@
<svelte:fragment slot="actions">
{#if !drawerLoading}
{#if edit}
<div class="mr-8">
<div class="mr-8 flex flex-row gap-3">
<Button
size="sm"
variant="border"
@@ -581,6 +616,16 @@
>
View runs
</Button>
<Button
size="sm"
variant="border"
disabled={!allowSchedule || pathError != '' || emptyString(script_path)}
on:click={() => {
runScheduleNow(script_path, path, is_flow, $workspaceStore!)
}}
>
Run now
</Button>
</div>
{#if can_write}
<div class="mr-8 center-center -mt-1">
@@ -749,7 +794,7 @@
Pick a script or flow to be triggered by the schedule<Required required={true} />
</p>
<ScriptPicker
disabled={initialScriptPath != '' || !can_write}
disabled={(initialScriptPath != '' && !duplicate) || !can_write}
initialPath={initialScriptPath}
kinds={['script']}
allowFlow={true}

View File

@@ -1,3 +1,5 @@
import type { DbType } from './components/apps/components/display/dbtable/utils'
export const DEFAULT_WEBHOOK_TYPE: 'async' | 'sync' = 'async'
export const HOME_SHOW_HUB = true
@@ -201,3 +203,11 @@ export const MSSQL_TYPES = [
'decimal',
'bit'
]
export const DB_TYPES: Record<DbType, string[]> = {
bigquery: BIGQUERY_TYPES,
ms_sql_server: MSSQL_TYPES,
mysql: MYSQL_TYPES,
postgresql: POSTGRES_TYPES,
snowflake: SNOWFLAKE_TYPES
}

View File

@@ -28,6 +28,14 @@ export interface UserExt {
folders_owners: string[]
}
export interface UserWorkspace {
id: string
name: string
username: string
color: string | null
operator_settings?: OperatorSettings
}
const persistedWorkspace = BROWSER && getWorkspace()
function getWorkspace(): string | undefined {
@@ -60,31 +68,26 @@ export const superadmin = writable<string | false | undefined>(undefined)
export const devopsRole = writable<string | false | undefined>(undefined)
export const lspTokenStore = writable<string | undefined>(undefined)
export const hubBaseUrlStore = writable<string>('https://hub.windmill.dev')
export const userWorkspaces: Readable<
Array<{
id: string
name: string
username: string
color: string | null
operator_settings?: OperatorSettings
}>
> = derived([usersWorkspaceStore, superadmin], ([store, superadmin]) => {
const originalWorkspaces = store?.workspaces ?? []
if (superadmin) {
return [
...originalWorkspaces.filter((x) => x.id != 'admins'),
{
id: 'admins',
name: 'Admins',
username: 'superadmin',
color: null,
operator_settings: null
}
]
} else {
return originalWorkspaces
export const userWorkspaces: Readable<Array<UserWorkspace>> = derived(
[usersWorkspaceStore, superadmin],
([store, superadmin]) => {
const originalWorkspaces = store?.workspaces ?? []
if (superadmin) {
return [
...originalWorkspaces.filter((x) => x.id != 'admins'),
{
id: 'admins',
name: 'Admins',
username: 'superadmin',
color: null,
operator_settings: null
}
]
} else {
return originalWorkspaces
}
}
})
)
export const copilotInfo = writable<{
enabled: boolean
codeCompletionModel?: AIProviderModel
@@ -232,3 +235,16 @@ export const workspaceColor: Readable<string | null | undefined> = derived(
})
}
)
export function getFlatTableNamesFromSchema(dbSchema: DBSchema | undefined): string[] {
const schema = dbSchema?.schema ?? {}
const tableNames: string[] = []
for (const schemaKey in schema) {
for (const tableKey in schema[schemaKey]) {
tableNames.push(`${schemaKey}.${tableKey}`)
}
}
return tableNames
}

View File

@@ -692,6 +692,12 @@ export function sortObject<T>(o: T & object): T {
}, {}) as T
}
export function sortArray<T>(array: T[], compareFn?: (a: T, b: T) => number): T[] {
const arr = [...array]
arr.sort(compareFn)
return arr
}
export function generateRandomString(len: number = 24): string {
let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''

View File

@@ -60,6 +60,8 @@
import EditableSchemaWrapper from '$lib/components/schema/EditableSchemaWrapper.svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import DbManagerDrawerButton from '$lib/components/DBManagerDrawerButton.svelte'
import { isDbType } from '$lib/components/apps/components/display/dbtable/utils'
type ResourceW = ListableResource & { canWrite: boolean; marked?: string }
type ResourceTypeW = ResourceType & { canWrite: boolean }
@@ -136,23 +138,23 @@
x.resource_type !== 'state' &&
x.resource_type !== 'cache'
: tab === 'states'
? x.resource_type === 'state'
: tab === 'cache'
? x.resource_type === 'cache'
: tab === 'theme'
? x.resource_type === 'app_theme'
: true
})
? x.resource_type === 'state'
: tab === 'cache'
? x.resource_type === 'cache'
: tab === 'theme'
? x.resource_type === 'app_theme'
: true
})
: preFilteredItemsOwners?.filter((x) => {
return (
x.resource_type === typeFilter &&
(tab === 'workspace'
? x.resource_type !== 'app_theme' &&
x.resource_type !== 'state' &&
x.resource_type !== 'cache'
x.resource_type !== 'state' &&
x.resource_type !== 'cache'
: true)
)
})
})
async function loadResources(): Promise<void> {
resources = await loadResourceInternal(undefined, 'cache,state')
@@ -488,10 +490,7 @@
>
<label>
<div class="mb-1 font-semibold text-secondary">Description</div>
<textarea
use:autosize
autocomplete="off"
bind:value={editResourceType.description}
<textarea use:autosize autocomplete="off" bind:value={editResourceType.description}
></textarea></label
>
<div>
@@ -569,7 +568,8 @@
</label>
<label>
<div class="mb-1 font-semibold text-secondary">Description</div>
<textarea use:autosize autocomplete="off" bind:value={newResourceType.description}></textarea></label
<textarea use:autosize autocomplete="off" bind:value={newResourceType.description}
></textarea></label
>
<div>
<div class="flex justify-between w-full items-center">
@@ -603,422 +603,435 @@
f={(x) => x.path + ' ' + x.resource_type + ' ' + x.description + ' '}
/>
{#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="Resources"
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
>
<div class="flex flex-row justify-end gap-4">
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
Add resource type
</Button>
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
Add resource
</Button>
</div>
</PageHeader>
<div class="flex justify-between">
<Tabs
class="w-full"
bind:selected={tab}
on:selected={(e) => {
if (e.detail == 'cache') {
loading.resources = true
loadCache()
} else if (e.detail == 'states') {
loading.resources = true
loadState()
}
}}
>
<Tab size="md" value="workspace">
<div class="flex gap-2 items-center my-1">
<Building size={18} />
Workspace
</div>
</Tab>
<Tab size="md" value="types">
<div class="flex gap-2 items-center my-1">
Resource Types
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
>
Every resource has a Resource Type attached to it which contains its schema and make it
easy in scripts and flows to accept only resources of a specific resource type.
</Tooltip>
</div>
</Tab>
<Tab size="md" value="states">
<div class="flex gap-2 items-center my-1">
States
<Tooltip>
States are actually resources (but excluded from the Workspace tab for clarity). States
are used by scripts to keep data persistent between runs of the same script by the same
trigger (schedule or user)
</Tooltip>
</div>
</Tab>
<Tab size="md" value="cache">
<div class="flex gap-2 items-center my-1">
Cache
<Tooltip>
Cached results are actually resources (but excluded from the Workspace tab for clarity).
Cache are used by flows's step to cache result to avoid recomputing unnecessarily
</Tooltip>
</div>
</Tab>
<Tab size="md" value="theme">
<div class="flex gap-2 items-center my-1">
Theme
<Tooltip>
Theme are actually resources (but excluded from the Workspace tab for clarity). Theme
are used by the apps to customize their look and feel.
</Tooltip>
</div>
</Tab>
</Tabs>
<div class="flex">
<Button
variant="border"
color="light"
on:click={reload}
startIcon={{
icon: RotateCw,
classes: loading.resources || loading.types ? 'animate-spin' : ''
}}
/>
</div>
{#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>
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
<div class="pt-2">
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
</div>
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
{#if tab != 'states' && tab != 'cache'}
<ListFilters
queryName="app_filter"
bind:selectedFilter={typeFilter}
filters={types}
resourceType
/>
{:else}
<div class="h-4"></div>
{/if}
<div class="overflow-x-auto pb-40 mt-4">
{#if loading.resources}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(6) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
{:else if filteredItems?.length == 0}
<div class="flex flex-col items-center justify-center h-full">
<div class="text-md font-medium">No resources found</div>
<div class="text-sm text-secondary">
Try changing the filters or creating a new resource
{:else}
<CenteredPage>
<PageHeader
title="Resources"
tooltip="Save and permission rich objects (JSON) including credentials obtained through OAuth."
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
>
<div class="flex flex-row justify-end gap-4">
<Button variant="border" size="md" startIcon={{ icon: Plus }} on:click={startNewType}>
Add resource type
</Button>
<Button size="md" startIcon={{ icon: Link }} on:click={() => appConnect.open?.()}>
Add resource
</Button>
</div>
</PageHeader>
<div class="flex justify-between">
<Tabs
class="w-full"
bind:selected={tab}
on:selected={(e) => {
if (e.detail == 'cache') {
loading.resources = true
loadCache()
} else if (e.detail == 'states') {
loading.resources = true
loadState()
}
}}
>
<Tab size="md" value="workspace">
<div class="flex gap-2 items-center my-1">
<Building size={18} />
Workspace
</div>
</div>
</Tab>
<Tab size="md" value="types">
<div class="flex gap-2 items-center my-1">
Resource Types
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/resources_and_types"
>
Every resource has a Resource Type attached to it which contains its schema and make
it easy in scripts and flows to accept only resources of a specific resource type.
</Tooltip>
</div>
</Tab>
<Tab size="md" value="states">
<div class="flex gap-2 items-center my-1">
States
<Tooltip>
States are actually resources (but excluded from the Workspace tab for clarity).
States are used by scripts to keep data persistent between runs of the same script by
the same trigger (schedule or user)
</Tooltip>
</div>
</Tab>
<Tab size="md" value="cache">
<div class="flex gap-2 items-center my-1">
Cache
<Tooltip>
Cached results are actually resources (but excluded from the Workspace tab for
clarity). Cache are used by flows's step to cache result to avoid recomputing
unnecessarily
</Tooltip>
</div>
</Tab>
<Tab size="md" value="theme">
<div class="flex gap-2 items-center my-1">
Theme
<Tooltip>
Theme are actually resources (but excluded from the Workspace tab for clarity). Theme
are used by the apps to customize their look and feel.
</Tooltip>
</div>
</Tab>
</Tabs>
<div class="flex">
<Button
variant="border"
color="light"
on:click={reload}
startIcon={{
icon: RotateCw,
classes: loading.resources || loading.types ? 'animate-spin' : ''
}}
/>
</div>
</div>
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
<div class="pt-2">
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
</div>
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
{#if tab != 'states' && tab != 'cache'}
<ListFilters
queryName="app_filter"
bind:selectedFilter={typeFilter}
filters={types}
resourceType
/>
{:else}
<DataTable>
<Head>
<Row>
<Cell head first />
<Cell head>Path</Cell>
<Cell head>Resource type</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last />
</Row>
</Head>
<tbody class="divide-y bg-surface">
{#if filteredItems}
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed }}
<Row>
<Cell first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<a
class="break-all"
href="#{path}"
on:click={() => resourceEditor?.initEdit?.(path)}
>{#if marked}{@html marked}{:else}{path}{/if}</a
>
</Cell>
<Cell>
<a
href="#{name}"
on:click={() => {
const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
if (linkedRt) {
resourceTypeViewerObj = {
rt: linkedRt.name,
//@ts-ignore
schema: linkedRt.schema,
description: linkedRt.description ?? '',
formatExtension: linkedRt.format_extension
<div class="h-4"></div>
{/if}
<div class="overflow-x-auto pb-40 mt-4">
{#if loading.resources}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(6) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
{:else if filteredItems?.length == 0}
<div class="flex flex-col items-center justify-center h-full">
<div class="text-md font-medium">No resources found</div>
<div class="text-sm text-secondary">
Try changing the filters or creating a new resource
</div>
</div>
{:else}
<DataTable>
<Head>
<Row>
<Cell head first />
<Cell head>Path</Cell>
<Cell head>Resource type</Cell>
<Cell head>Description</Cell>
<Cell head />
<Cell head last />
</Row>
</Head>
<tbody class="divide-y bg-surface">
{#if filteredItems}
{#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed }}
<Row>
<Cell first>
<SharedBadge {canWrite} extraPerms={extra_perms} />
</Cell>
<Cell>
<a
class="break-all"
href="#{path}"
on:click={() => resourceEditor?.initEdit?.(path)}
>{#if marked}{@html marked}{:else}{path}{/if}</a
>
</Cell>
<Cell>
<a
href="#{name}"
on:click={() => {
const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type)
if (linkedRt) {
resourceTypeViewerObj = {
rt: linkedRt.name,
//@ts-ignore
schema: linkedRt.schema,
description: linkedRt.description ?? '',
formatExtension: linkedRt.format_extension
}
resourceTypeViewer.openDrawer?.()
} else {
sendUserToast(
`Resource type ${resource_type} not found in workspace.`,
true
)
}
resourceTypeViewer.openDrawer?.()
} else {
sendUserToast(
`Resource type ${resource_type} not found in workspace.`,
true
)
}
}}
>
<IconedResourceType
name={resource_type}
after={true}
formatExtension={resourceNameToFileExt(resource_type)}
/>
</a>
</Cell>
<Cell>
<span class="text-tertiary text-xs">
{removeMarkdown(truncate(description ?? '', 30))}
</span>
</Cell>
<Cell>
<div class="flex flex-row text-center">
<div class="w-10">
{#if is_linked}
<Popover>
<Link />
<div slot="text">
This resource is linked with a variable of the same path. They are
deleted and renamed together.
</div>
</Popover>
{/if}
</div>
<div class="w-10">
{#if is_refreshed}
<Popover>
<RotateCw />
<div slot="text">
The OAuth token will be kept up-to-date in the background by Windmill
using its refresh token
</div>
</Popover>
{/if}
</div>
{#if is_oauth}
<div class="w-10 pt-1.5">
{#if refresh_error}
}}
>
<IconedResourceType
name={resource_type}
after={true}
formatExtension={resourceNameToFileExt(resource_type)}
/>
</a>
</Cell>
<Cell>
<span class="text-tertiary text-xs">
{removeMarkdown(truncate(description ?? '', 30))}
</span>
</Cell>
<Cell>
<div class="flex flex-row text-center">
<div class="w-10">
{#if is_linked}
<Popover>
<Circle
class="text-red-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<Link />
<div slot="text">
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
</Popover>
{:else if is_expired}
<Popover>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The access_token is expired, it will get renewed the next time this
variable is fetched or you can request is to be refreshed in the
dropdown on the right.
</div>
</Popover>
{:else}
<Popover>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The resource was connected through OAuth and the token is not
expired.
This resource is linked with a variable of the same path. They are
deleted and renamed together.
</div>
</Popover>
{/if}
</div>
<div class="w-10">
{#if is_refreshed}
<Popover>
<RotateCw />
<div slot="text">
The OAuth token will be kept up-to-date in the background by
Windmill using its refresh token
</div>
</Popover>
{/if}
</div>
{#if is_oauth}
<div class="w-10 pt-1.5">
{#if refresh_error}
<Popover>
<Circle
class="text-red-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
Latest exchange of the refresh token did not succeed. Error: {refresh_error}
</div>
</Popover>
{:else if is_expired}
<Popover>
<Circle
class="text-yellow-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The access_token is expired, it will get renewed the next time
this variable is fetched or you can request is to be refreshed in
the dropdown on the right.
</div>
</Popover>
{:else}
<Popover>
<Circle
class="text-green-600 animate-[pulse_5s_linear_infinite] fill-current"
size={12}
/>
<div slot="text">
The resource was connected through OAuth and the token is not
expired.
</div>
</Popover>
{/if}
</div>
{/if}
</div>
</Cell>
<Cell class="flex justify-end">
{#if path && isDbType(resource_type)}
<DbManagerDrawerButton
resourcePath={path}
resourceType={resource_type}
class="mr-8"
/>
{/if}
</div>
</Cell>
<Cell>
<Dropdown
items={[
{
displayName: !canWrite ? 'View Permissions' : 'Share',
icon: Share,
action: () => {
shareModal.openDrawer?.(path, 'resource')
}
},
{
displayName: 'Edit',
icon: Pen,
disabled: !canWrite,
action: () => {
resourceEditor?.initEdit?.(path)
}
},
...(isDeployable('resource', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer.openDrawer(path, 'resource')
<Dropdown
class="w-fit"
items={[
{
displayName: !canWrite ? 'View Permissions' : 'Share',
icon: Share,
action: () => {
shareModal.openDrawer?.(path, 'resource')
}
},
{
displayName: 'Edit',
icon: Pen,
disabled: !canWrite,
action: () => {
resourceEditor?.initEdit?.(path)
}
},
...(isDeployable('resource', path, deployUiSettings)
? [
{
displayName: 'Deploy to prod/staging',
icon: FileUp,
action: () => {
deploymentDrawer.openDrawer(path, 'resource')
}
}
}
]
: []),
{
displayName: 'Delete',
disabled: !canWrite,
icon: Trash,
type: 'delete',
action: (event) => {
// TODO
// @ts-ignore
if (event?.shiftKey) {
deleteResource(path, account)
} else {
deleteConfirmedCallback = () => {
]
: []),
{
displayName: 'Delete',
disabled: !canWrite,
icon: Trash,
type: 'delete',
action: (event) => {
// TODO
// @ts-ignore
if (event?.shiftKey) {
deleteResource(path, account)
} else {
deleteConfirmedCallback = () => {
deleteResource(path, account)
}
}
}
}
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RotateCw,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadResources()
},
...(account != undefined
? [
{
displayName: 'Refresh token',
icon: RotateCw,
action: async () => {
await OauthService.refreshToken({
workspace: $workspaceStore ?? '',
id: account ?? 0,
requestBody: {
path
}
})
sendUserToast('Token refreshed')
loadResources()
}
}
}
]
: [])
]}
/>
</Cell>
</Row>
{/each}
{/if}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'types'}
{#if loading.types}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(6) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
{:else}
<div class="overflow-auto mt-4">
<DataTable>
<Head>
<Row>
<Cell head first>Name</Cell>
<Cell head>Description</Cell>
<Cell head last />
</Row>
</Head>
<tbody class="divide-y bg-surface">
{#if resourceTypes}
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
<Row>
<Cell first>
<a
href="#{name}"
on:click={() => {
resourceTypeViewerObj = {
rt: name,
//@ts-ignore
schema: schema,
description: description ?? '',
formatExtension: format_extension
}
resourceTypeViewer.openDrawer?.()
}}
>
<IconedResourceType after={true} {name} formatExtension={format_extension} />
</a>
</Cell>
<Cell>
<span class="text-tertiary text-xs w-96 flex flex-wrap whitespace-pre-wrap">
{removeMarkdown(truncate(description ?? '', 200))}
</span>
</Cell>
<Cell last>
{#if !canWrite}
<Badge>
Shared globally
<Tooltip>
This resource type is from the 'admins' workspace shared with all
workspaces
</Tooltip>
</Badge>
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<div class="flex flex-row-reverse gap-2">
<Button
size="xs"
color="red"
variant="border"
btnClasses="border-0"
startIcon={{ icon: Trash }}
on:click={() => handleDeleteResourceType(name)}
>
Delete
</Button>
<Button
size="xs"
color="light"
startIcon={{ icon: Pen }}
on:click={() => startEditResourceType(name)}
>
Edit
</Button>
</div>
{:else}
<Badge>
Non Editable
<Tooltip>
Since resource types are shared with the whole workspace, only admins can
edit/delete them
</Tooltip>
</Badge>
{/if}
</Cell>
</Row>
{/each}
{/if}
</tbody>
</DataTable>
]
: [])
]}
/>
</Cell>
</Row>
{/each}
{/if}
</tbody>
</DataTable>
{/if}
</div>
{:else if tab == 'types'}
{#if loading.types}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(6) as _}
<Skeleton layout={[[4], 0.7]} />
{/each}
{:else}
<div class="overflow-auto mt-4">
<DataTable>
<Head>
<Row>
<Cell head first>Name</Cell>
<Cell head>Description</Cell>
<Cell head last />
</Row>
</Head>
<tbody class="divide-y bg-surface">
{#if resourceTypes}
{#each resourceTypes as { name, description, schema, canWrite, format_extension }}
<Row>
<Cell first>
<a
href="#{name}"
on:click={() => {
resourceTypeViewerObj = {
rt: name,
//@ts-ignore
schema: schema,
description: description ?? '',
formatExtension: format_extension
}
resourceTypeViewer.openDrawer?.()
}}
>
<IconedResourceType
after={true}
{name}
formatExtension={format_extension}
/>
</a>
</Cell>
<Cell>
<span class="text-tertiary text-xs w-96 flex flex-wrap whitespace-pre-wrap">
{removeMarkdown(truncate(description ?? '', 200))}
</span>
</Cell>
<Cell last>
{#if !canWrite}
<Badge>
Shared globally
<Tooltip>
This resource type is from the 'admins' workspace shared with all
workspaces
</Tooltip>
</Badge>
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
<div class="flex flex-row-reverse gap-2">
<Button
size="xs"
color="red"
variant="border"
btnClasses="border-0"
startIcon={{ icon: Trash }}
on:click={() => handleDeleteResourceType(name)}
>
Delete
</Button>
<Button
size="xs"
color="light"
startIcon={{ icon: Pen }}
on:click={() => startEditResourceType(name)}
>
Edit
</Button>
</div>
{:else}
<Badge>
Non Editable
<Tooltip>
Since resource types are shared with the whole workspace, only admins
can edit/delete them
</Tooltip>
</Badge>
{/if}
</Cell>
</Row>
{/each}
{/if}
</tbody>
</DataTable>
</div>
{/if}
{/if}
{/if}
</CenteredPage>
</CenteredPage>
{/if}
<SupabaseConnect bind:this={supabaseConnect} on:refresh={loadResources} />

View File

@@ -81,13 +81,13 @@
import { json } from 'svelte-highlight/languages'
import Toggle from '$lib/components/Toggle.svelte'
import WorkflowTimeline from '$lib/components/WorkflowTimeline.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
import ExecutionDuration from '$lib/components/ExecutionDuration.svelte'
import CustomPopover from '$lib/components/CustomPopover.svelte'
import { isWindmillTooBigObject } from '$lib/components/job_args'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
let job: Job | undefined
let jobUpdateLastFetch: Date | undefined

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import {
ScheduleService,
JobService,
type ScheduleWJobs,
type WorkspaceDeployUISettings,
WorkspaceService
@@ -13,7 +12,6 @@
import Dropdown from '$lib/components/DropdownV2.svelte'
import PageHeader from '$lib/components/PageHeader.svelte'
import Popover from '$lib/components/Popover.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import SharedBadge from '$lib/components/SharedBadge.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import Toggle from '$lib/components/Toggle.svelte'
@@ -22,6 +20,7 @@
Calendar,
Circle,
Code,
Copy,
Eye,
FileUp,
List,
@@ -45,6 +44,8 @@
import { onMount } from 'svelte'
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
type ScheduleW = ScheduleWJobs & { canWrite: boolean }
@@ -106,36 +107,6 @@
}
}
async function runScheduleNow(
path: string,
schedulePath: string,
isFlow: boolean
): Promise<void> {
try {
const runByPath = isFlow ? JobService.runFlowByPath : JobService.runScriptByPath
const args = (
await ScheduleService.getSchedule({
workspace: $workspaceStore!,
path: schedulePath
})
).args
const run = await runByPath({
path,
requestBody: args ?? {},
workspace: $workspaceStore!
})
sendUserToast(`Schedule ${path} will run now`, false, [
{
label: 'Go to the run page',
callback: () => goto('/run/' + run + '?workspace=' + $workspaceStore)
}
])
} catch (err) {
sendUserToast(`Cannot run schedule now: ${err.body}`, true)
}
}
$: {
if ($workspaceStore && $userStore) {
loadSchedules()
@@ -200,18 +171,18 @@
x.path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
)
)
: schedules?.filter(
(x) =>
x.script_path.startsWith(ownerFilter + '/') &&
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
)
)
: schedules?.filter(
(x) =>
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
)
)
$: if ($workspaceStore) {
ownerFilter = undefined
@@ -221,10 +192,10 @@
selectedFilterKind === 'schedule'
? Array.from(
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
).sort()
: Array.from(
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
).sort()
).sort()
$: items = filter !== '' ? filteredItems : preFilteredItems
@@ -430,6 +401,13 @@
goto(href)
}
},
{
displayName: `Duplicate schedule`,
icon: Copy,
action: () => {
scheduleEditor?.openNew(is_flow, script_path, path)
}
},
{
displayName: 'Delete',
type: 'delete',
@@ -463,7 +441,7 @@
})
}
}
]
]
: []),
{
displayName: 'View runs',
@@ -483,7 +461,7 @@
displayName: 'Run now',
icon: Play,
action: () => {
runScheduleNow(script_path, path, is_flow)
runScheduleNow(script_path, path, is_flow, $workspaceStore!)
}
},
{

View File

@@ -18,7 +18,7 @@
import { getUserExt } from '$lib/user'
import { setLicense } from '$lib/enterpriseUtils'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import ScheduleEditor from '$lib/components/ScheduleEditor.svelte'
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
$workspaceStore = $page.params.workspace

View File

@@ -472,11 +472,16 @@ const config = {
'4k': '3800px'
},
animation: {
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite'
'spin-counter-clockwise': 'spin-counter-clockwise 1s linear infinite',
'zoom-in': 'zoom-in 0.25s ease-in-out'
},
keyframes: {
'spin-counter-clockwise': {
to: { transform: 'rotate(-360deg)' }
},
'zoom-in': {
'0%': { transform: 'scale(0.95)' },
'100%': { transform: 'scale(1)' }
}
}
}

View File

@@ -5,3 +5,4 @@ httpcore==1.0.2
httpx==0.26.0
idna==3.6
sniffio==1.3.0
docker==7.1.0

View File

@@ -2,3 +2,4 @@ from .identity_script_test import *
from .increment_flow_test import *
from .schedule_test import *
from .windmill_sdk_test import *
from .agent_workers import *

View File

@@ -0,0 +1,265 @@
import unittest
import base64
import json
import os
import time
import atexit
import docker
from docker.errors import NotFound
from .wmill_integration_test_utils import WindmillClient
AGENT_CONTAINER_NAME = "windmill_agent_test"
# Global cleanup function registered with atexit
def cleanup_docker_container():
print(f"At exit: Attempting to remove container {AGENT_CONTAINER_NAME}")
try:
docker_client = docker.from_env()
try:
container = docker_client.containers.get(AGENT_CONTAINER_NAME)
container.stop()
container.remove()
print(f"At exit: Successfully removed container {AGENT_CONTAINER_NAME}")
except NotFound:
print(f"At exit: Container {AGENT_CONTAINER_NAME} not found")
except Exception as e:
print(f"At exit: Error removing container: {e}")
except Exception as e:
print(f"At exit: Could not initialize Docker client: {e}")
atexit.register(cleanup_docker_container)
class TestAgentWorkers(unittest.TestCase):
_client: WindmillClient
_docker_client = None
_agent_container = None
_container_name = AGENT_CONTAINER_NAME
_agent_token = None
@classmethod
def setUpClass(cls) -> None:
print("Running {}".format(cls.__name__))
cls._client = WindmillClient()
cls._client.add_global_custom_tag("agent_test")
cls._docker_client = docker.from_env()
cls._start_agent_container()
cls._wait_for_agent_connection()
@classmethod
def _start_agent_container(cls):
wm_image = os.environ.get("WM_IMAGE", "ghcr.io/windmill-labs/windmill-ee")
wm_version = os.environ.get("WM_VERSION", "latest")
cls._agent_token = cls._client.create_agent_token(
worker_group="agent",
tags=["agent", "python3", "bash", "agent_test"],
exp=int(time.time()) + 3600 # 1 hour from now
)
try:
container = cls._docker_client.containers.get(cls._container_name)
container.stop()
container.remove()
print(f"Removed existing container {cls._container_name}")
except NotFound:
pass
# Get the host IP address for connecting back to the Windmill server
host_ip = "host.docker.internal"
if os.name == "posix" and os.uname().sysname == "Linux":
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
host_ip = s.getsockname()[0]
s.close()
print(f"Starting agent container connecting to Windmill server at http://{host_ip}:8000")
# Create and start the container
container = cls._docker_client.containers.run(
f"{wm_image}:{wm_version}",
name=cls._container_name,
detach=True,
environment={
"BASE_INTERNAL_URL": f"http://{host_ip}:8000",
"MODE": "agent",
"AGENT_TOKEN": cls._agent_token
},
volumes={
"/var/run/docker.sock": {"bind": "/var/run/docker.sock", "mode": "rw"}
},
restart_policy={"Name": "unless-stopped"},
mem_limit="2g",
cpu_count=1,
)
cls._agent_container = container
print(f"Started agent container: {cls._container_name}")
@classmethod
def _wait_for_agent_connection(cls):
print("Waiting for agent to connect to the server...")
connected = False
max_attempts = 60
for attempt in range(max_attempts):
# Query server for connected workers
workers = cls._client.get_workers_list(ping_since=60)
# Check if any worker is in the "agent" worker_group
for worker in workers:
if worker.get("worker_group") == "agent":
connected = True
print(f"Agent connected with details: {worker}")
break
if connected:
break
print(f"Waiting for agent to connect... Attempt {attempt+1}/{max_attempts}")
time.sleep(1)
if not connected:
raise Exception("Agent failed to connect within the expected time")
print("Agent successfully connected!")
@classmethod
def tearDownClass(cls) -> None:
print("Cleaning up after tests...")
if hasattr(cls, "_client") and cls._client is not None:
cls._client.remove_global_custom_tag("agent_test")
if cls._docker_client is None:
try:
cls._docker_client = docker.from_env()
except Exception as e:
print(f"Error initializing Docker client for cleanup: {e}")
return
if cls._agent_container is not None:
try:
cls._agent_container.stop()
cls._agent_container.remove()
print(f"Cleaned up container using container object: {cls._container_name}")
return
except Exception as e:
print(f"Error cleaning up container using object: {e}")
try:
container = cls._docker_client.containers.get(cls._container_name)
container.stop()
container.remove()
print(f"Cleaned up container by name: {cls._container_name}")
except NotFound:
print(f"Container {cls._container_name} not found during cleanup")
except Exception as e:
print(f"Error cleaning up container by name: {e}")
print("Cleanup complete")
def test_create_agent_token(self):
token = self._agent_token
self.assertIsNotNone(token)
# JWT tokens have the format: jwt_agent_<prefix>_<token>
self.assertTrue(token.startswith("jwt_agent_"), "Token should start with jwt_agent_")
# Test that it's a valid JWT format (should contain 2 dots in the JWT part)
parts = token.split('_')
self.assertGreaterEqual(len(parts), 3, "Token should have at least 3 parts separated by underscores")
# The actual JWT is after the second underscore
jwt_part = parts[2]
self.assertEqual(jwt_part.count('.'), 2, "JWT should contain exactly 2 dots")
# Check that the token contains three base64-encoded parts
jwt_segments = jwt_part.split('.')
self.assertEqual(len(jwt_segments), 3, "JWT should have 3 segments")
for segment in jwt_segments:
self.assertGreater(len(segment), 0, "JWT segment should not be empty")
# Decode the JWT payload (second segment)
payload_base64 = jwt_segments[1]
# Add padding if needed
padding_needed = len(payload_base64) % 4
if padding_needed:
payload_base64 += '=' * (4 - padding_needed)
# Base64 decode the payload
payload_bytes = base64.urlsafe_b64decode(payload_base64)
payload_json = payload_bytes.decode('utf-8')
payload = json.loads(payload_json)
# Check payload structure
self.assertIn('worker_group', payload, "Payload should contain worker_group")
self.assertEqual(payload['worker_group'], 'agent', "worker_group should be 'agent'")
self.assertIn('suffix', payload, "Payload should contain suffix")
self.assertIsNone(payload['suffix'], "suffix should be null")
self.assertIn('tags', payload, "Payload should contain tags")
self.assertIsInstance(payload['tags'], list, "tags should be a list")
self.assertIn('agent', payload['tags'], "tags should contain 'agent'")
self.assertIn('exp', payload, "Payload should contain exp")
self.assertIsInstance(payload['exp'], int, "exp should be an integer")
def test_agent_is_connected(self):
"""Test that the agent is connected to the server."""
workers = self._client.get_workers_list(ping_since=60)
# Find the agent worker
agent_worker = None
for worker in workers:
if worker.get("worker_group") == "agent":
agent_worker = worker
break
self.assertIsNotNone(agent_worker, "Agent worker should be connected")
self.assertEqual(agent_worker.get("worker_group"), "agent")
# Check tags
tags = agent_worker.get("custom_tags", [])
self.assertIn("agent", tags)
self.assertIn("python3", tags)
self.assertIn("bash", tags)
self.assertIn("agent_test", tags)
def test_bash_script_on_agent(self):
"""Test running a bash script on the agent worker."""
# Create a simple bash script tagged to run on the agent
script_path = "u/admin/agent_bash_test"
script_content = """
#!/bin/bash
msg="$1"
echo "Running on $(hostname)"
echo "Argument received: $msg"
echo $msg
"""
# Create the script with the agent_test tag so it runs on our agent
self._client.create_script(
path=script_path,
content=script_content,
language="bash",
tag="agent_test"
)
try:
# Run the script
result = self._client.run_sync(script_path, {"msg": "Hello from agent test!"})
print(f"Script result: {result}")
# Verify the result
self.assertIsNotNone(result)
self.assertEqual(result, "Hello from agent test!")
finally:
# Clean up the script
self._client.delete_script(script_path)

View File

@@ -114,17 +114,23 @@ class WindmillClient:
raise Exception(response.content.decode())
return response.json()
def create_script(self, path: str, content: str, language: str):
def create_script(self, path: str, content: str, language: str, tag: str = None):
print(f"Creating script {path}")
payload = {
"path": path,
"content": content,
"description": "",
"summary": "",
"language": language,
}
if tag is not None:
payload["tag"] = tag
response = self._client.post(
f"/api/w/{self._workspace}/scripts/create",
json={
"path": path,
"content": content,
"description": "",
"summary": "",
"language": language,
},
json=payload,
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
@@ -252,3 +258,162 @@ class WindmillClient:
def get_version(self):
response = self._client.get("/api/version")
return response.content.decode()
def get_global_custom_tags(self):
"""
Get the current list of global custom tags.
Returns:
list: List of custom tags or empty list if not set or an error occurred.
"""
try:
response = self._client.get("/api/settings/global/custom_tags")
if response.status_code // 100 == 2:
tags = response.json()
return tags if tags is not None else []
else:
print(f"Error retrieving global custom tags: Status {response.status_code}, Response: {response.content.decode()}")
return []
except Exception as e:
print(f"Exception when retrieving global custom tags: {e}")
return []
def add_global_custom_tag(self, tag):
"""
Add a tag to the global custom tags if it's not already present.
Args:
tag (str): The tag to add to global custom tags.
Returns:
bool: True if the tag was added or already exists, False if there was an error.
"""
try:
current_tags = self.get_global_custom_tags()
if tag in current_tags:
print(f"Tag '{tag}' already exists in global custom tags")
return True
new_tags = current_tags + [tag]
print(f"Adding '{tag}' to global custom tags: {new_tags}")
response = self._client.post(
"/api/settings/global/custom_tags",
json={
"value": new_tags,
},
)
if response.status_code // 100 == 2:
print(f"Successfully added '{tag}' to global custom tags")
return True
else:
print(f"Error adding tag to global custom tags: Status {response.status_code}, Response: {response.content.decode()}")
return False
except Exception as e:
print(f"Exception when adding global custom tag: {e}")
return False
def remove_global_custom_tag(self, tag):
"""
Remove a tag from the global custom tags if it exists.
Args:
tag (str): The tag to remove from global custom tags.
Returns:
bool: True if the tag was removed or didn't exist, False if there was an error.
"""
try:
current_tags = self.get_global_custom_tags()
if tag not in current_tags:
print(f"Tag '{tag}' doesn't exist in global custom tags")
return True
new_tags = [t for t in current_tags if t != tag]
print(f"Removing '{tag}' from global custom tags: {new_tags}")
response = self._client.post(
"/api/settings/global/custom_tags",
json={
"value": new_tags,
},
)
if response.status_code // 100 == 2:
print(f"Successfully removed '{tag}' from global custom tags")
return True
else:
print(f"Error removing tag from global custom tags: Status {response.status_code}, Response: {response.content.decode()}")
return False
except Exception as e:
print(f"Exception when removing global custom tag: {e}")
return False
def get_workers_list(self, ping_since=60, page=0, per_page=100):
"""
Get a list of workers currently connected to the Windmill server.
Args:
ping_since (int): Only include workers that have pinged in the last N seconds. Default is 60.
page (int): Page number for pagination. Default is 0.
per_page (int): Number of results per page. Default is 100.
Returns:
list: List of worker objects or empty list if no workers found or an error occurred.
"""
try:
params = {
"page": page,
"per_page": per_page,
"ping_since": ping_since
}
response = self._client.get(
"/api/workers/list",
params=params
)
if response.status_code // 100 == 2:
return response.json()
else:
print(f"Error retrieving workers list: Status {response.status_code}, Response: {response.content.decode()}")
return []
except Exception as e:
print(f"Exception when retrieving workers list: {e}")
return []
def create_agent_token(self, worker_group="agent", tags=None, exp=None):
"""
Create an agent JWT token using superadmin privilege.
Args:
worker_group (str): The worker group for the agent, defaults to "agent"
tags (list): Tags for the agent, defaults to ["agent"]
exp (int): Expiration timestamp, defaults to a timestamp about 1 year in the future
Returns:
str: The JWT token for the agent
"""
if tags is None:
tags = ["agent"]
if exp is None:
exp = int(time.time()) + 31536000 # 60*60*24*365 = 1 year
print(f"Creating agent token for worker_group={worker_group}, tags={tags}")
response = self._client.post(
"/api/agent_workers/create_agent_token",
json={
"worker_group": worker_group,
"tags": tags,
"exp": exp
},
)
if response.status_code // 100 != 2:
raise Exception(response.content.decode())
token = response.content.decode().strip('"')
print(f"Created agent token: {token[:15]}...{token[-15:]}")
return token

View File

@@ -4,8 +4,8 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.485.1"
wmill_pg = ">=1.485.1"
wmill = ">=1.485.3"
wmill_pg = ">=1.485.3"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.485.1
version: 1.485.3
title: OpenFlow Spec
contact:
name: Ruben Fiszel

View File

@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.485.1'
ModuleVersion = '1.485.3'
# Supported PSEditions
# CompatiblePSEditions = @()

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.485.1"
version = "1.485.3"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill-pg"
version = "1.485.1"
version = "1.485.3"
description = "An extension client for the wmill client library focused on pg"
license = "Apache-2.0"
homepage = "https://windmill.dev"

View File

@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.485.1",
"version": "1.485.3",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./client.ts"]

View File

@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.485.1",
"version": "1.485.3",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"devDependencies": {

View File

@@ -1 +1 @@
1.485.1
1.485.3