mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()))
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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 _}
|
||||
|
||||
Reference in New Issue
Block a user