feat: Broad filters for search (#8112)

* Default filters

* nit style

* Nit fixes

* broadFilter for runs

* unused param

* Remove debounce from assets page

* Escape ILIKE patterns

* fix pg enum cast to text for ILIKE

* nit error
This commit is contained in:
Diego Imbert
2026-02-26 14:03:46 +00:00
committed by GitHub
parent 82a574a980
commit 34844e5091
23 changed files with 308 additions and 124 deletions
+16 -2
View File
@@ -10,6 +10,7 @@ use windmill_common::{
assets::{AssetKind, AssetUsageKind},
db::UserDB,
error::JsonResult,
utils::escape_ilike_pattern,
};
use windmill_api_auth::ApiAuthed;
@@ -34,6 +35,7 @@ struct ListAssetsQuery {
pub path: Option<String>,
// Filter by matching a subset of the columns using base64 encoded json subset
pub columns: Option<String>,
pub broad_filter: Option<String>,
}
fn default_per_page() -> i64 {
@@ -128,6 +130,14 @@ async fn list_assets(
asset_summary_filters.push(format!("asset.kind = ANY(${})", param_count));
}
if query.broad_filter.is_some() {
param_count += 1;
asset_summary_filters.push(format!(
"(asset.path ILIKE ${p} OR asset.kind::text ILIKE ${p})",
p = param_count
));
}
let asset_summary_where = asset_summary_filters.join(" AND ");
// Build cursor condition
@@ -224,7 +234,7 @@ async fn list_assets(
let mut query_builder = sqlx::query(&sql).bind(&w_id).bind(limit);
if let Some(ref asset_path) = query.asset_path {
query_builder = query_builder.bind(format!("%{}%", asset_path));
query_builder = query_builder.bind(format!("%{}%", escape_ilike_pattern(asset_path)));
}
if let Some(ref path) = query.path {
@@ -242,7 +252,7 @@ async fn list_assets(
}
if let Some(ref usage_path) = query.usage_path {
query_builder = query_builder.bind(format!("%{}%", usage_path));
query_builder = query_builder.bind(format!("%{}%", escape_ilike_pattern(usage_path)));
}
if let Some(ref asset_kinds) = asset_kinds {
@@ -251,6 +261,10 @@ async fn list_assets(
}
}
if let Some(ref broad_filter) = query.broad_filter {
query_builder = query_builder.bind(format!("%{}%", escape_ilike_pattern(broad_filter)));
}
if let (Some(cursor_created_at), Some(cursor_id)) = (query.cursor_created_at, query.cursor_id) {
query_builder = query_builder.bind(cursor_created_at).bind(cursor_id);
}
@@ -225,6 +225,7 @@ async fn get_concurrent_intervals(
allow_wildcards: None,
trigger_kind: _,
include_args: _,
broad_filter: _,
} => true,
_ => false,
};
+20 -1
View File
@@ -10,7 +10,7 @@
use sql_builder::prelude::*;
use sql_builder::SqlBuilder;
use windmill_common::utils::{paginate_without_limits, Pagination};
use windmill_common::utils::{escape_ilike_pattern, paginate_without_limits, Pagination};
use crate::types::{ListCompletedQuery, ListQueueQuery};
@@ -229,6 +229,14 @@ pub fn filter_list_queue_query(
}
}
if let Some(bf) = &lq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(bf));
sqlb.and_where(
"(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ?)"
.bind(&pat).bind(&pat).bind(&pat).bind(&pat)
);
}
sqlb
}
@@ -524,6 +532,15 @@ pub fn filter_list_completed_query(
}
}
if let Some(bf) = &lq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(bf));
sqlb.and_where(
"(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ? \
OR EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl ILIKE ?))"
.bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat)
);
}
sqlb
}
@@ -598,6 +615,7 @@ mod tests {
trigger_kind: None,
trigger_path: None,
include_args: None,
broad_filter: None,
}
}
@@ -641,6 +659,7 @@ mod tests {
trigger_kind: None,
trigger_path: None,
include_args: None,
broad_filter: None,
}
}
+5
View File
@@ -121,6 +121,7 @@ pub struct ListQueueQuery {
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
pub trigger_path: Option<NegatedListFilter<String>>,
pub include_args: Option<bool>,
pub broad_filter: Option<String>,
}
#[derive(Deserialize, Clone)]
@@ -165,6 +166,7 @@ pub struct ListCompletedQuery {
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
pub trigger_path: Option<NegatedListFilter<String>>,
pub include_args: Option<bool>,
pub broad_filter: Option<String>,
}
impl From<ListCompletedQuery> for ListQueueQuery {
@@ -199,6 +201,7 @@ impl From<ListCompletedQuery> for ListQueueQuery {
trigger_kind: lcq.trigger_kind,
trigger_path: lcq.trigger_path,
include_args: lcq.include_args,
broad_filter: lcq.broad_filter,
}
}
}
@@ -703,6 +706,7 @@ mod tests {
trigger_kind: None,
trigger_path: None,
include_args: None,
broad_filter: None,
};
let lqq: ListQueueQuery = lcq.into();
@@ -770,6 +774,7 @@ mod tests {
trigger_kind: None,
trigger_path: None,
include_args: None,
broad_filter: None,
};
let lqq: ListQueueQuery = lcq.into();
+15 -6
View File
@@ -25,7 +25,9 @@ use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
schedule::Schedule,
utils::{not_found_if_none, paginate, Pagination, ScheduleType, StripPath},
utils::{
escape_ilike_pattern, not_found_if_none, paginate, Pagination, ScheduleType, StripPath,
},
worker::to_raw_value,
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -592,6 +594,7 @@ pub struct ListScheduleQuery {
pub description: Option<String>,
// filter on summary (pattern match)
pub summary: Option<String>,
pub broad_filter: Option<String>,
}
#[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
@@ -651,13 +654,19 @@ async fn list_schedule(
sqlb.and_where_eq("path", "?".bind(schedule_path));
}
if let Some(description) = &lsq.description {
sqlb.and_where(&format!(
"description ILIKE '%{}%'",
description.replace("'", "''")
));
let pat = format!("%{}%", escape_ilike_pattern(description));
sqlb.and_where("description ILIKE ?".bind(&pat));
}
if let Some(summary) = &lsq.summary {
sqlb.and_where(&format!("summary ILIKE '%{}%'", summary.replace("'", "''")));
let pat = format!("%{}%", escape_ilike_pattern(summary));
sqlb.and_where("summary ILIKE ?".bind(&pat));
}
if let Some(broad_filter) = &lsq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
sqlb.and_where(
"(path ILIKE ? OR script_path ILIKE ? OR description ILIKE ? OR summary ILIKE ? OR schedule ILIKE ?)"
.bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat)
);
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let rows = sqlx::query_as::<_, ScheduleLight>(&sql)
+25
View File
@@ -4106,6 +4106,11 @@ paths:
in: query
schema:
type: string
- name: broad_filter
description: broad search across multiple fields (case-insensitive substring match)
in: query
schema:
type: string
- $ref: "#/components/parameters/Page"
- $ref: "#/components/parameters/PerPage"
responses:
@@ -5120,6 +5125,11 @@ paths:
in: query
schema:
type: string
- name: broad_filter
description: broad search across multiple fields (case-insensitive substring match)
in: query
schema:
type: string
responses:
"200":
description: resource list
@@ -9996,6 +10006,11 @@ paths:
in: query
schema:
type: boolean
- name: broad_filter
description: broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)
in: query
schema:
type: string
responses:
"200":
description: All jobs
@@ -11220,6 +11235,11 @@ paths:
in: query
schema:
type: string
- name: broad_filter
description: broad search across multiple fields (case-insensitive substring match)
in: query
schema:
type: string
responses:
"200":
description: schedule list
@@ -16981,6 +17001,11 @@ paths:
description: JSONB subset match filter for columns using base64 encoded JSON
schema:
type: string
- name: broad_filter
description: broad search across multiple fields (case-insensitive substring match)
in: query
schema:
type: string
responses:
"200":
description: paginated assets in the workspace
+9
View File
@@ -202,6 +202,15 @@ impl StripPath {
}
}
/// Escape ILIKE special characters (`%`, `_`, `\`) so user input is matched
/// literally. Use this when building `ILIKE '%…%'` patterns from user-supplied
/// strings to prevent wildcard injection.
pub fn escape_ilike_pattern(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('%', "\\%")
.replace('_', "\\_")
}
pub fn require_admin(is_admin: bool, username: &str) -> Result<()> {
if !is_admin {
Err(Error::RequireAdmin(username.to_string()))
+12 -2
View File
@@ -17,7 +17,7 @@ use windmill_common::workspaces::{check_user_against_rule, ProtectionRuleKind, R
use crate::secret_backend_ext::rename_vault_secret;
use crate::var_resource_cache::{cache_resource, get_cached_resource};
use windmill_common::utils::BulkDeleteRequest;
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
use windmill_common::webhook::{WebhookMessage, WebhookShared};
use axum::{
@@ -170,6 +170,7 @@ pub struct ListResourceQuery {
pub description: Option<String>,
// filter by matching a subset of the value using base64 encoded json subset
pub value: Option<String>,
pub broad_filter: Option<String>,
}
#[derive(Serialize, FromRow)]
@@ -294,13 +295,22 @@ async fn list_resources(
}
if let Some(description) = &lq.description {
sqlb.and_where("resource.description ILIKE ?".bind(&format!("%{}%", description)));
let pat = format!("%{}%", escape_ilike_pattern(description));
sqlb.and_where("resource.description ILIKE ?".bind(&pat));
}
if let Some(value) = &lq.value {
sqlb.and_where("resource.value @> ?".bind(&value.replace("'", "''")));
}
if let Some(broad_filter) = &lq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
sqlb.and_where(
"(resource.path ILIKE ? OR resource.description ILIKE ? OR resource_type ILIKE ? OR resource.value::text ILIKE ?)"
.bind(&pat).bind(&pat).bind(&pat).bind(&pat)
);
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ListableResource>(&sql)
+14 -9
View File
@@ -14,7 +14,7 @@ use crate::secret_backend_ext::{
delete_secret_from_backend, get_secret_value, is_vault_stored_value, rename_vault_secret,
store_secret_value,
};
use windmill_common::utils::BulkDeleteRequest;
use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest};
use windmill_common::webhook::{WebhookMessage, WebhookShared};
use axum::{
@@ -101,6 +101,7 @@ struct ListVariableQuery {
pub description: Option<String>,
// filter by matching the non-encrypted value (for non-secrets only)
pub value: Option<String>,
pub broad_filter: Option<String>,
}
async fn list_variables(
@@ -161,18 +162,22 @@ async fn list_variables(
}
if let Some(description) = &lq.description {
sqlb.and_where(&format!(
"variable.description ILIKE '%{}%'",
description.replace("'", "''")
));
let pat = format!("%{}%", escape_ilike_pattern(description));
sqlb.and_where("variable.description ILIKE ?".bind(&pat));
}
if let Some(value) = &lq.value {
// Only filter on non-secret variables' value
sqlb.and_where(&format!(
"(is_secret = FALSE AND variable.value ILIKE '%{}%')",
value.replace("'", "''")
));
let pat = format!("%{}%", escape_ilike_pattern(value));
sqlb.and_where("(is_secret = FALSE AND variable.value ILIKE ?)".bind(&pat));
}
if let Some(broad_filter) = &lq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
sqlb.and_where(
"(variable.path ILIKE ? OR variable.description ILIKE ? OR (is_secret = FALSE AND variable.value ILIKE ?))"
.bind(&pat).bind(&pat).bind(&pat)
);
}
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
@@ -27,6 +27,7 @@
label?: string
description?: string
icon?: IconType
hidden?: boolean
}
export type FilterInstanceRec<T extends FilterSchemaRec> = {
[K in keyof T]: FilterInstance<T[K]>
@@ -289,14 +290,24 @@
let taggedTextInput: TaggedTextInput | undefined = $state()
let tags = $derived(
Object.entries(schema).map(([key, filterSchema]) => ({
regex: new RegExp(`\\b${key}:(?:\\\\.|[^\\s])*`, 'g'),
id: key,
onClear: () => (delete value[key], asText.reparse())
}))
Object.entries(schema)
.filter(([_, s]) => !s.hidden)
.map(([key, filterSchema]) => ({
regex: new RegExp(`\\b${key}:(?:\\\\.|[^\\s])*`, 'g'),
id: key,
onClear: () => (delete value[key], asText.reparse())
}))
)
let keyHighlightRegex = $derived(new RegExp(`\\b(${Object.keys(schema).join('|')}):`, 'g'))
let keyHighlightRegex = $derived(
new RegExp(
`\\b(${Object.entries(schema)
.filter(([_, s]) => !s.hidden)
.map(([k]) => k)
.join('|')}):`,
'g'
)
)
let errorKeys = $derived(new Set(errors.flatMap((e) => e.fields)))
let errorHighlights = $derived(
@@ -310,6 +321,7 @@
if (!currentTag) {
const searchText = currentTextSegment.text.trim().toLowerCase()
return Object.entries(schema)
.filter(([_, s]) => !s.hidden)
.filter(([k, _]) => !(k in value))
.filter(([k, filterSchema]) => {
if (!searchText) return true
@@ -397,34 +409,61 @@
function parseFromText(text: string): Partial<FilterInstanceRec<SchemaT>> {
const parsed: Record<string, string> = {}
const matchedRanges: [number, number][] = []
let match
while ((match = kvRegex.exec(text)) !== null) {
let [_, key, val] = match
if (key in schema) {
let [fullMatch, key, val] = match
if (key in schema && key !== '_default_') {
val ??= ''
val = val.replace(/\\(.)/g, (_: string, c: string) => {
if (c === 'n') return '\n'
if (c === 'r') return '\r'
return c
}) // Unescape escaped characters
})
val = val.trim()
parsed[key] = textToFilter(val, schema[key]) as any
matchedRanges.push([match.index, match.index + fullMatch.length])
}
}
if ('_default_' in schema) {
let remaining = ''
let lastEnd = 0
matchedRanges.sort((a, b) => a[0] - b[0])
for (const [start, end] of matchedRanges) {
remaining += text.slice(lastEnd, start)
lastEnd = end
}
remaining += text.slice(lastEnd)
remaining = remaining.replace(/\s+/g, ' ').trim()
if (remaining) {
parsed['_default_'] = remaining
}
}
return parsed
}
function parseToText(v: Partial<FilterInstanceRec<SchemaT>>): string {
return (
Object.entries(v)
.map(([key, val]) =>
`${key}: ${filterToText(val as any, schema[key])}`
.replace(/ /g, '\\ ')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
)
.join(' ') + '\u00A0'
)
const parts: string[] = []
let defaultText = ''
for (const [key, val] of Object.entries(v)) {
if (key === '_default_') {
defaultText = String(val)
continue
}
parts.push(
`${key}: ${filterToText(val as any, schema[key])}`
.replace(/ /g, '\\ ')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
)
}
let result = parts.join(' ')
if (defaultText) {
result = result ? `${result} ${defaultText}` : defaultText
}
return result + '\u00A0'
}
let asText = useTransformedSyncedValue(
+5 -1
View File
@@ -718,7 +718,11 @@
bind:value={_timeframe.val}
/>
<FilterSearchbar
class="flex-1 relative max-w-[34rem] min-w-[18rem] {ButtonType.UnifiedMinHeightClasses.md}"
class={twMerge(
'flex-1 relative min-w-[18rem]',
Object.keys(filters.val).length <= 3 ? 'max-w-[28rem]' : 'max-w-[34rem]',
ButtonType.UnifiedMinHeightClasses.md
)}
schema={runsFilterSearchbarSchema}
presets={buildRunsFilterPresets({ isSuperadmin: !!$superadmin })}
bind:value={filters.val}
@@ -9,6 +9,10 @@ export function buildAssetsFilterSchema({
assetKinds: string[]
}) {
return {
_default_: {
type: 'string' as const,
hidden: true
},
asset_path: {
type: 'string' as const,
label: 'Asset path pattern',
@@ -7,11 +7,11 @@
interface Props {
loading: boolean
size?: ButtonType.UnifiedSize
light?: boolean
variant?: ButtonType.Variant
onClick?: () => void
}
let { loading, size = 'md', onClick }: Props = $props()
let { loading, size = 'md', onClick, variant = 'subtle' }: Props = $props()
let buttonHover = $state(false)
</script>
@@ -22,7 +22,8 @@
on:mouseleave={() => (buttonHover = false)}
color="light"
unifiedSize={size}
variant="subtle"
{variant}
iconOnly
{onClick}
startIcon={{ icon: RefreshCw, props: { class: loading ? 'animate-spin' : '' } }}
></Button>
@@ -69,7 +69,6 @@
{/if}
<RefreshButton
size="sm"
light
{loading}
onClick={() => {
refreshCount.val += 1
@@ -15,6 +15,10 @@ export function buildResourcesFilterSchema({
userFoldersLabel?: string
}) {
return {
_default_: {
type: 'string' as const,
hidden: true
},
resource_type: {
type: 'oneof' as const,
options: resourceTypes.map((s) => ({ label: s, value: s })),
@@ -31,6 +31,10 @@ export function buildRunsFilterSearchbarSchema({
isSuperAdmin: boolean
}) {
return {
_default_: {
type: 'string' as const,
hidden: true
},
min_ts: {
type: 'date' as const,
label: 'From',
@@ -95,6 +95,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
let folder = $derived(filters?.folder)
let path = $derived(filters?.path)
let argFilter = $derived(filters?.arg)
let broadFilter = $derived(filters?._default_ || undefined)
let queue_count: Tweened<number> | undefined = $state()
let suspended_count: Tweened<number> | undefined = $state()
@@ -217,7 +218,8 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
triggerKind: jobTriggerKind ?? undefined,
allWorkspaces: allWorkspaces ? true : undefined,
perPage,
allowWildcards: allowWildcards ? true : undefined
allowWildcards: allowWildcards ? true : undefined,
broadFilter
})
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
if (e instanceof CancelError) return CancelablePromiseUtils.err(e)
@@ -13,6 +13,10 @@ export function buildSchedulesFilterSchema({
userFoldersLabel?: string
}) {
return {
_default_: {
type: 'string' as const,
hidden: true
},
schedule_path: {
type: 'oneof' as const,
options: paths.map((s) => ({ label: s, value: s })),
@@ -13,6 +13,10 @@ export function buildVariablesFilterSchema({
userFoldersLabel?: string
}) {
return {
_default_: {
type: 'string' as const,
hidden: true
},
owner: {
type: 'oneof' as const,
options: owners.map((s) => ({ label: s, value: s })),
@@ -22,7 +22,7 @@
import { Tooltip } from '$lib/components/meltComponents'
import { AlertTriangle, Loader2, SettingsIcon, StarIcon } from 'lucide-svelte'
import { StaleWhileLoading, useInfiniteQuery, useScrollToBottom } from '$lib/svelte5Utils.svelte'
import { Debounced, resource, watch, type ResourceReturn } from 'runed'
import { resource, watch, type ResourceReturn } from 'runed'
import RefreshButton from '$lib/components/common/button/RefreshButton.svelte'
import Section from '$lib/components/Section.svelte'
import Button from '$lib/components/common/button/Button.svelte'
@@ -57,16 +57,14 @@
)
let filterValues = useUrlSyncedFilterInstance(untrack(() => assetsFilterSchema))
let filters = new Debounced(
() => ({
assetPath: filterValues.val.asset_path || undefined,
usagePath: filterValues.val.usage_path || undefined,
assetKinds: filterValues.val.asset_kinds || undefined,
path: filterValues.val.path || undefined,
columns: filterValues.val.columns || undefined
}),
500
)
let filters = $derived.by(() => ({
assetPath: filterValues.val.asset_path || undefined,
usagePath: filterValues.val.usage_path || undefined,
assetKinds: filterValues.val.asset_kinds || undefined,
path: filterValues.val.path || undefined,
columns: filterValues.val.columns || undefined,
broadFilter: filterValues.val._default_ || undefined
}))
const assetsQuery = useInfiniteQuery<ListAssetsResponse, AssetCursor | undefined>({
queryFn: async (cursor) => {
@@ -75,7 +73,7 @@
perPage: 50,
cursorCreatedAt: cursor?.created_at,
cursorId: cursor?.id,
...filters.current
...filters
})
},
initialPageParam: undefined,
@@ -91,7 +89,7 @@
)
watch(
() => [filters.current, $workspaceStore],
() => [filters, $workspaceStore],
() => assetsQuery.reset()
)
@@ -272,14 +270,20 @@
</Section>
<Section label="Latest assets used">
{#snippet action()}
<RefreshButton onClick={() => assetsQuery.reset()} loading={assetsQuery.isLoading} />
<div class="flex gap-2 grow justify-end">
<RefreshButton
variant="default"
onClick={() => assetsQuery.reset()}
loading={assetsQuery.isLoading}
/>
<FilterSearchbar
class="grow max-w-[26rem]"
schema={assetsFilterSchema}
bind:value={filterValues.val}
placeholder="Filter assets..."
/>
</div>
{/snippet}
<FilterSearchbar
schema={assetsFilterSchema}
bind:value={filterValues.val}
placeholder="Filter assets..."
class="mb-4"
/>
{@render table()}
{#if assetsQuery.isFetchingNextPage}
<Loader2 size={32} class="mx-auto my-4 text-primary animate-spin" />
@@ -198,6 +198,9 @@
if (currentFilters.owner) {
apiParams.pathStart = currentFilters.owner
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
const result = (await ResourceService.listResource(apiParams)).map((x) => {
return {
@@ -555,6 +558,10 @@
})
let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any
let showTable = $derived(
tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'
)
</script>
<ConfirmationModal
@@ -876,31 +883,31 @@
{/snippet}
</Tab>
</Tabs>
<div class="flex">
<div class="flex gap-2 grow justify-end">
<Button
variant="default"
on:click={reload}
unifiedSize="md"
iconOnly
onClick={reload}
startIcon={{
icon: RotateCw,
classes: loading.resources || loading.types ? 'animate-spin' : ''
}}
/>
<FilterSearchbar
schema={resourcesFilterSchema}
class="max-w-[26rem] grow"
bind:value={filters.val}
placeholder="Filter resources..."
presets={[
{
name: resourcesFilterSchema.user_folders_only?.label ?? '?',
value: 'user_folders_only:\\ true'
}
]}
/>
</div>
</div>
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
<FilterSearchbar
schema={resourcesFilterSchema}
bind:value={filters.val}
placeholder="Filter resources..."
class="mt-4"
presets={[
{
name: resourcesFilterSchema.user_folders_only?.label ?? '?',
value: 'user_folders_only:\\ true'
}
]}
/>
{#if showTable}
<div class="overflow-x-auto pb-40 mt-4">
{#if loading.resources}
<Skeleton layout={[0.5, [2], 1]} />
@@ -31,7 +31,9 @@
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import FilterSearchbar, { useUrlSyncedFilterInstance } from '$lib/components/FilterSearchbar.svelte'
import FilterSearchbar, {
useUrlSyncedFilterInstance
} from '$lib/components/FilterSearchbar.svelte'
import { buildSchedulesFilterSchema } from '$lib/components/schedules/schedulesFilter'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
@@ -88,6 +90,9 @@
if (currentFilters.args) {
apiParams.args = currentFilters.args
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
@@ -171,9 +176,7 @@
scriptPaths: allScriptPaths,
showUserFoldersFilter: userFoldersFilterType !== undefined,
userFoldersLabel:
userFoldersFilterType === 'only f/*'
? 'Only f/*'
: `Only u/${$userStore?.username} and f/*`
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
})
)
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
@@ -260,18 +263,19 @@
</Button>
</PageHeader>
<div class="w-full h-full flex flex-col">
<div class="w-full pb-4 pt-6">
<FilterSearchbar schema={schedulesFilterSchema} bind:value={filters.val} />
<div class="flex flex-row items-center justify-end gap-4 mt-4">
<ToggleButtonGroup class="w-auto" bind:selected={filterEnabledDisabled}>
{#snippet children({ item })}
<ToggleButton small value="all" label="All" {item} />
<ToggleButton small value="enabled" label="Enabled" {item} />
<ToggleButton small value="disabled" label="Disabled" {item} />
{/snippet}
</ToggleButtonGroup>
</div>
<div class="flex flex-row items-center justify-end gap-4 pb-4">
<ToggleButtonGroup bind:selected={filterEnabledDisabled} class="w-fit">
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton value="enabled" label="Enabled" {item} />
<ToggleButton value="disabled" label="Disabled" {item} />
{/snippet}
</ToggleButtonGroup>
<FilterSearchbar
schema={schedulesFilterSchema}
bind:value={filters.val}
class="grow max-w-[26rem]"
/>
</div>
{#if loading}
{#each new Array(6) as _}
@@ -118,6 +118,9 @@
if (currentFilters.owner) {
apiParams.pathStart = currentFilters.owner
}
if (currentFilters._default_) {
apiParams.broadFilter = currentFilters._default_
}
const result = (await VariableService.listVariable(apiParams)).map((x) => {
return {
@@ -253,33 +256,37 @@
}}
/>
<Tabs bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
<Tab value="contextual" label="Contextual" icon={DollarSign}>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and
depends on the execution context.
</Tooltip>
{/snippet}
</Tab>
</Tabs>
<div class="flex gap-2 justify-between items-center">
<Tabs bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
<Tab value="contextual" label="Contextual" icon={DollarSign}>
{#snippet extra()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/variables_and_secrets#contextual-variables"
>
Contextual variables are passed as environment variables when running a script and
depends on the execution context.
</Tooltip>
{/snippet}
</Tab>
</Tabs>
{#if tab == 'workspace'}
<FilterSearchbar
class="grow max-w-[26rem]"
schema={variablesFilterSchema}
bind:value={filters.val}
placeholder="Filter variables..."
presets={[
{
name: variablesFilterSchema.user_folders_only?.label ?? '?',
value: 'user_folders_only:\\ true'
}
]}
/>
{/if}
</div>
{#if tab == 'workspace'}
<FilterSearchbar
class="my-4"
schema={variablesFilterSchema}
bind:value={filters.val}
placeholder="Filter variables..."
presets={[
{
name: variablesFilterSchema.user_folders_only?.label ?? '?',
value: 'user_folders_only:\\ true'
}
]}
/>
<div class="relative overflow-x-auto pb-40">
<div class="relative overflow-x-auto pb-40 mt-4">
{#if !filteredItems}
<Skeleton layout={[0.5, [2], 1]} />
{#each new Array(3) as _}