feat: Full-text search on runs using tantivy and command palette for quick actions (#4046)

* Add indexer crate and files

* POC searcher

incomplete schema
only indexes at startup

* POC search component frontend

* Demo of the frontend element

* add Results and Args as text

* minimal functionality

* Make jump to scripts by name

also flows and apps

* Add button on sidebar to open search

* Update lock on indexer after merge

* Make arrow key navigation compatible with scrol

* Show empty result screen and log as a coming feat

* Add summary to script searchable items

* Catch `parts is undefined` error (uFuzzy)

* Index refreshing using tokio interval

* Fix JobLoader workspace being wrongly defined

* Fix click outside

* Add debouncing for completed run search

* Binary mode working + job index tracker

* Warning for no license + fix height scrollbars on content search

* Make it compile without EE files

* remove panic to use errors

* Move global search

* Cleanup UI, no more tab switcher but clear placeholders and actions

* Add tantivy feature flag for windmill-api

* Rework indexer mode

* Mac compatibility for shortcut

* Update test for new run_server

* Prepare sqlx

* Mac compatibility

* Fix openapi yaml

* Fix frontend

* Frontend api fix

* Update docker-compose.yml and caddyfile

With the (by default deactivated) container and reverse proxy to use the
windmill indexer

* fix feature flag for tests

* fix feature falg for running tests

* fix feature flag for running tests

* Make content search use search modal instead

* Add tantivy feature to ee build steps

* Remove old Content search

* change volume location for indexer

* Update dependencies

* Prepare sqlx

* Uncomment line on docker compose

* Add line between input and results

* Update ee repo ref
This commit is contained in:
wendrul
2024-07-11 11:13:27 +02:00
committed by GitHub
parent 88b61480cd
commit 7ea554a7fd
28 changed files with 1761 additions and 464 deletions
+2 -2
View File
@@ -135,7 +135,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:dev
${{ steps.meta-ee-public.outputs.tags }}
@@ -198,7 +198,7 @@ jobs:
platforms: linux/amd64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,tantivy
PYTHON_IMAGE=python:3.12.2-slim-bookworm
tags: |
${{ steps.meta-ee-public-py312.outputs.tags }}
+1
View File
@@ -2,6 +2,7 @@
bind {$ADDRESS}
reverse_proxy /ws/* http://lsp:3001
# reverse_proxy /ws_mp/* http://multiplayer:3002
# reverse_proxy /api/srch/* http://windmill_indexer:8001
reverse_proxy /* http://windmill_server:8000
# tls /certs/cert.pem /certs/key.pem
}
+2 -1
View File
@@ -4,4 +4,5 @@ oauth.json
oauth2.json
windmill-api/openapi-deref.yaml
tracing.folded
heaptrack*
heaptrack*
index/
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM queue WHERE created_at <= $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "8455e77a6a87bd0b8dbe55c854abfb3938cb9a440e9d53323a93ec9473417890"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(created_at) FROM completed_job",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "919c9432f36a6212ba79b50fbf887589bf7c37d3557cd831787b9b16b3665e47"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT CONCAT(coalesce(completed_job.logs, ''), coalesce(job_logs.logs, '')) as logs FROM completed_job\n LEFT JOIN job_logs ON job_logs.job_id = completed_job.id\n WHERE completed_job.id = $1 AND completed_job.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "logs",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null
]
},
"hash": "f036e930beff03e8a3f1f91c32267910c7d050f514dfba76991b644b86b914b9"
}
+332 -1
View File
@@ -175,6 +175,12 @@ version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "arc-swap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457"
[[package]]
name = "archiver-rs"
version = "0.5.1"
@@ -1304,6 +1310,15 @@ dependencies = [
"serde",
]
[[package]]
name = "bitpacking"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92"
dependencies = [
"crunchy",
]
[[package]]
name = "bitvec"
version = "1.0.1"
@@ -1619,6 +1634,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "census"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0"
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -2988,6 +3009,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dprint-swc-ext"
version = "0.16.0"
@@ -3209,6 +3236,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "fastdivide"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59668941c55e5c186b8b58c391629af56774ec768f73c08bbcd56f09348eb00b"
[[package]]
name = "fastrand"
version = "1.9.0"
@@ -3389,6 +3422,16 @@ dependencies = [
"syn 2.0.70",
]
[[package]]
name = "fs4"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8"
dependencies = [
"rustix 0.38.34",
"windows-sys 0.52.0",
]
[[package]]
name = "fslock"
version = "0.2.1"
@@ -3676,6 +3719,20 @@ dependencies = [
"seq-macro",
]
[[package]]
name = "generator"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186014d53bc231d0090ef8d6f03e0920c54d85a5ed22f4f2f74315ec56cf83fb"
dependencies = [
"cc",
"cfg-if",
"libc",
"log",
"rustversion",
"windows",
]
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -4010,6 +4067,12 @@ dependencies = [
"triomphe",
]
[[package]]
name = "htmlescape"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163"
[[package]]
name = "http"
version = "0.2.12"
@@ -4244,7 +4307,7 @@ dependencies = [
"iana-time-zone-haiku",
"js-sys",
"wasm-bindgen",
"windows-core",
"windows-core 0.52.0",
]
[[package]]
@@ -4531,6 +4594,12 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "levenshtein_automata"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25"
[[package]]
name = "lexical-core"
version = "0.8.5"
@@ -4720,6 +4789,20 @@ dependencies = [
"prost-types",
]
[[package]]
name = "loom"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca"
dependencies = [
"cfg-if",
"generator",
"pin-utils",
"scoped-tls",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "lru"
version = "0.12.3"
@@ -4929,6 +5012,16 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "measure_time"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbefd235b0aadd181626f281e1d684e116972988c14c264e42069d5e8a5775cc"
dependencies = [
"instant",
"log",
]
[[package]]
name = "memchr"
version = "2.7.4"
@@ -5035,6 +5128,12 @@ dependencies = [
"version_check",
]
[[package]]
name = "murmurhash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b"
[[package]]
name = "mysql-common-derive"
version = "0.31.1"
@@ -5361,6 +5460,15 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "oneshot"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071d1cf3298ad8e543dca18217d198cb6a3884443d204757b9624b935ef09fa0"
dependencies = [
"loom",
]
[[package]]
name = "onig"
version = "6.4.0"
@@ -5509,6 +5617,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
[[package]]
name = "ownedbytes"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558"
dependencies = [
"stable_deref_trait",
]
[[package]]
name = "p256"
version = "0.13.2"
@@ -6881,6 +6998,16 @@ dependencies = [
"walkdir",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54"
dependencies = [
"serde",
"serde_derive",
]
[[package]]
name = "rust_decimal"
version = "1.35.0"
@@ -7665,6 +7792,15 @@ version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
[[package]]
name = "sketches-ddsketch"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c"
dependencies = [
"serde",
]
[[package]]
name = "slab"
version = "0.4.9"
@@ -8644,6 +8780,147 @@ dependencies = [
"libc",
]
[[package]]
name = "tantivy"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8d0582f186c0a6d55655d24543f15e43607299425c5ad8352c242b914b31856"
dependencies = [
"aho-corasick",
"arc-swap",
"base64 0.22.1",
"bitpacking",
"byteorder",
"census",
"crc32fast",
"crossbeam-channel",
"downcast-rs",
"fastdivide",
"fnv",
"fs4",
"htmlescape",
"itertools 0.12.1",
"levenshtein_automata",
"log",
"lru",
"lz4_flex",
"measure_time",
"memmap2",
"num_cpus",
"once_cell",
"oneshot",
"rayon",
"regex",
"rust-stemmers",
"rustc-hash",
"serde",
"serde_json",
"sketches-ddsketch",
"smallvec",
"tantivy-bitpacker",
"tantivy-columnar",
"tantivy-common",
"tantivy-fst",
"tantivy-query-grammar",
"tantivy-stacker",
"tantivy-tokenizer-api",
"tempfile",
"thiserror",
"time",
"uuid 1.10.0",
"winapi",
]
[[package]]
name = "tantivy-bitpacker"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df"
dependencies = [
"bitpacking",
]
[[package]]
name = "tantivy-columnar"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e"
dependencies = [
"downcast-rs",
"fastdivide",
"itertools 0.12.1",
"serde",
"tantivy-bitpacker",
"tantivy-common",
"tantivy-sstable",
"tantivy-stacker",
]
[[package]]
name = "tantivy-common"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4"
dependencies = [
"async-trait",
"byteorder",
"ownedbytes",
"serde",
"time",
]
[[package]]
name = "tantivy-fst"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18"
dependencies = [
"byteorder",
"regex-syntax 0.8.4",
"utf8-ranges",
]
[[package]]
name = "tantivy-query-grammar"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82"
dependencies = [
"nom",
]
[[package]]
name = "tantivy-sstable"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e"
dependencies = [
"tantivy-bitpacker",
"tantivy-common",
"tantivy-fst",
"zstd 0.13.2",
]
[[package]]
name = "tantivy-stacker"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8"
dependencies = [
"murmurhash32",
"rand_distr",
"tantivy-common",
]
[[package]]
name = "tantivy-tokenizer-api"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04"
dependencies = [
"serde",
]
[[package]]
name = "tap"
version = "1.0.1"
@@ -9696,6 +9973,12 @@ dependencies = [
"url",
]
[[package]]
name = "utf8-ranges"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -10077,6 +10360,7 @@ dependencies = [
"windmill-api-client",
"windmill-common",
"windmill-git-sync",
"windmill-indexer",
"windmill-queue",
"windmill-worker",
]
@@ -10153,6 +10437,7 @@ dependencies = [
"windmill-audit",
"windmill-common",
"windmill-git-sync",
"windmill-indexer",
"windmill-parser",
"windmill-parser-py-imports",
"windmill-parser-ts",
@@ -10244,6 +10529,23 @@ dependencies = [
"windmill-queue",
]
[[package]]
name = "windmill-indexer"
version = "1.360.1"
dependencies = [
"anyhow",
"chrono",
"futures",
"serde",
"serde_json",
"sqlx",
"tantivy",
"tokio",
"tracing",
"uuid 1.10.0",
"windmill-common",
]
[[package]]
name = "windmill-parser"
version = "1.361.1"
@@ -10494,6 +10796,16 @@ dependencies = [
"zstd 0.12.4",
]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.52.0"
@@ -10503,6 +10815,25 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
+8 -1
View File
@@ -13,6 +13,7 @@ members = [
"./windmill-common",
"./windmill-audit",
"./windmill-git-sync",
"./windmill-indexer",
"./parsers/windmill-parser",
"./parsers/windmill-parser-ts",
"./parsers/windmill-parser-wasm",
@@ -52,6 +53,7 @@ flow_testing = ["windmill-worker/flow_testing"]
openidconnect = ["windmill-api/openidconnect"]
cloud = ["windmill-queue/cloud", "windmill-worker/cloud"]
jemalloc = ["windmill-common/jemalloc", "dep:tikv-jemallocator", "dep:tikv-jemalloc-sys", "dep:tikv-jemalloc-ctl"]
tantivy = ["windmill-indexer/tantivy"]
[dependencies]
anyhow.workspace = true
@@ -62,6 +64,7 @@ windmill-common = { workspace = true, default-features = false }
windmill-git-sync.workspace = true
windmill-api = { workspace = true, default-features = false }
windmill-worker.workspace = true
windmill-indexer.workspace = true
futures.workspace = true
tracing.workspace = true
sqlx.workspace = true
@@ -80,7 +83,8 @@ gethostname.workspace = true
serde_json.workspace = true
serde.workspace = true
deno_core.workspace = true
pg-embed = {git = "https://github.com/faokunega/pg-embed", optional = true, default-features = false, features = ['rt_tokio']}
pg-embed = {git = "https://github.com/faokunega/pg-embed", optional = true, default-features = false, features = ['rt_tokio']}
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = { optional = true, workspace = true }
@@ -103,6 +107,7 @@ windmill-worker = { path = "./windmill-worker" }
windmill-common = { path = "./windmill-common", default-features = false }
windmill-audit = { path = "./windmill-audit" }
windmill-git-sync = { path = "./windmill-git-sync" }
windmill-indexer = {path = "./windmill-indexer"}
windmill-parser = { path = "./parsers/windmill-parser" }
windmill-parser-ts = { path = "./parsers/windmill-parser-ts" }
windmill-parser-py = { path = "./parsers/windmill-parser-py" }
@@ -254,3 +259,5 @@ tikv-jemalloc-ctl = { version = "^0.5" }
# 0.1.12 broken (nested dependency of swc_common)
triomphe = "<0.1.12"
tantivy = "0.22.0"
+1 -1
View File
@@ -1 +1 @@
d414242f92cfaf9f364e9aeb321a511c3ad46fa7
49714fc57e3fc8e716f080ecf99fc71bc8e8083e
+44 -3
View File
@@ -174,6 +174,15 @@ async fn windmill_main() -> anyhow::Result<()> {
}
#[cfg(feature = "enterprise")]
Mode::Agent
} else if &x == "indexer" {
tracing::info!("Binary is in 'indexer' mode");
#[cfg(not(feature = "tantivy"))]
{
panic!("Indexer mode requires the tantivy feature flag");
}
#[cfg(feature = "tantivy")]
Mode::Indexer
} else {
if &x != "standalone" {
tracing::error!("mode not recognized, defaulting to standalone: {x}");
@@ -188,7 +197,7 @@ async fn windmill_main() -> anyhow::Result<()> {
Mode::Standalone
});
let num_workers = if mode == Mode::Server {
let num_workers = if mode == Mode::Server || mode == Mode::Indexer {
0
} else {
std::env::var("NUM_WORKERS")
@@ -207,7 +216,7 @@ async fn windmill_main() -> anyhow::Result<()> {
.ok()
.and_then(|x| x.parse::<bool>().ok())
.unwrap_or(false)
&& (mode == Mode::Server || mode == Mode::Standalone);
&& (mode == Mode::Server || mode == Mode::Standalone || mode == Mode::Indexer);
let server_bind_address: IpAddr = if server_mode {
std::env::var("SERVER_BIND_ADDR")
@@ -346,11 +355,36 @@ Windmill Community Edition {GIT_VERSION}
.await
.expect("could not create initial server dir");
#[cfg(feature = "tantivy")]
let should_index_jobs = mode == Mode::Indexer || mode == Mode::Standalone;
#[cfg(not(feature = "tantivy"))]
let should_index_jobs = false;
let (index_reader, index_writer) = if should_index_jobs {
let (r, w) = windmill_indexer::indexer_ee::init_index()?;
(Some(r), Some(w))
} else {
(None, None)
};
let indexer_rx = killpill_rx.resubscribe();
let index_writer2 = index_writer.clone();
let indexer_f = async {
if let Some(index_writer) = index_writer2 {
windmill_indexer::indexer_ee::run_indexer(db.clone(), index_writer, indexer_rx)
.await;
}
Ok(())
};
let server_f = async {
if !is_agent {
windmill_api::run_server(
db.clone(),
rsmq2,
index_reader,
index_writer,
addr,
server_killpill_rx,
base_internal_tx,
@@ -597,7 +631,14 @@ Windmill Community Edition {GIT_VERSION}
schedule_key_renewal(&HTTP_CLIENT, &db).await;
}
futures::try_join!(shutdown_signal, workers_f, monitor_f, server_f, metrics_f)?;
futures::try_join!(
shutdown_signal,
workers_f,
monitor_f,
server_f,
metrics_f,
indexer_f
)?;
} else {
tracing::info!("Nothing to do, exiting.");
}
+2
View File
@@ -122,6 +122,8 @@ impl ApiServer {
let task = tokio::task::spawn(windmill_api::run_server(
db.clone(),
None,
None,
None,
addr,
rx,
port_tx,
+2 -1
View File
@@ -27,6 +27,7 @@ windmill-parser.workspace = true
windmill-parser-py-imports.workspace = true
windmill-parser-ts.workspace = true
windmill-git-sync.workspace = true
windmill-indexer.workspace = true
tokio.workspace = true
anyhow.workspace = true
argon2.workspace = true
@@ -92,4 +93,4 @@ pin-project.workspace = true
crc.workspace = true
http.workspace = true
async-stream.workspace = true
ulid.workspace = true
ulid.workspace = true
+42
View File
@@ -8552,6 +8552,42 @@ paths:
schema:
$ref: "#/components/schemas/ExtendedJobs"
/srch/w/{workspace}/index/search/job:
get:
summary: Search through jobs with a string query
operationId: searchJobsIndex
tags:
- indexSearch
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: search_query
in: query
required: true
schema:
type: string
responses:
"200":
description: search results
content:
application/json:
schema:
type: object
properties:
query_parse_errors:
description: a list of the terms that couldn't be parsed (and thus ignored)
type: array
items:
type: object
properties:
dancer:
type: string
hits:
description: the jobs that matched the query
type: array
items:
$ref: "#/components/schemas/JobSearchHit"
components:
securitySchemes:
bearerAuth:
@@ -11085,3 +11121,9 @@ components:
required:
- jobs
- obscured_jobs
JobSearchHit:
type: object
properties:
dancer:
type: string
+5
View File
@@ -0,0 +1,5 @@
use axum::Router;
pub fn workspaced_service() -> Router {
Router::new()
}
+9
View File
@@ -57,6 +57,7 @@ mod flows;
mod folders;
mod granular_acls;
mod groups;
mod indexer_ee;
mod inputs;
mod integration;
#[cfg(feature = "parquet")]
@@ -143,6 +144,8 @@ pub async fn add_webhook_allowed_origin(
pub async fn run_server(
db: DB,
rsmq: Option<rsmq_async::MultiplexedRsmq>,
index_reader: Option<windmill_indexer::indexer_ee::IndexReader>,
index_writer: Option<windmill_indexer::indexer_ee::IndexWriter>,
addr: SocketAddr,
mut rx: tokio::sync::broadcast::Receiver<()>,
port_tx: tokio::sync::oneshot::Sender<String>,
@@ -177,6 +180,8 @@ pub async fn run_server(
.layer(Extension(rsmq))
.layer(Extension(user_db))
.layer(Extension(auth_cache.clone()))
.layer(Extension(index_reader))
.layer(Extension(index_writer))
.layer(CookieManagerLayer::new())
.layer(Extension(WebhookShared::new(rx.resubscribe(), db.clone())))
.layer(DefaultBodyLimit::max(
@@ -266,6 +271,10 @@ pub async fn run_server(
.route_layer(from_extractor::<ApiAuthed>())
.route_layer(from_extractor::<users::Tokened>())
.nest("/jobs", jobs::global_root_service())
.nest(
"/srch/w/:workspace_id/index",
indexer_ee::workspaced_service(),
)
.nest("/oidc", oidc_ee::global_service())
.nest(
"/saml",
+1
View File
@@ -183,6 +183,7 @@ pub enum Mode {
Agent,
Server,
Standalone,
Indexer,
}
pub async fn send_email(
+26
View File
@@ -0,0 +1,26 @@
[package]
name = "windmill-indexer"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_indexer"
path = "src/lib.rs"
[features]
default = []
tantivy = ["dep:tantivy"]
[dependencies]
windmill-common.workspace = true
tantivy = {workspace = true, optional = true}
tokio.workspace = true
sqlx.workspace = true
anyhow.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
chrono.workspace = true
uuid.workspace = true
futures.workspace = true
@@ -0,0 +1,21 @@
use sqlx::{Pool, Postgres};
use windmill_common::error::Error;
use anyhow::anyhow;
#[derive(Clone)]
pub struct IndexReader;
#[derive(Clone)]
pub struct IndexWriter;
pub fn init_index() -> Result<(IndexReader, IndexWriter), Error> {
Err(anyhow!("Cannot initialize index: not in EE").into())
}
pub async fn run_indexer(
_db: Pool<Postgres>,
mut _index_writer: IndexWriter,
mut _killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
tracing::error!("Cannot run indexer: not in EE");
}
+1
View File
@@ -0,0 +1 @@
pub mod indexer_ee;
+22
View File
@@ -109,6 +109,27 @@ services:
# - /var/run/docker.sock:/var/run/docker.sock
# - worker_dependency_cache:/tmp/windmill/cache
# The indexer powers full-text job and log search, an EE feature.
windmill_indexer:
image: ${WM_IMAGE}
pull_policy: always
deploy:
replicas: 0 # set to 1 to enable full-text job and log search
restart: unless-stopped
expose:
- 8001
environment:
- PORT=8001
- DATABASE_URL=${DATABASE_URL}
- MODE=indexer
#how often will new jobs be added to the index to be searched
- TANTIVY_REFRESH_INDEX_PERIOD__S=300
depends_on:
db:
condition: service_healthy
volumes:
- windmill_index:/tmp/windmill/search
lsp:
image: ghcr.io/windmill-labs/windmill-lsp:latest
pull_policy: always
@@ -147,4 +168,5 @@ volumes:
db_data: null
worker_dependency_cache: null
worker_logs: null
windmill_index: null
lsp_cache: null
@@ -1,440 +0,0 @@
<script lang="ts">
import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { clickOutside } from '$lib/utils'
import { Boxes, Code2, Edit, LayoutDashboard, Loader2, X } from 'lucide-svelte'
import Portal from 'svelte-portal'
import { twMerge } from 'tailwind-merge'
import SearchItems from './SearchItems.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './home/FlowIcon.svelte'
import { Alert, Button } from './common'
import YAML from 'yaml'
let search: string = ''
export async function open(nsearch?: string) {
isOpen = true
await Promise.all([loadScripts(), loadResources(), loadApps(), loadFlows()])
if (nsearch) {
search = nsearch
}
}
export async function loadScripts() {
scripts = await ScriptService.listSearchScript({ workspace: $workspaceStore ?? '' })
}
export async function loadResources() {
resources = await ResourceService.listSearchResource({ workspace: $workspaceStore ?? '' })
}
export async function loadApps() {
apps = await AppService.listSearchApp({ workspace: $workspaceStore ?? '' })
}
export async function loadFlows() {
flows = await FlowService.listSearchFlow({ workspace: $workspaceStore ?? '' })
}
let searchKind: 'all' | 'scripts' | 'flows' | 'apps' | 'resources' = 'all'
let isOpen = false
let inputElement
// Reactive statement to focus the input when the modal opens
$: if (isOpen && inputElement) {
// Use a timeout to ensure focus after any animations or rendering
setTimeout(() => inputElement.focus(), 0)
}
let scripts: undefined | { path: string; content: string }[] = undefined
let filteredScriptItems: { path: string; content: string; marked: any }[] = []
let resources: undefined | { path: string; value: any }[] = undefined
let filteredResourceItems: { path: string; value: any; marked: any }[] = []
let flows: undefined | { path: string; value: any }[] = undefined
let filteredFlowItems: { path: string; value: any; marked: any }[] = []
let apps: undefined | { path: string; value: any }[] = undefined
let filteredAppItems: { path: string; value: any; marked: any }[] = []
function getCounts(n: number) {
return ` (${n})`
}
function escape(htmlStr) {
return htmlStr
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
$: counts =
search == '' ||
!scripts ||
!resources ||
!flows ||
!apps ||
!filteredAppItems ||
!filteredFlowItems ||
!filteredResourceItems ||
!filteredScriptItems
? {
all: '',
apps: '',
flows: '',
resources: '',
scripts: ''
}
: {
all: getCounts(
filteredAppItems.length +
filteredFlowItems.length +
filteredResourceItems.length +
filteredScriptItems.length
),
apps: getCounts(filteredAppItems.length),
resources: getCounts(filteredResourceItems.length),
flows: getCounts(filteredFlowItems.length),
scripts: getCounts(filteredScriptItems.length)
}
let showNbScripts = 10
let showNbApps = 10
let showNbResources = 10
let showNbFlows = 10
$: search && resetShows()
function resetShows() {
showNbScripts = 10
showNbApps = 10
showNbResources = 10
showNbFlows = 10
}
</script>
<SearchItems
filter={search}
items={scripts}
f={(s) => {
return escape(s.content)
}}
bind:filteredItems={filteredScriptItems}
/>
<SearchItems
filter={search}
items={resources}
f={(s) => {
return escape(YAML.stringify(s.value))
}}
bind:filteredItems={filteredResourceItems}
/>
<SearchItems
filter={search}
items={flows}
f={(s) => {
return escape(YAML.stringify(s.value, null, 4))
}}
bind:filteredItems={filteredFlowItems}
/>
<SearchItems
filter={search}
items={apps}
f={(s) => {
return escape(YAML.stringify(s.value, null, 4))
}}
bind:filteredItems={filteredAppItems}
/>
{#if isOpen}
<Portal>
<div
class={twMerge(
`fixed top-0 bottom-0 left-0 right-0 transition-all duration-50`,
' bg-black bg-opacity-60',
'z-[1100]'
)}
>
<div
class={'max-w-4xl lg:mx-auto mx-10 mt-8 bg-surface rounded-lg relative'}
use:clickOutside={false}
on:click_outside={() => {
isOpen = false
}}
>
<div class="px-4 py-2 border-b flex justify-between items-center">
<div>Search by content</div>
<div class="w-8">
<button
on:click|stopPropagation={() => {
isOpen = false
}}
class="hover:bg-surface-hover bg-surface-secondary rounded-full w-8 h-8 flex items-center justify-center transition-all"
>
<X class="text-tertiary" />
</button>
</div>
</div>
<div class="px-2 py-2 overflow-auto">
<div class="flex gap-2 flex-wrap">
<div class="flex justify-start">
<ToggleButtonGroup bind:selected={searchKind} class="h-10">
<ToggleButton small light value="all" label={'All' + counts.all} />
<ToggleButton
small
light
value="scripts"
icon={Code2}
label={'Scripts' + counts.scripts}
/>
<ToggleButton
small
light
value="resources"
icon={Boxes}
label={'Resources' + counts.resources}
/>
<ToggleButton
small
light
value="flows"
label={'Flows' + counts.flows}
icon={FlowIcon}
selectedColor="#14b8a6"
/>
<ToggleButton
small
light
value="apps"
label={'Apps' + counts.apps}
icon={LayoutDashboard}
selectedColor="#fb923c"
/>
</ToggleButtonGroup>
</div>
<div class="relative text-tertiary grow min-w-[100px]">
<!-- svelte-ignore a11y-autofocus -->
<input
bind:this={inputElement}
placeholder="Search in the content of resources, scripts, flows and apps"
bind:value={search}
class="bg-surface !h-10 !px-4 !pr-10 !rounded-lg text-sm focus:outline-none"
/>
<button type="submit" class="absolute right-0 top-0 mt-3 mr-4">
<svg
class="h-4 w-4 fill-current"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 56.966 56.966"
style="enable-background:new 0 0 56.966 56.966;"
xml:space="preserve"
width="512px"
height="512px"
>
<path
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
/>
</svg>
</button>
</div>
</div>
<div class="mt-1">
<div class="text-xs text-secondary"
>Searching among <div class="inline-flex"
>{#if scripts}{scripts?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
scripts,
<div class="inline-flex"
>{#if resources}{resources?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
resources,
<div class="inline-flex"
>{#if flows}{flows?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
flows,
<div class="inline-flex"
>{#if apps}{apps?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
apps</div
>
</div>
<div class="mt-1 overflow-auto max-h-[80vh]">
{#if !$enterpriseLicense}
<div class="py-1" />
<Alert title="Content Search is an EE feature" type="warning">
Without EE, content search will only search among 10 scripts, 3 flows, 3 apps and 3
resources.
</Alert>
<div class="py-1" />
{/if}
{#if search.trim().length > 0}
<div class="flex flex-col gap-4">
{#if (searchKind == 'all' || searchKind == 'scripts') && filteredScriptItems?.length > 0}
{#each filteredScriptItems.slice(0, showNbScripts) ?? [] as item}
<div>
<div class="text-sm font-semibold"
><a href="/scripts/get/{item.path}">Script: {item.path}</a></div
>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
<div>
<div class="flex gap-2">
<Button
on:click|once={() => {
window
.open(`/scripts/edit/${item.path}?no_draft=true`, '_blank')
?.focus()
}}
color="light"
size="sm"
startIcon={{ icon: Edit }}
>
Edit
</Button>
</div>
</div>
</div>
</div>
{/each}
{#if filteredScriptItems.length > showNbScripts}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbScripts += 30
}}
>
({showNbScripts} of {filteredScriptItems.length}) Show more scripts
</a>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'resources') && filteredResourceItems?.length > 0}
{#each filteredResourceItems.slice(0, showNbResources) ?? [] as item}
<div>
<div class="text-sm font-semibold">Resource: {item.path}</div>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
</div>
</div>
{/each}
{#if filteredResourceItems.length > showNbResources}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbResources += 30
}}
>
({showNbResources} of {filteredResourceItems.length}) Show more resources
</a>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'flows') && filteredFlowItems?.length > 0}
{#each filteredFlowItems.slice(0, showNbFlows) ?? [] as item}
<div>
<div class="text-sm font-semibold"
><a href="/flows/get/{item.path}">Flow: {item.path}</a></div
>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
<div>
<div class="flex gap-2">
<Button
on:click|once={() => {
window
.open(`/flows/edit/${item.path}?no_draft=true`, '_blank')
?.focus()
}}
color="light"
size="sm"
startIcon={{ icon: Edit }}>Edit</Button
>
</div>
</div>
</div>
</div>
{/each}
{#if filteredFlowItems.length > showNbFlows}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbScripts += 30
}}
>
({showNbFlows} of {filteredFlowItems.length}) Show more flows
</a>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'apps') && filteredAppItems?.length > 0}
{#each filteredAppItems.slice(0, showNbApps) ?? [] as item}
<div>
<div class="text-sm font-semibold"
><a href="/apps/get/{item.path}">App: {item.path}</a></div
>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
</div>
</div>
{/each}
{#if filteredAppItems.length > showNbApps}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbApps += 30
}}
>
({showNbApps} of {filteredAppItems.length}) Show more apps
</a>
{/if}
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-48">
<div class="text-tertiary text-center">
<div class="text-2xl font-bold">Empty Search Filter</div>
<div class="text-sm"
>Start writing, search everywhere a path is referenced for instance</div
>
</div>
</div>
{/if}
</div>
</div></div
></div
></Portal
>
{/if}
@@ -0,0 +1,379 @@
<script lang="ts">
import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { Boxes, Code2, Edit, LayoutDashboard, Loader2 } from 'lucide-svelte'
import SearchItems from './SearchItems.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './home/FlowIcon.svelte'
import { Alert, Button } from './common'
import YAML from 'yaml'
export let search: string = ''
export let classNameInner = 'max-h-[80vh]'
export async function open(nsearch?: string) {
await Promise.all([loadScripts(), loadResources(), loadApps(), loadFlows()])
if (nsearch) {
search = nsearch
}
}
export async function loadScripts() {
scripts = await ScriptService.listSearchScript({ workspace: $workspaceStore ?? '' })
}
export async function loadResources() {
resources = await ResourceService.listSearchResource({ workspace: $workspaceStore ?? '' })
}
export async function loadApps() {
apps = await AppService.listSearchApp({ workspace: $workspaceStore ?? '' })
}
export async function loadFlows() {
flows = await FlowService.listSearchFlow({ workspace: $workspaceStore ?? '' })
}
let searchKind: 'all' | 'scripts' | 'flows' | 'apps' | 'resources' = 'all'
let scripts: undefined | { path: string; content: string }[] = undefined
let filteredScriptItems: { path: string; content: string; marked: any }[] = []
let resources: undefined | { path: string; value: any }[] = undefined
let filteredResourceItems: { path: string; value: any; marked: any }[] = []
let flows: undefined | { path: string; value: any }[] = undefined
let filteredFlowItems: { path: string; value: any; marked: any }[] = []
let apps: undefined | { path: string; value: any }[] = undefined
let filteredAppItems: { path: string; value: any; marked: any }[] = []
function getCounts(n: number) {
return ` (${n})`
}
function escape(htmlStr) {
return htmlStr
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
$: counts =
search == '' ||
!scripts ||
!resources ||
!flows ||
!apps ||
!filteredAppItems ||
!filteredFlowItems ||
!filteredResourceItems ||
!filteredScriptItems
? {
all: '',
apps: '',
flows: '',
resources: '',
scripts: ''
}
: {
all: getCounts(
filteredAppItems.length +
filteredFlowItems.length +
filteredResourceItems.length +
filteredScriptItems.length
),
apps: getCounts(filteredAppItems.length),
resources: getCounts(filteredResourceItems.length),
flows: getCounts(filteredFlowItems.length),
scripts: getCounts(filteredScriptItems.length)
}
let showNbScripts = 10
let showNbApps = 10
let showNbResources = 10
let showNbFlows = 10
$: search && resetShows()
function resetShows() {
showNbScripts = 10
showNbApps = 10
showNbResources = 10
showNbFlows = 10
}
</script>
<SearchItems
filter={search}
items={scripts}
f={(s) => {
return escape(s.content)
}}
bind:filteredItems={filteredScriptItems}
/>
<SearchItems
filter={search}
items={resources}
f={(s) => {
return escape(YAML.stringify(s.value))
}}
bind:filteredItems={filteredResourceItems}
/>
<SearchItems
filter={search}
items={flows}
f={(s) => {
return escape(YAML.stringify(s.value, null, 4))
}}
bind:filteredItems={filteredFlowItems}
/>
<SearchItems
filter={search}
items={apps}
f={(s) => {
return escape(YAML.stringify(s.value, null, 4))
}}
bind:filteredItems={filteredAppItems}
/>
<div class="px-2 py-2 overflow-auto">
<div class="flex gap-2 flex-wrap">
<div class="flex justify-start">
<ToggleButtonGroup bind:selected={searchKind} class="h-10">
<ToggleButton small light value="all" label={'All' + counts.all} />
<ToggleButton small light value="scripts" icon={Code2} label={'Scripts' + counts.scripts} />
<ToggleButton
small
light
value="resources"
icon={Boxes}
label={'Resources' + counts.resources}
/>
<ToggleButton
small
light
value="flows"
label={'Flows' + counts.flows}
icon={FlowIcon}
selectedColor="#14b8a6"
/>
<ToggleButton
small
light
value="apps"
label={'Apps' + counts.apps}
icon={LayoutDashboard}
selectedColor="#fb923c"
/>
</ToggleButtonGroup>
</div>
<div class="relative text-tertiary grow min-w-[100px]">
<slot name="input-slot" />
<button type="submit" class="absolute right-0 top-0 mt-3 mr-4">
<svg
class="h-4 w-4 fill-current"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
id="Capa_1"
x="0px"
y="0px"
viewBox="0 0 56.966 56.966"
style="enable-background:new 0 0 56.966 56.966;"
xml:space="preserve"
width="512px"
height="512px"
>
<path
d="M55.146,51.887L41.588,37.786c3.486-4.144,5.396-9.358,5.396-14.786c0-12.682-10.318-23-23-23s-23,10.318-23,23 s10.318,23,23,23c4.761,0,9.298-1.436,13.177-4.162l13.661,14.208c0.571,0.593,1.339,0.92,2.162,0.92 c0.779,0,1.518-0.297,2.079-0.837C56.255,54.982,56.293,53.08,55.146,51.887z M23.984,6c9.374,0,17,7.626,17,17s-7.626,17-17,17 s-17-7.626-17-17S14.61,6,23.984,6z"
/>
</svg>
</button>
</div>
</div>
<div class="mt-1">
<div class="text-xs text-secondary"
>Searching among <div class="inline-flex"
>{#if scripts}{scripts?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
scripts,
<div class="inline-flex"
>{#if resources}{resources?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
resources,
<div class="inline-flex"
>{#if flows}{flows?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
flows,
<div class="inline-flex"
>{#if apps}{apps?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}</div
>
apps</div
>
</div>
<div class="mt-1 overflow-auto {classNameInner}">
{#if !$enterpriseLicense}
<div class="py-1" />
<Alert title="Content Search is an EE feature" type="warning">
Without EE, content search will only search among 10 scripts, 3 flows, 3 apps and 3
resources.
</Alert>
<div class="py-1" />
{/if}
{#if search.trim().length > 0}
<div class="flex flex-col gap-4">
{#if (searchKind == 'all' || searchKind == 'scripts') && filteredScriptItems?.length > 0}
{#each filteredScriptItems.slice(0, showNbScripts) ?? [] as item}
<div>
<div class="text-sm font-semibold"
><a href="/scripts/get/{item.path}">Script: {item.path}</a></div
>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
<div>
<div class="flex gap-2">
<Button
on:click|once={() => {
window.open(`/scripts/edit/${item.path}?no_draft=true`, '_blank')?.focus()
}}
color="light"
size="sm"
startIcon={{ icon: Edit }}
>
Edit
</Button>
</div>
</div>
</div>
</div>
{/each}
{#if filteredScriptItems.length > showNbScripts}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbScripts += 30
}}
>
({showNbScripts} of {filteredScriptItems.length}) Show more scripts
</a>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'resources') && filteredResourceItems?.length > 0}
{#each filteredResourceItems.slice(0, showNbResources) ?? [] as item}
<div>
<div class="text-sm font-semibold">Resource: {item.path}</div>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
</div>
</div>
{/each}
{#if filteredResourceItems.length > showNbResources}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbResources += 30
}}
>
({showNbResources} of {filteredResourceItems.length}) Show more resources
</a>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'flows') && filteredFlowItems?.length > 0}
{#each filteredFlowItems.slice(0, showNbFlows) ?? [] as item}
<div>
<div class="text-sm font-semibold"
><a href="/flows/get/{item.path}">Flow: {item.path}</a></div
>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
<div>
<div class="flex gap-2">
<Button
on:click|once={() => {
window.open(`/flows/edit/${item.path}?no_draft=true`, '_blank')?.focus()
}}
color="light"
size="sm"
startIcon={{ icon: Edit }}>Edit</Button
>
</div>
</div>
</div>
</div>
{/each}
{#if filteredFlowItems.length > showNbFlows}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbScripts += 30
}}
>
({showNbFlows} of {filteredFlowItems.length}) Show more flows
</a>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'apps') && filteredAppItems?.length > 0}
{#each filteredAppItems.slice(0, showNbApps) ?? [] as item}
<div>
<div class="text-sm font-semibold"
><a href="/apps/get/{item.path}">App: {item.path}</a></div
>
<div class="flex gap-2 justify-between">
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full max-w-2xl"
><code>{@html item.marked}</code></pre
>
</div>
</div>
{/each}
{#if filteredAppItems.length > showNbApps}
<a
href="#"
class="text-center font-semibold cursor-pointer pb-40"
on:click={() => {
showNbApps += 30
}}
>
({showNbApps} of {filteredAppItems.length}) Show more apps
</a>
{/if}
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-48">
<div class="text-tertiary text-center">
<div class="text-2xl font-bold">Empty Search Filter</div>
<div class="text-sm"
>Start writing, search everywhere a path is referenced for instance</div
>
</div>
</div>
{/if}
</div>
</div>
+3 -8
View File
@@ -15,7 +15,7 @@
VariableService
} from '$lib/gen'
import { superadmin, userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, getContext } from 'svelte'
import { writable } from 'svelte/store'
import { Alert, Button, Drawer, DrawerContent } from './common'
import Badge from './common/badge/Badge.svelte'
@@ -26,7 +26,6 @@
import Required from './Required.svelte'
import Tooltip from './Tooltip.svelte'
import { Eye, Folder, Plus, SearchCode, User } from 'lucide-svelte'
import ContentSearch from './ContentSearch.svelte'
type PathKind = 'resource' | 'script' | 'variable' | 'flow' | 'schedule' | 'app' | 'raw_app'
let meta: Meta | undefined = undefined
@@ -263,13 +262,9 @@
!dirty && (dirty = true)
}
let contentSearch: ContentSearch
const openSearchWithPrefilledText: (t?: string) => void = getContext("openSearchWithPrefilledText")
</script>
{#if kind != 'app' && kind != 'schedule' && initialPath != '' && initialPath != undefined}
<ContentSearch bind:this={contentSearch} />
{/if}
<Drawer bind:this={newFolder}>
<DrawerContent
title="New Folder"
@@ -462,7 +457,7 @@
variant="border"
color="dark"
on:click={() => {
contentSearch?.open(initialPath)
openSearchWithPrefilledText("#")
}}
startIcon={{ icon: SearchCode }}
>
@@ -37,13 +37,13 @@
import { canWrite, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { page } from '$app/stores'
import { setQuery } from '$lib/navigation'
import ContentSearch from '../ContentSearch.svelte'
import Drawer from '../common/drawer/Drawer.svelte'
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import Item from './Item.svelte'
import TreeViewRoot from './TreeViewRoot.svelte'
import { Popup } from '../common'
import { getContext } from 'svelte'
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
canWrite: boolean
@@ -289,7 +289,7 @@
$: storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
$: storeLocalSetting(INCLUDE_WITHOUT_MAIN_SETTING_NAME, includeWithoutMain ? 'true' : undefined)
let contentSearch: ContentSearch
const openSearchWithPrefilledText: (t?: string) => void = getContext("openSearchWithPrefilledText")
let viewCodeDrawer: Drawer
let viewCodeTitle: string | undefined
@@ -332,7 +332,6 @@
</DrawerContent>
</Drawer>
<ContentSearch bind:this={contentSearch} />
<CenteredPage>
<div class="flex flex-wrap gap-2 items-center justify-between w-full mt-2">
<div class="flex justify-start">
@@ -397,7 +396,7 @@
</button>
</div>
<Button
on:click={() => contentSearch?.open()}
on:click={() => openSearchWithPrefilledText("#")}
variant="border"
size="sm"
spacingSize="lg"
@@ -0,0 +1,651 @@
<script lang="ts">
import { onDestroy, onMount, tick } from 'svelte'
import {
AppService,
FlowService,
IndexSearchService,
RawAppService,
ScriptService,
type Flow,
type ListableApp,
type ListableRawApp,
type Script
} from '$lib/gen'
import { clickOutside, displayDateOnly, isMac, sendUserToast } from '$lib/utils'
import TimeAgo from '../TimeAgo.svelte'
import {
BoxesIcon,
CalendarIcon,
Code2Icon,
DollarSignIcon,
HomeIcon,
LayoutDashboardIcon,
Loader2,
PlayIcon,
Search,
SearchCode
} from 'lucide-svelte'
import JobPreview from '../runs/JobPreview.svelte'
import Portal from 'svelte-portal'
import { twMerge } from 'tailwind-merge'
import ContentSearchInner from '../ContentSearchInner.svelte'
import { goto } from '$app/navigation'
import QuickMenuItem from '../search/QuickMenuItem.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import uFuzzy from '@leeoniya/ufuzzy'
import BarsStaggered from '../icons/BarsStaggered.svelte'
import { scroll_into_view_if_needed_polyfill } from '../multiselect/utils'
import { Alert } from '../common'
let open: boolean = false
let searchTerm: string = ''
let textInput: HTMLInputElement
let selectedWorkspace: string | undefined = undefined
let contentSearch: ContentSearchInner | undefined = undefined
const RUNS_PREFIX = '>'
const LOGS_PREFIX = '!'
const CONTENT_SEARCH_PREFIX = '#'
const SWITCH_MODE_PREFIX = '?'
type SearchMode = 'default' | 'switch-mode' | 'runs' | 'content' | 'logs'
let tab: SearchMode = 'default'
type quickMenuItem = {
search_id: string
label: string
action: () => void
icon?: any
shortcutKey?: string
}
let switchModeItems: quickMenuItem[] = [
{
search_id: 'switchto:run-search',
label: 'Search across completed runs',
action: () => switchMode('runs'),
shortcutKey: RUNS_PREFIX,
icon: Search
},
{
search_id: 'switchto:content-search',
label: 'Search scripts/flows/apps based on content',
action: () => switchMode('content'),
shortcutKey: CONTENT_SEARCH_PREFIX,
icon: SearchCode
},
{
search_id: 'switchto:log-search',
label: 'Search windmill logs',
action: () => switchMode('logs'),
shortcutKey: LOGS_PREFIX,
icon: Search
}
]
let defaultMenuItems: quickMenuItem[] = [
{ search_id: 'nav:home', label: 'Go to Home', action: () => gotoPage('/'), icon: HomeIcon },
{ search_id: 'nav:runs', label: 'Go to Runs', action: () => gotoPage('/runs'), icon: PlayIcon },
{
search_id: 'nav:variables',
label: 'Go to Variables',
action: () => gotoPage('/variables'),
icon: DollarSignIcon
},
{
search_id: 'nav:resources',
label: 'Go to Resources',
action: () => gotoPage('/resources'),
icon: BoxesIcon
},
{
search_id: 'nav:schedules',
label: 'Go to Schedules',
action: () => gotoPage('/schedules'),
icon: CalendarIcon
},
...switchModeItems
]
let itemMap = {
default: defaultMenuItems as any[],
'switch-mode': switchModeItems,
runs: [] as any[],
content: [] as any[],
logs: [] as any[]
}
$: tab === 'content' && contentSearch?.open()
async function switchPrompt(tab: string) {
if (tab === 'default') {
searchTerm = ''
}
if (tab === 'runs') {
searchTerm = RUNS_PREFIX
}
if (tab === 'content') {
searchTerm = CONTENT_SEARCH_PREFIX
}
if (tab === 'switch-mode') {
searchTerm = SWITCH_MODE_PREFIX
}
if (tab === 'logs') {
searchTerm = LOGS_PREFIX
}
selectedItem = selectItem(0)
textInput.focus()
}
function removePrefix(str: string, prefix: string): string {
if (str.startsWith(prefix)) {
return str.substring(prefix.length)
}
return str
}
let opts: uFuzzy.Options = {}
let uf = new uFuzzy(opts)
let defaultMenuItemLabels = defaultMenuItems.map((item) => item.label)
let switchModeItemLabels = switchModeItems.map((item) => item.label)
function fuzzyFilter(filter: string, items: any[], itemsPlainText: string[]) {
if (filter === '') {
return items
}
let idxs = uf.filter(itemsPlainText, filter) ?? []
let info: uFuzzy.Info
// parts is undefined error happens when filter is similar
// to `.>!` (string with no letters but some symbols)
try {
info = uf.info(idxs, itemsPlainText, filter)
} catch (e) {
return items
}
let order = uf.sort(info, itemsPlainText, filter)
let r: any[] = []
for (let o of order) {
r.push(items[info.idx[o]])
}
return r
}
let debounceTimeout: any = undefined
const debouncePeriod: number = 1000
let loadingCompletedRuns: boolean = false
async function handleSearch() {
if (
tab !== 'default' &&
(searchTerm === '' ||
![RUNS_PREFIX, LOGS_PREFIX, CONTENT_SEARCH_PREFIX, SWITCH_MODE_PREFIX].includes(
searchTerm[0]
))
) {
_switchMode('default')
}
if (tab != 'switch-mode' && searchTerm.length > 0 && searchTerm[0] === SWITCH_MODE_PREFIX) {
_switchMode('switch-mode')
}
if (tab != 'logs' && searchTerm.length > 0 && searchTerm[0] === LOGS_PREFIX) {
_switchMode('logs')
}
if (tab != 'runs' && searchTerm.length > 0 && searchTerm[0] === RUNS_PREFIX) {
_switchMode('runs')
}
if (tab != 'content' && searchTerm.length > 0 && searchTerm[0] === CONTENT_SEARCH_PREFIX) {
_switchMode('content')
}
if (tab === 'default') {
itemMap['default'] = fuzzyFilter(searchTerm, defaultMenuItems, defaultMenuItemLabels)
if (combinedItems) {
itemMap['default'] = itemMap['default'].concat(
fuzzyFilter(
searchTerm,
combinedItems,
combinedItems.map((i) => `${i.path} ${i.summary}`)
)
)
}
}
if (tab === 'switch-mode') {
itemMap['switch-mode'] = fuzzyFilter(
removePrefix(searchTerm, SWITCH_MODE_PREFIX),
switchModeItems,
switchModeItemLabels
)
}
if (tab === 'runs') {
const s = removePrefix(searchTerm, RUNS_PREFIX)
clearTimeout(debounceTimeout)
loadingCompletedRuns = true
debounceTimeout = setTimeout(async () => {
clearTimeout(debounceTimeout)
let searchResults
try {
searchResults = await IndexSearchService.searchJobsIndex({
searchQuery: s,
workspace: $workspaceStore!
})
itemMap['runs'] = searchResults.hits
} catch (e) {
sendUserToast(e, true)
}
loadingCompletedRuns = false
selectedItem = selectItem(0)
}, debouncePeriod)
}
selectedItem = selectItem(0)
}
function selectItem(index: number) {
if (!itemMap[tab] || itemMap[tab].length <= index) {
return undefined
}
onHover(itemMap[tab][index])
return itemMap[tab][index]
}
let selectedItem: any
async function handleKeydown(event: KeyboardEvent) {
if ((!isMac() ? event.ctrlKey : event.metaKey) && event.key === 'k') {
event.preventDefault()
await openModal()
}
if (open) {
if (event.key === 'Escape') {
event.preventDefault()
if (searchTerm.length != 0 || tab != 'default') {
switchMode('default')
textInput?.focus()
} else {
open = false
}
}
if (event.key === 'ArrowDown') {
event.preventDefault()
let idx = itemMap[tab].indexOf(selectedItem)
if (idx != -1) {
idx = (idx + 1) % itemMap[tab].length
selectedItem = selectItem(idx)
let el = document.getElementById(selectedItem.search_id)
if (el) scroll_into_view_if_needed_polyfill(el, false)
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
let idx = itemMap[tab].indexOf(selectedItem)
if (idx != -1) {
idx = (idx - 1 + itemMap[tab].length) % itemMap[tab].length
selectedItem = selectItem(idx)
let el = document.getElementById(selectedItem.search_id)
if (el) scroll_into_view_if_needed_polyfill(el, false)
}
}
}
}
//internal, should not be called outside of the handleSearch function
function _switchMode(mode: SearchMode) {
selectedItem = undefined
tab = mode
}
// Used by callbacks, call this to change the mode
function switchMode(mode: SearchMode) {
switchPrompt(mode)
textInput.focus()
}
function gotoWindmillItemPage(e: TableAny) {
let path: string
switch (e.type) {
case 'flow':
path = `/flows/get/${e.path}`
break
case 'script':
path = `/scripts/get/${e.path}`
break
case 'app':
path = `/apps/get/${e.path}`
break
case 'raw_app':
path = `/raw_apps/get/${e.path}`
break
default:
path = '/'
}
gotoPage(path)
}
function gotoPage(path: string) {
open = false
searchTerm = ''
goto(path)
}
onMount(() => {
window.addEventListener('keydown', handleKeydown)
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
$: searchTerm, handleSearch()
function placeholderFromPrefix(text: string): string {
switch (text) {
case '':
return ' Search or type `?` for search options'
case RUNS_PREFIX:
return ' Search across completed runs'
case LOGS_PREFIX:
return ' Search across completed runs'
case CONTENT_SEARCH_PREFIX:
return ' Search flows/scripts/apps by content'
default:
return ''
}
}
type TableItem<T, U extends 'script' | 'flow' | 'app' | 'raw_app'> = T & {
search_id: string
marked?: string
type?: U
time?: number
starred?: boolean
has_draft?: boolean
}
// interface SelectableSearchMenuItem {
// search_id: string
// }
type TableScript = TableItem<Script, 'script'>
type TableFlow = TableItem<Flow, 'flow'>
type TableApp = TableItem<ListableApp, 'app'>
type TableRawApp = TableItem<ListableRawApp, 'raw_app'>
type TableAny = TableScript | TableFlow | TableApp | TableRawApp
let combinedItems: TableAny[] | undefined = undefined
async function fetchCombinedItems() {
const scripts = await ScriptService.listScripts({
workspace: $workspaceStore!
})
const flows = await FlowService.listFlows({
workspace: $workspaceStore!
})
const apps = await AppService.listApps({ workspace: $workspaceStore! })
const raw_apps = await RawAppService.listRawApps({ workspace: $workspaceStore! })
let combinedItems: (TableScript | TableFlow | TableApp | TableRawApp)[] | undefined = [
...flows.map((x) => ({
...x,
type: 'flow' as 'flow',
time: new Date(x.edited_at).getTime(),
search_id: x.path
})),
...scripts.map((x) => ({
...x,
type: 'script' as 'script',
time: new Date(x.created_at).getTime(),
search_id: x.path
})),
...apps.map((x) => ({
...x,
type: 'app' as 'app',
time: new Date(x.edited_at).getTime(),
search_id: x.path
})),
...raw_apps.map((x) => ({
...x,
type: 'raw_app' as 'raw_app',
time: new Date(x.edited_at).getTime(),
search_id: x.path
}))
].sort((a, b) => (a.starred != b.starred ? (a.starred ? -1 : 1) : a.time - b.time > 0 ? -1 : 1))
return combinedItems
}
function iconForWindmillItem(type: string) {
switch (type) {
case 'flow':
return BarsStaggered
case 'script':
return Code2Icon
case 'app':
return LayoutDashboardIcon
case 'raw_app':
return LayoutDashboardIcon
}
}
function onHover(selectedItem: any) {
if (tab === 'runs') {
selectedWorkspace = selectedItem?.document?.workspace_id[0]
}
}
export async function openSearchWithPrefilledText(text?: string) {
await openModal()
searchTerm = text ?? searchTerm
await handleSearch()
}
async function openModal() {
open = !open
await tick()
if (open) {
if (combinedItems == undefined) {
combinedItems = await fetchCombinedItems()
handleSearch()
}
selectedItem = selectItem(0)
textInput.focus()
textInput.select()
}
}
</script>
{#if open}
<Portal>
<div
class={twMerge(
`fixed top-0 bottom-0 left-0 right-0 transition-all duration-50`,
' bg-black bg-opacity-40',
'z-[1100]'
)}
>
<div
class={'max-w-4xl lg:mx-auto mx-10 mt-40 bg-surface rounded-lg relative'}
use:clickOutside={false}
on:click_outside={() => {
open = false
}}
>
<div class="py-2 items-center">
<div class="px-4 flex flex-row gap-1 items-center pb-1 mb-2 border-b">
<Search />
<div class="relative inline-block w-full">
<input
id="quickSearchInput"
bind:this={textInput}
type="text"
class="quick-search-input"
bind:value={searchTerm}
/>
<label
for="quickSearchInput"
class="absolute top-1/2 left-2 transform -translate-y-1/2 pointer-events-none text-gray-400 transition-all duration-200 whitespace-pre"
>{placeholderFromPrefix(searchTerm)}</label
>
</div>
</div>
<div class="px-4 overflow-scroll max-h-[30rem]">
{#if tab === 'default' || tab === 'switch-mode'}
{#each (itemMap[tab] ?? []).filter((e) => defaultMenuItems.includes(e)) as el}
<QuickMenuItem
on:select={el?.action}
on:hover={() => (selectedItem = el)}
id={el?.search_id}
hovered={el?.search_id === selectedItem?.search_id}
label={el?.label}
icon={el?.icon}
shortcutKey={el?.shortcutKey}
/>
{/each}
{/if}
{#if tab === 'default'}
{#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0}
<div class="mt-2 pt-2 pb-1 px-1 text-xs text-sm font-bold border-t text-tertiary"
>Flows/Scripts/Apps</div
>
{/if}
{#each (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)) as el}
<QuickMenuItem
on:select={() => gotoWindmillItemPage(el)}
on:hover={() => (selectedItem = el)}
id={el?.search_id}
hovered={el?.path === selectedItem?.path}
label={(el.summary ? `${el.summary} - ` : '') +
el.path +
(el.starred ? ' ★' : '')}
icon={iconForWindmillItem(el.type)}
/>
{/each}
{#if (itemMap[tab] ?? []).length === 0}
<div class="flex w-full justify-center items-center h-48">
<div class="text-tertiary text-center">
<div class="text-2xl font-bold">Nothing found</div>
<div class="text-sm">Tip: press `esc` to quickly clear the search bar</div>
</div>
</div>
{/if}
{:else if tab === 'content'}
<ContentSearchInner
classNameInner="max-h-[20rem]"
search={removePrefix(searchTerm, '#')}
bind:this={contentSearch}
/>
{:else if tab === 'logs'}
<div class="p-2">
<Alert title="Service log search is coming soon" type="info">
Full text search on windmill's service logs is coming soon
</Alert>
</div>
{:else if tab === 'runs'}
<div class="flex h-96">
{#if loadingCompletedRuns}
<div class="flex w-full justify-center items-center h-48">
<div class="text-tertiary text-center">
<Loader2 size={34} class="animate-spin" />
</div>
</div>
{:else if itemMap['runs'] && itemMap['runs'].length > 0}
<div class="w-5/12 overflow-scroll">
{#each itemMap['runs'] ?? [] as r}
<QuickMenuItem
on:hover={() => {
selectedItem = r
selectedWorkspace = r?.document.workspace_id[0]
}}
on:select={() => {
open = false
goto(`/run/${r?.document.id[0]}`)
}}
id={r?.document.id[0]}
hovered={selectedItem && r?.document.id[0] === selectedItem?.document.id[0]}
icon={r?.icon}
>
<svelte:fragment slot="itemReplacement">
<button
class={twMerge(
`w-full flex items-center justify-between gap-1 py-2 px-2 text-left border rounded-sm transition-a`,
r?.document.id === selectedItem?.document?.id
? 'bg-surface-hover'
: ''
)}
on:click={() => {}}
>
<div
class="w-full h-full items-center text-xs font-normal grid grid-cols-10 gap-0 min-w-0"
>
<div class="col-span-1">
<div
class="rounded-full w-2 h-2 {r?.document.success[0]
? 'bg-green-400'
: 'bg-red-400'}"
/>
</div>
<div class="col-span-5">
{r?.document.script_path}
</div>
<div
class="whitespace-nowrap col-span-2 !text-tertiary !text-2xs overflow-hidden text-ellipsis flex-shrink text-center"
>
{displayDateOnly(new Date(r?.document.created_at[0]))}
</div>
<div
class="whitespace-nowrap col-span-2 !text-tertiary !text-2xs overflow-hidden text-ellipsis flex-shrink text-center"
>
<TimeAgo date={r?.document.created_at[0] ?? ''} />
</div>
</div>
</button>
</svelte:fragment>
</QuickMenuItem>
{/each}
</div>
{#if selectedItem === undefined}
select a result to preview
{:else}
<div class="w-7/12 overflow-y-scroll">
<JobPreview
id={selectedItem?.document?.id[0]}
workspace={selectedWorkspace}
/>
</div>
{/if}
{:else}
<div class="flex w-full justify-center items-center h-96">
<div class="text-tertiary text-center">
<div class="text-2xl font-bold">No runs found</div>
<div class="text-sm">There were no completed runs that match your query</div>
<div class="text-sm"
>Note that new runs might take a while to become searchable (by default
~5min)</div
>
{#if !$enterpriseLicense}
<div class="py-6" />
<Alert title="This is an EE feature" type="warning">
Full-text search on jobs is only available on EE.
</Alert>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
</Portal>
{/if}
<style>
.quick-search-input {
outline: none;
border: none !important;
box-shadow: none !important;
}
.quick-search-input:focus-visible {
outline: none !important;
}
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import { isMac } from '$lib/utils'
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
import { twMerge } from 'tailwind-merge'
export let hovered: boolean = false
export let id: string
export let label: string = ''
export let icon: any = undefined
export let shortcutKey: string | undefined = undefined
const dispatch = createEventDispatcher()
onMount(() => {
window.addEventListener('keydown', handleKeydown)
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
async function handleKeydown(event: KeyboardEvent) {
if (hovered && event.key === 'Enter') {
event.preventDefault()
runAction()
}
}
function runAction() {
dispatch('select')
}
export let kbdClass = ''
export let small = true
if (small) {
kbdClass = twMerge(
kbdClass,
'!text-[10px] px-1',
false && isMac() ? '!text-lg ' : 'text-xs',
'leading-none'
)
} else {
kbdClass += ' !text-xs px-1.5'
}
</script>
<div
{id}
on:click|stopPropagation={runAction}
on:mouseenter={() => dispatch('hover')}
class={`rounded-md w-full ${hovered ? 'bg-surface-hover' : ''}`}
>
{#if $$slots.itemReplacement}
<slot name="itemReplacement" />
{:else}
<div class="flex flex-row gap-2 items-center px-1 py-0.5 rounded-md pr-6 font-light">
<div class="w-4">
{#if icon}
<svelte:component this={icon} size={14} />
{:else if shortcutKey != undefined}
<div class="font-bold flex items-center justify-center w-full">
<span
class="h-4 center-center ml-0.5 rounded border bg-surface-secondary text-primary shadow-sm font-light transition-all group-hover:border-primary-500 group-hover:text-primary-inverse"
>
<kbd class={kbdClass}>
{shortcutKey}
</kbd>
</span>
</div>
{/if}
</div>
{label}
{#if shortcutKey != undefined}
<div class="ml-auto">
<div class="font-bold flex items-center justify-center w-full">
<span
class="flex h-4 center-center ml-0.5 rounded border bg-surface-secondary text-primary shadow-sm font-light transition-all group-hover:border-primary-500 group-hover:text-primary-inverse"
>
<kbd class={kbdClass}>
{shortcutKey}
</kbd>
</span>
</div>
</div>
{/if}
</div>
{/if}
</div>
@@ -1,17 +1,26 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import Popover from '../Popover.svelte'
import { createEventDispatcher } from 'svelte'
export let label: string | undefined = undefined
export let icon: any | undefined = undefined
export let isCollapsed: boolean
export let disabled: boolean = false
export let lightMode: boolean = false
export let stopPropagationOnClick: boolean = false
export let shortcut: string = ""
let dispatch = createEventDispatcher()
</script>
{#if !disabled}
<Popover appearTimeout={0} disappearTimeout={0} class="w-full" disablePopup={!isCollapsed}>
<button
on:click={(e) => {
if (stopPropagationOnClick) e.preventDefault()
dispatch('click')
}}
class={twMerge(
'group flex items-center px-2 py-2 font-light rounded-md h-8 gap-3 w-full',
lightMode
@@ -46,6 +55,9 @@
)}
>
{label}
<span class="pl-2 text-xs dark:text-secondary light:text-secondary-inverse font-semibold">
{shortcut}
</span>
</span>
{/if}
</button>
@@ -11,7 +11,7 @@
UserService,
WorkspaceService
} from '$lib/gen'
import { classNames } from '$lib/utils'
import { classNames, getModifierKey } from '$lib/utils'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
import {
@@ -37,14 +37,18 @@
import { SUPERADMIN_SETTINGS_HASH, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { isCloudHosted } from '$lib/cloud'
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { ArrowLeft } from 'lucide-svelte'
import { ArrowLeft, Search } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { workspacedOpenai } from '$lib/components/copilot/lib'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import { setContext } from 'svelte'
OpenAPI.WITH_CREDENTIALS = true
let menuOpen = false
let globalSearchModal: GlobalSearchModal | undefined = undefined
let isCollapsed = false
let userSettings: UserSettings
let superadminSettings: SuperadminSettings
@@ -244,6 +248,12 @@
$: if (isCollapsed && $userStore?.operator) {
isCollapsed = false
}
function openSearchModal(text?: string): void {
globalSearchModal?.openSearchWithPrefilledText(text)
}
setContext("openSearchWithPrefilledText", openSearchModal)
</script>
<svelte:window bind:innerWidth />
@@ -258,6 +268,7 @@
</div>
</CenteredModal>
{:else if $userStore}
<GlobalSearchModal bind:this={globalSearchModal} />
{#if $superadmin}
<SuperadminSettings bind:this={superadminSettings} />
{/if}
@@ -328,6 +339,15 @@
<div class="px-2 py-4 space-y-2 border-y border-gray-500">
<WorkspaceMenu />
<FavoriteMenu {favoriteLinks} />
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
isCollapsed={false}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
</div>
<SidebarContent isCollapsed={false} />
@@ -367,6 +387,15 @@
<div class="px-2 py-4 space-y-2 border-y border-gray-700">
<WorkspaceMenu {isCollapsed} />
<FavoriteMenu {favoriteLinks} {isCollapsed} />
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
</div>
<SidebarContent {isCollapsed} />
@@ -446,6 +475,15 @@
<div class="px-2 py-4 space-y-2 border-y border-gray-500">
<WorkspaceMenu />
<FavoriteMenu {favoriteLinks} />
<MenuButton
stopPropagationOnClick={true}
on:click={() => openSearchModal()}
{isCollapsed}
icon={Search}
label="Search"
class="!text-xs"
shortcut={`${getModifierKey()}k`}
/>
</div>
<SidebarContent {isCollapsed} />