mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
feat: Unified filters and new runs page (#8027)
* RunsPage redesign v0 * nit * Remove manualdatepicker * remove shadow * ui nits * nit scrollbar bg * prettier cards * nit * Remove code * command/meta multi select * Shift select * RightClickPopover * nit * Ctrl A * nit card * DropdownMenu * nit * count hint * fix stuck keys * opacity UX * error toasts pickhubscript * Improve UX * fix undefined error * keyboard nav * nit batch rerun fixes * nit fix scroll / height * Batch reruns actions + nits * nit * Cancel selected jobs * Cancel / re-run all filtered jobs * Go to job / flow / script action * nit * add batch actions back * nit * nit * bar on splitpane hover * nit * New Timeframe system * reset btn * nit fixes * dead code * nits * typecheck * naming clarity * Update frontend/src/lib/components/RightClickPopover.svelte Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> * unnecessary json stringify * dedup 'the' * Code deletion to prepare for changes * filter types * ui * fix bug with maxTs * stuck with melt * GenericDropdown * filters onclick * iterate * iter * add all filters * Descriptions * focus position * stash * TaggedTextInput works much much better * placeholder * currentTag suggestion * improve * nit * Keyboard nav * buildRunsFilterSearchbarSchema * nit naming * assignObjInPlace * Escaping + pretty dates * nit empty * fix cursor * nit space * Filter filtering * escape pasted value * nit * escape spaces * nit undefined * add space at end if right arrow * escape all spaces * arrow skips escape chars * escape \ too * delete whole escaped characters * double space to escape tag * code refactor * Ensure cursor visible * fix keyboard nav * safety * filterSchemaRecToZodSchema * URL Sync * fix readonly * fix typing * start replacing old filter logic * use new filter impl * nit * nit reactivity * nit fix * no more localStorage * Add back status and kind toggles * Nit fix * style nit * focus at end on click * clearn btn + fixes * fix broken date uri * nit * useSyncedTimeframe * negative filter button * negative filters helpers rust * Negated filters backed * nit * highlight * New useSearchParams * Accept comma separated list * nit allowNegative * openapi update * Fix trigger kind list/negation not working * nit oipenpai * Presets * DebouncedTempValue * remove presets from list when already applied * UI nit improvements * allowMultiple * hint * validateFilterInstance fn * nit fix * error highlights * nit ux selecting negative list * nit * on clear btn * SimpleEditor for JSON * nit * flop * Pass presets as param * nit delete * preventCursorMoveOnNextSync * responsive layout * Escape \n * Inline calendar input * mm/dd or dd/mm depending on US or not * onClickBehavior * infiniteRange * other nits * Wiring with runs filter * formatDateRange better * inits on right page * style * min hour support * Time input * use our components * Improve SKILL.md * dd mm yyyy numeric input * TimeframeSelect with new date picker * fixes * ensure date is in view when value changes externally * fixes * nit select all on focus * select year + nits * nit layout shift * nit negative when starting with ! * nit * SelectDropdown uses GenericDropdown now * Fix blank select dropdown rendering bug * icons * Reset btn + shorter date range formatting * overflow fix * unnecessary absolute * fix clear btn overlap * Update routes for new filters (assets, schedule, resource, variables) * update openapi * Impl for other pages * ui nits * nit fixes * Fix columns filter * super nits --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
claude[bot]
parent
6ba0da3ee5
commit
9b28c85469
@@ -226,4 +226,93 @@ When generating Svelte 5 code, prioritize frontend performance by applying the f
|
||||
Hello
|
||||
</div>
|
||||
```
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
|
||||
## Windmill UI Component Rules (MUST follow)
|
||||
|
||||
Always use Windmill's own design-system components instead of raw HTML elements. Using raw HTML elements produces inconsistent styling and breaks the design language.
|
||||
|
||||
### Icons — use `lucide-svelte`
|
||||
|
||||
**Never** write inline SVGs. Import icons from `lucide-svelte`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, X } from 'lucide-svelte'
|
||||
</script>
|
||||
|
||||
<ChevronLeft size={16} />
|
||||
```
|
||||
|
||||
### Buttons — use `<Button>`
|
||||
|
||||
**Never** use `<button>`. Import and use `Button` from `$lib/components/common`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Button } from '$lib/components/common'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-svelte'
|
||||
</script>
|
||||
|
||||
<!-- Regular button -->
|
||||
<Button variant="default" onclick={handleClick}>Label</Button>
|
||||
|
||||
<!-- Icon-only button (no label) -->
|
||||
<Button startIcon={{ icon: ChevronLeft }} iconOnly onclick={prevMonth} />
|
||||
<Button startIcon={{ icon: ChevronRight }} iconOnly onclick={nextMonth} />
|
||||
```
|
||||
|
||||
Key `Button` props:
|
||||
- `variant?: 'accent' | 'accent-secondary' | 'default' | 'subtle'`
|
||||
- `unifiedSize?: 'sm' | 'md' | 'lg'`
|
||||
- `startIcon?: { icon: SvelteComponent }` — renders an icon before the label
|
||||
- `iconOnly?: boolean` — renders icon with no surrounding label text
|
||||
- `disabled?: boolean`
|
||||
|
||||
### Text inputs — use `<TextInput>`
|
||||
|
||||
**Never** use `<input>`. Import and use `TextInput` from `$lib/components/common`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { TextInput } from '$lib/components/common'
|
||||
let val = $state('')
|
||||
</script>
|
||||
|
||||
<TextInput bind:value={val} placeholder="Enter value" />
|
||||
```
|
||||
|
||||
Key `TextInput` props:
|
||||
- `value?: string | number` (bindable)
|
||||
- `placeholder?: string`
|
||||
- `disabled?: boolean`
|
||||
- `error?: string | boolean`
|
||||
- `size?: 'sm' | 'md' | 'lg'`
|
||||
- `inputProps?` — forwarded to the underlying `<input>`
|
||||
|
||||
### Selects — use `<Select>`
|
||||
|
||||
**Never** use `<select>`. Import and use `Select` from `$lib/components/select/Select.svelte`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
|
||||
const monthItems = [
|
||||
{ label: 'January', value: 1 },
|
||||
{ label: 'February', value: 2 },
|
||||
// ...
|
||||
]
|
||||
let selectedMonth = $state(1)
|
||||
</script>
|
||||
|
||||
<Select items={monthItems} bind:value={selectedMonth} />
|
||||
```
|
||||
|
||||
Key `Select` props:
|
||||
- `items?: Array<{ label?: string; value: any; subtitle?: string; disabled?: boolean }>`
|
||||
- `value` (bindable) — the currently selected `.value`
|
||||
- `placeholder?: string`
|
||||
- `clearable?: boolean`
|
||||
- `disabled?: boolean`
|
||||
- `size?: 'sm' | 'md' | 'lg'`
|
||||
@@ -27,9 +27,13 @@ struct ListAssetsQuery {
|
||||
per_page: i64,
|
||||
cursor_created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
cursor_id: Option<i64>,
|
||||
asset_path: Option<String>,
|
||||
usage_path: Option<String>,
|
||||
asset_kinds: Option<String>,
|
||||
pub asset_path: Option<String>,
|
||||
pub usage_path: Option<String>,
|
||||
pub asset_kinds: Option<String>,
|
||||
// Exact path match filter
|
||||
pub path: Option<String>,
|
||||
// Filter by matching a subset of the columns using base64 encoded json subset
|
||||
pub columns: Option<String>,
|
||||
}
|
||||
|
||||
fn default_per_page() -> i64 {
|
||||
@@ -75,12 +79,24 @@ async fn list_assets(
|
||||
|
||||
let mut param_count = 2; // $1 = workspace_id, $2 = limit
|
||||
|
||||
// Asset path filter
|
||||
// Asset path filter (ILIKE pattern match)
|
||||
if query.asset_path.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!("asset.path ILIKE ${}", param_count));
|
||||
}
|
||||
|
||||
// Exact path filter
|
||||
if query.path.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!("asset.path = ${}", param_count));
|
||||
}
|
||||
|
||||
// Columns filter (check if JSONB has all specified keys)
|
||||
if query.columns.is_some() {
|
||||
param_count += 1;
|
||||
asset_summary_filters.push(format!("asset.columns ?& ${}", param_count));
|
||||
}
|
||||
|
||||
// Usage path filter - for jobs, also check runnable_path
|
||||
let needs_job_join_in_cte = query.usage_path.is_some();
|
||||
if query.usage_path.is_some() {
|
||||
@@ -211,6 +227,20 @@ async fn list_assets(
|
||||
query_builder = query_builder.bind(format!("%{}%", asset_path));
|
||||
}
|
||||
|
||||
if let Some(ref path) = query.path {
|
||||
query_builder = query_builder.bind(path);
|
||||
}
|
||||
|
||||
if let Some(ref columns) = query.columns {
|
||||
// Columns is a comma-separated string, split into array for ?& operator
|
||||
let columns_array: Vec<String> = columns
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
query_builder = query_builder.bind(columns_array);
|
||||
}
|
||||
|
||||
if let Some(ref usage_path) = query.usage_path {
|
||||
query_builder = query_builder.bind(format!("%{}%", usage_path));
|
||||
}
|
||||
|
||||
@@ -10,9 +10,11 @@ pub mod concurrency_groups;
|
||||
pub mod execution;
|
||||
pub mod job_metrics;
|
||||
pub mod jobs_export;
|
||||
pub mod negated_filter;
|
||||
pub mod query;
|
||||
pub mod types;
|
||||
|
||||
pub use execution::*;
|
||||
pub use negated_filter::{NegatedFilter, NegatedListFilter};
|
||||
pub use query::*;
|
||||
pub use types::*;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Author: Windmill Labs, Inc
|
||||
* Copyright: Windmill Labs, Inc 2024
|
||||
* This file and its contents are licensed under the AGPLv3 License.
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
//! Filter wrappers that support an optional `!` negation prefix.
|
||||
//!
|
||||
//! - [`NegatedFilter<T>`] — a single value, e.g. `"schedule"` or `"!schedule"`.
|
||||
//! - [`NegatedListFilter<T>`] — comma-separated values, e.g. `"!schedule,!email"` or `"http,webhook"`.
|
||||
//! Every item in the list shares the same negated/non-negated sense; mixing is not supported
|
||||
|
||||
use serde::{
|
||||
de::{self, DeserializeOwned},
|
||||
Deserializer,
|
||||
};
|
||||
use std::{fmt, marker::PhantomData};
|
||||
|
||||
// ── NegatedFilter<T> ──────────────────────────────────────────────────────────
|
||||
|
||||
/// A single filter value optionally prefixed with `!` to indicate negation.
|
||||
///
|
||||
/// Deserializes `"schedule"` → `NegatedFilter { value: Schedule, negated: false }`
|
||||
/// Deserializes `"!schedule"` → `NegatedFilter { value: Schedule, negated: true }`
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NegatedFilter<T> {
|
||||
pub value: T,
|
||||
pub negated: bool,
|
||||
}
|
||||
|
||||
impl<T> NegatedFilter<T> {
|
||||
pub fn positive(value: T) -> Self {
|
||||
Self { value, negated: false }
|
||||
}
|
||||
|
||||
pub fn negated(value: T) -> Self {
|
||||
Self { value, negated: true }
|
||||
}
|
||||
}
|
||||
|
||||
struct NegatedFilterVisitor<T>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Visitor<'de> for NegatedFilterVisitor<T> {
|
||||
type Value = NegatedFilter<T>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "a string optionally prefixed with '!'")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
||||
let (negated, raw) = match s.strip_prefix('!') {
|
||||
Some(rest) => (true, rest),
|
||||
None => (false, s),
|
||||
};
|
||||
let value = serde_json::from_value(serde_json::Value::String(raw.to_owned()))
|
||||
.map_err(|e| E::custom(format!("invalid filter value {:?}: {}", raw, e)))?;
|
||||
Ok(NegatedFilter { value, negated })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Deserialize<'de> for NegatedFilter<T> {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_str(NegatedFilterVisitor(PhantomData))
|
||||
}
|
||||
}
|
||||
|
||||
// ── NegatedListFilter<T> ──────────────────────────────────────────────────────
|
||||
|
||||
/// A comma-separated list of filter values, all sharing the same negation sense.
|
||||
///
|
||||
/// Deserializes `"schedule,email"` → `NegatedListFilter { values: [Schedule, Email], negated: false }`
|
||||
/// Deserializes `"!schedule,!email"` → `NegatedListFilter { values: [Schedule, Email], negated: true }`
|
||||
///
|
||||
/// The `!` is read from the **first** item only; subsequent items may or may not carry
|
||||
/// `!` and it is stripped regardless, keeping the API forgiving.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NegatedListFilter<T> {
|
||||
pub values: Vec<T>,
|
||||
pub negated: bool,
|
||||
}
|
||||
|
||||
impl<T> NegatedListFilter<T> {
|
||||
pub fn positive(values: Vec<T>) -> Self {
|
||||
Self { values, negated: false }
|
||||
}
|
||||
}
|
||||
|
||||
struct NegatedListFilterVisitor<T>(PhantomData<T>);
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Visitor<'de> for NegatedListFilterVisitor<T> {
|
||||
type Value = NegatedListFilter<T>;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(f, "a comma-separated string optionally prefixed with '!'")
|
||||
}
|
||||
|
||||
fn visit_str<E: de::Error>(self, s: &str) -> Result<Self::Value, E> {
|
||||
let mut negated = false;
|
||||
let values = s
|
||||
.split(',')
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let raw = match item.strip_prefix('!') {
|
||||
Some(rest) => {
|
||||
if i == 0 {
|
||||
negated = true;
|
||||
}
|
||||
rest
|
||||
}
|
||||
None => item,
|
||||
};
|
||||
serde_json::from_value::<T>(serde_json::Value::String(raw.to_owned()))
|
||||
.map_err(|e| E::custom(format!("invalid filter value {:?}: {}", raw, e)))
|
||||
})
|
||||
.collect::<Result<Vec<T>, E>>()?;
|
||||
Ok(NegatedListFilter { values, negated })
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: DeserializeOwned> de::Deserialize<'de> for NegatedListFilter<T> {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
deserializer.deserialize_str(NegatedListFilterVisitor(PhantomData))
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,17 @@ use windmill_common::utils::{paginate_without_limits, Pagination};
|
||||
|
||||
use crate::types::{ListCompletedQuery, ListQueueQuery};
|
||||
|
||||
/// Build a `NOT IN (...)` clause that also includes `OR col IS NULL`, so that
|
||||
/// rows where the nullable column is NULL are not silently excluded.
|
||||
fn not_in_nullable(col: &str, quoted: &[String]) -> String {
|
||||
format!(
|
||||
"({} IS NULL OR {} NOT IN ({}))",
|
||||
col,
|
||||
col,
|
||||
quoted.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn filter_list_queue_query(
|
||||
mut sqlb: SqlBuilder,
|
||||
lq: &ListQueueQuery,
|
||||
@@ -33,18 +44,62 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(w) = &lq.worker {
|
||||
let quoted: Vec<_> = w.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job_queue.worker", w.replace("*", "%"));
|
||||
let clauses: Vec<_> = w
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if w.negated {
|
||||
format!("v2_job_queue.worker NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job_queue.worker LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if w.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if w.negated {
|
||||
sqlb.and_where(format!("(v2_job_queue.worker IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
} else if w.negated {
|
||||
sqlb.and_where(not_in_nullable("v2_job_queue.worker", "ed));
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job_queue.worker", "?".bind(w));
|
||||
sqlb.and_where_in("v2_job_queue.worker", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ps) = &lq.script_path_start {
|
||||
sqlb.and_where_like_left("runnable_path", ps);
|
||||
let clauses: Vec<_> = ps
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if ps.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if ps.negated {
|
||||
sqlb.and_where(format!("(runnable_path IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &lq.script_path_exact {
|
||||
sqlb.and_where_eq("runnable_path", "?".bind(p));
|
||||
let quoted: Vec<_> = p.values.iter().map(|v| quote(v)).collect();
|
||||
if p.negated {
|
||||
sqlb.and_where(not_in_nullable("runnable_path", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("runnable_path", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(p) = &lq.schedule_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(p));
|
||||
@@ -54,13 +109,34 @@ pub fn filter_list_queue_query(
|
||||
sqlb.and_where_eq("runnable_id", "?".bind(h));
|
||||
}
|
||||
if let Some(cb) = &lq.created_by {
|
||||
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||
let quoted: Vec<_> = cb.values.iter().map(|v| quote(v)).collect();
|
||||
if cb.negated {
|
||||
sqlb.and_where_not_in("created_by", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("created_by", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(t) = &lq.tag {
|
||||
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
|
||||
let clauses: Vec<_> = t
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if t.negated { " AND " } else { " OR " };
|
||||
sqlb.and_where(format!("({})", clauses.join(sep)));
|
||||
} else if t.negated {
|
||||
sqlb.and_where_not_in("v2_job.tag", "ed);
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
|
||||
sqlb.and_where_in("v2_job.tag", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,10 +191,12 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
sqlb.and_where_in(
|
||||
"kind",
|
||||
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
|
||||
);
|
||||
let quoted: Vec<_> = jk.values.iter().map(|v| quote(v)).collect();
|
||||
if jk.negated {
|
||||
sqlb.and_where_not_in("kind", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
@@ -134,11 +212,21 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
|
||||
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
|
||||
if tk.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger_kind", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger_kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tp) = &lq.trigger_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(tp));
|
||||
let quoted: Vec<_> = tp.values.iter().map(|v| quote(v)).collect();
|
||||
if tp.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
sqlb
|
||||
@@ -187,25 +275,71 @@ pub fn filter_list_completed_query(
|
||||
|
||||
if let Some(label) = &lq.label {
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
let wh = format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') label WHERE jsonb_typeof(result->'wm_labels') = 'array' AND label LIKE '{}')",
|
||||
&label.replace("*", "%").replace("'", "''")
|
||||
);
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(&wh);
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if label.negated {
|
||||
format!(
|
||||
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if label.negated { " AND " } else { " OR " };
|
||||
if !label.negated {
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
}
|
||||
sqlb.and_where(format!("({})", clauses.join(sep)));
|
||||
} else if label.negated {
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
|
||||
.collect();
|
||||
sqlb.and_where(format!("({})", clauses.join(" AND ")));
|
||||
} else {
|
||||
let mut wh = format!("result->'wm_labels' ? ");
|
||||
wh.push_str(&format!("'{}'", &label.replace("'", "''")));
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
|
||||
.collect();
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(&wh);
|
||||
sqlb.and_where(format!("({})", clauses.join(" OR ")));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(worker) = &lq.worker {
|
||||
let quoted: Vec<_> = worker.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job_completed.worker", worker.replace("*", "%"));
|
||||
let clauses: Vec<_> = worker
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if worker.negated {
|
||||
format!("v2_job_completed.worker NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job_completed.worker LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if worker.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if worker.negated {
|
||||
sqlb.and_where(format!("(v2_job_completed.worker IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
} else if worker.negated {
|
||||
sqlb.and_where(not_in_nullable("v2_job_completed.worker", "ed));
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job_completed.worker", "?".bind(worker));
|
||||
sqlb.and_where_in("v2_job_completed.worker", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,24 +354,68 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(ps) = &lq.script_path_start {
|
||||
sqlb.and_where_like_left("runnable_path", ps);
|
||||
let clauses: Vec<_> = ps
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if ps.negated { " AND " } else { " OR " };
|
||||
let inner = clauses.join(sep);
|
||||
if ps.negated {
|
||||
sqlb.and_where(format!("(runnable_path IS NULL OR ({inner}))"));
|
||||
} else {
|
||||
sqlb.and_where(format!("({inner})"));
|
||||
}
|
||||
}
|
||||
if let Some(p) = &lq.script_path_exact {
|
||||
sqlb.and_where_eq("runnable_path", "?".bind(p));
|
||||
let quoted: Vec<_> = p.values.iter().map(|v| quote(v)).collect();
|
||||
if p.negated {
|
||||
sqlb.and_where(not_in_nullable("runnable_path", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("runnable_path", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(h) = &lq.script_hash {
|
||||
sqlb.and_where_eq("runnable_id", "?".bind(h));
|
||||
}
|
||||
if let Some(t) = &lq.tag {
|
||||
let quoted: Vec<_> = t.values.iter().map(|v| quote(v)).collect();
|
||||
if lq.allow_wildcards.unwrap_or(false) {
|
||||
sqlb.and_where_like_left("v2_job.tag", t.replace("*", "%"));
|
||||
let clauses: Vec<_> = t
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let sep = if t.negated { " AND " } else { " OR " };
|
||||
sqlb.and_where(format!("({})", clauses.join(sep)));
|
||||
} else if t.negated {
|
||||
sqlb.and_where_not_in("v2_job.tag", "ed);
|
||||
} else {
|
||||
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
|
||||
sqlb.and_where_in("v2_job.tag", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(cb) = &lq.created_by {
|
||||
sqlb.and_where_eq("created_by", "?".bind(cb));
|
||||
let quoted: Vec<_> = cb.values.iter().map(|v| quote(v)).collect();
|
||||
if cb.negated {
|
||||
sqlb.and_where_not_in("created_by", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("created_by", "ed);
|
||||
}
|
||||
}
|
||||
if let Some(r) = &lq.success {
|
||||
if *r {
|
||||
@@ -308,10 +486,12 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
}
|
||||
if let Some(jk) = &lq.job_kinds {
|
||||
sqlb.and_where_in(
|
||||
"kind",
|
||||
&jk.split(',').into_iter().map(quote).collect::<Vec<_>>(),
|
||||
);
|
||||
let quoted: Vec<_> = jk.values.iter().map(|v| quote(v)).collect();
|
||||
if jk.negated {
|
||||
sqlb.and_where_not_in("kind", "ed);
|
||||
} else {
|
||||
sqlb.and_where_in("kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
@@ -327,11 +507,21 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(tk) = &lq.trigger_kind {
|
||||
sqlb.and_where_eq("trigger_kind", "?".bind(&format!("{}", tk)));
|
||||
let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect();
|
||||
if tk.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger_kind", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger_kind", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tp) = &lq.trigger_path {
|
||||
sqlb.and_where_eq("trigger", "?".bind(tp));
|
||||
let quoted: Vec<_> = tp.values.iter().map(|v| quote(v)).collect();
|
||||
if tp.negated {
|
||||
sqlb.and_where(not_in_nullable("trigger", "ed));
|
||||
} else {
|
||||
sqlb.and_where_in("trigger", "ed);
|
||||
}
|
||||
}
|
||||
|
||||
sqlb
|
||||
@@ -375,6 +565,7 @@ pub fn list_completed_jobs_query(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::negated_filter::NegatedListFilter;
|
||||
|
||||
fn empty_queue_query() -> ListQueueQuery {
|
||||
ListQueueQuery {
|
||||
@@ -478,7 +669,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_script_path_start() {
|
||||
let lq = ListQueueQuery {
|
||||
script_path_start: Some("f/test".to_string()),
|
||||
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -495,7 +686,9 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_script_path_exact() {
|
||||
let lq = ListQueueQuery {
|
||||
script_path_exact: Some("f/test/script".to_string()),
|
||||
script_path_exact: Some(NegatedListFilter::positive(vec![
|
||||
"f/test/script".to_string()
|
||||
])),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -510,10 +703,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_running() {
|
||||
let lq = ListQueueQuery {
|
||||
running: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { running: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -527,7 +717,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_queue_filter_job_kinds() {
|
||||
let lq = ListQueueQuery {
|
||||
job_kinds: Some("script,flow".to_string()),
|
||||
job_kinds: Some(NegatedListFilter::positive(vec![
|
||||
"script".to_string(),
|
||||
"flow".to_string(),
|
||||
])),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let sqlb = filter_list_queue_query(
|
||||
@@ -543,10 +736,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_suspended() {
|
||||
let lq = ListQueueQuery {
|
||||
suspended: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { suspended: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -559,10 +749,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_is_not_schedule() {
|
||||
let lq = ListQueueQuery {
|
||||
is_not_schedule: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { is_not_schedule: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -575,10 +762,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_has_null_parent() {
|
||||
let lq = ListQueueQuery {
|
||||
has_null_parent: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { has_null_parent: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -591,10 +775,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_is_flow_step_true() {
|
||||
let lq = ListQueueQuery {
|
||||
is_flow_step: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { is_flow_step: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -607,10 +788,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_filter_is_flow_step_false() {
|
||||
let lq = ListQueueQuery {
|
||||
is_flow_step: Some(false),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { is_flow_step: Some(false), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -623,10 +801,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_admins_all_workspaces() {
|
||||
let lq = ListQueueQuery {
|
||||
all_workspaces: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { all_workspaces: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -639,10 +814,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_queue_non_admins_ignores_all_workspaces() {
|
||||
let lq = ListQueueQuery {
|
||||
all_workspaces: Some(true),
|
||||
..empty_queue_query()
|
||||
};
|
||||
let lq = ListQueueQuery { all_workspaces: Some(true), ..empty_queue_query() };
|
||||
let sqlb = filter_list_queue_query(
|
||||
SqlBuilder::select_from("v2_job_queue").clone(),
|
||||
&lq,
|
||||
@@ -695,10 +867,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_success_true() {
|
||||
let lq = ListCompletedQuery {
|
||||
success: Some(true),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let lq = ListCompletedQuery { success: Some(true), ..empty_completed_query() };
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
@@ -711,10 +880,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_success_false() {
|
||||
let lq = ListCompletedQuery {
|
||||
success: Some(false),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let lq = ListCompletedQuery { success: Some(false), ..empty_completed_query() };
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
@@ -739,7 +905,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_completed_filter_label() {
|
||||
let lq = ListCompletedQuery {
|
||||
label: Some("deploy".to_string()),
|
||||
label: Some(NegatedListFilter::positive(vec!["deploy".to_string()])),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let sqlb = filter_list_completed_query(
|
||||
@@ -754,10 +920,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_completed_filter_is_skipped() {
|
||||
let lq = ListCompletedQuery {
|
||||
is_skipped: Some(true),
|
||||
..empty_completed_query()
|
||||
};
|
||||
let lq = ListCompletedQuery { is_skipped: Some(true), ..empty_completed_query() };
|
||||
let sqlb = filter_list_completed_query(
|
||||
SqlBuilder::select_from("v2_job_completed").clone(),
|
||||
&lq,
|
||||
|
||||
@@ -27,6 +27,8 @@ use windmill_common::{
|
||||
|
||||
use windmill_api_sse::{Job, JobExtended};
|
||||
|
||||
use crate::negated_filter::NegatedListFilter;
|
||||
|
||||
// ------------ RunJobQuery ------------
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
@@ -89,10 +91,10 @@ impl RunJobQuery {
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ListQueueQuery {
|
||||
pub script_path_start: Option<String>,
|
||||
pub script_path_exact: Option<String>,
|
||||
pub script_path_start: Option<NegatedListFilter<String>>,
|
||||
pub script_path_exact: Option<NegatedListFilter<String>>,
|
||||
pub script_hash: Option<String>,
|
||||
pub created_by: Option<String>,
|
||||
pub created_by: Option<NegatedListFilter<String>>,
|
||||
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
@@ -103,12 +105,12 @@ pub struct ListQueueQuery {
|
||||
pub schedule_path: Option<String>,
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub job_kinds: Option<NegatedListFilter<String>>,
|
||||
pub suspended: Option<bool>,
|
||||
pub worker: Option<String>,
|
||||
pub worker: Option<NegatedListFilter<String>>,
|
||||
// filter by matching a subset of the args using base64 encoded json subset
|
||||
pub args: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub tag: Option<NegatedListFilter<String>>,
|
||||
pub scheduled_for_before_now: Option<bool>,
|
||||
pub all_workspaces: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
@@ -116,17 +118,17 @@ pub struct ListQueueQuery {
|
||||
pub is_not_schedule: Option<bool>,
|
||||
pub concurrency_key: Option<String>,
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct ListCompletedQuery {
|
||||
pub script_path_start: Option<String>,
|
||||
pub script_path_exact: Option<String>,
|
||||
pub script_path_start: Option<NegatedListFilter<String>>,
|
||||
pub script_path_exact: Option<NegatedListFilter<String>>,
|
||||
pub script_hash: Option<String>,
|
||||
pub created_by: Option<String>,
|
||||
pub created_by: Option<NegatedListFilter<String>>,
|
||||
pub started_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub started_after: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub created_before: Option<chrono::DateTime<chrono::Utc>>,
|
||||
@@ -142,7 +144,7 @@ pub struct ListCompletedQuery {
|
||||
pub running: Option<bool>,
|
||||
pub parent_job: Option<String>,
|
||||
pub order_desc: Option<bool>,
|
||||
pub job_kinds: Option<String>,
|
||||
pub job_kinds: Option<NegatedListFilter<String>>,
|
||||
pub is_skipped: Option<bool>,
|
||||
pub is_flow_step: Option<bool>,
|
||||
pub suspended: Option<bool>,
|
||||
@@ -151,17 +153,17 @@ pub struct ListCompletedQuery {
|
||||
pub args: Option<String>,
|
||||
// filter by matching a subset of the result using base64 encoded json subset
|
||||
pub result: Option<String>,
|
||||
pub tag: Option<String>,
|
||||
pub tag: Option<NegatedListFilter<String>>,
|
||||
pub scheduled_for_before_now: Option<bool>,
|
||||
pub all_workspaces: Option<bool>,
|
||||
pub has_null_parent: Option<bool>,
|
||||
pub label: Option<String>,
|
||||
pub label: Option<NegatedListFilter<String>>,
|
||||
pub is_not_schedule: Option<bool>,
|
||||
pub concurrency_key: Option<String>,
|
||||
pub worker: Option<String>,
|
||||
pub worker: Option<NegatedListFilter<String>>,
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub trigger_kind: Option<NegatedListFilter<JobTriggerKind>>,
|
||||
pub trigger_path: Option<NegatedListFilter<String>>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -578,8 +580,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_decode_payload_valid() {
|
||||
let payload = base64::engine::general_purpose::STANDARD
|
||||
.encode(r#"{"key": "value"}"#);
|
||||
let payload = base64::engine::general_purpose::STANDARD.encode(r#"{"key": "value"}"#);
|
||||
let result: HashMap<String, serde_json::Value> = decode_payload(payload).unwrap();
|
||||
assert_eq!(result["key"], json!("value"));
|
||||
}
|
||||
@@ -644,22 +645,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_run_job_query_payload_as_args_valid() {
|
||||
let encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(r#"{"x": 42}"#);
|
||||
let q = RunJobQuery {
|
||||
payload: Some(encoded),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(r#"{"x": 42}"#);
|
||||
let q = RunJobQuery { payload: Some(encoded), ..Default::default() };
|
||||
let result = q.payload_as_args().unwrap();
|
||||
assert!(result.contains_key("x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_run_job_query_payload_as_args_invalid() {
|
||||
let q = RunJobQuery {
|
||||
payload: Some("invalid!!!".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let q = RunJobQuery { payload: Some("invalid!!!".to_string()), ..Default::default() };
|
||||
assert!(q.payload_as_args().is_err());
|
||||
}
|
||||
|
||||
@@ -668,10 +662,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_list_completed_to_queue_query_conversion() {
|
||||
let lcq = ListCompletedQuery {
|
||||
script_path_start: Some("f/test".to_string()),
|
||||
script_path_start: Some(NegatedListFilter::positive(vec!["f/test".to_string()])),
|
||||
script_path_exact: None,
|
||||
script_hash: None,
|
||||
created_by: Some("admin".to_string()),
|
||||
created_by: Some(NegatedListFilter::positive(vec!["admin".to_string()])),
|
||||
started_before: None,
|
||||
started_after: None,
|
||||
created_before: Some(chrono::Utc::now()),
|
||||
@@ -687,14 +681,17 @@ mod tests {
|
||||
running: Some(true),
|
||||
parent_job: None,
|
||||
order_desc: Some(true),
|
||||
job_kinds: Some("script,flow".to_string()),
|
||||
job_kinds: Some(NegatedListFilter::positive(vec![
|
||||
"script".to_string(),
|
||||
"flow".to_string(),
|
||||
])),
|
||||
is_skipped: None,
|
||||
is_flow_step: None,
|
||||
suspended: None,
|
||||
schedule_path: None,
|
||||
args: None,
|
||||
result: None,
|
||||
tag: Some("custom".to_string()),
|
||||
tag: Some(NegatedListFilter::positive(vec!["custom".to_string()])),
|
||||
scheduled_for_before_now: None,
|
||||
all_workspaces: None,
|
||||
has_null_parent: None,
|
||||
@@ -709,11 +706,24 @@ mod tests {
|
||||
};
|
||||
|
||||
let lqq: ListQueueQuery = lcq.into();
|
||||
assert_eq!(lqq.script_path_start, Some("f/test".to_string()));
|
||||
assert_eq!(lqq.created_by, Some("admin".to_string()));
|
||||
assert_eq!(
|
||||
lqq.script_path_start
|
||||
.as_ref()
|
||||
.and_then(|f| f.values.first().cloned()),
|
||||
Some("f/test".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
lqq.created_by
|
||||
.as_ref()
|
||||
.and_then(|f| f.values.first().cloned()),
|
||||
Some("admin".to_string())
|
||||
);
|
||||
assert_eq!(lqq.running, Some(true));
|
||||
assert_eq!(lqq.job_kinds, Some("script,flow".to_string()));
|
||||
assert_eq!(lqq.tag, Some("custom".to_string()));
|
||||
assert_eq!(lqq.job_kinds.as_ref().map(|f| f.values.len()), Some(2));
|
||||
assert_eq!(
|
||||
lqq.tag.as_ref().and_then(|f| f.values.first().cloned()),
|
||||
Some("custom".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_super_admin, ApiAuthed};
|
||||
use windmill_common::DB;
|
||||
use axum::{
|
||||
extract::{Extension, Path, Query},
|
||||
routing::{delete, get, post},
|
||||
@@ -18,8 +16,10 @@ use serde::{Deserialize, Serialize};
|
||||
use sql_builder::{prelude::Bind, SqlBuilder};
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use std::str::FromStr;
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_super_admin, ApiAuthed};
|
||||
use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, JsonResult, Result},
|
||||
@@ -486,8 +486,15 @@ pub struct ListScheduleQuery {
|
||||
pub per_page: Option<usize>,
|
||||
pub path: Option<String>,
|
||||
pub is_flow: Option<bool>,
|
||||
// filter by matching a subset of the args using base64 encoded json subset
|
||||
pub args: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
// exact match on schedule path
|
||||
pub schedule_path: Option<String>,
|
||||
// filter on description (pattern match)
|
||||
pub description: Option<String>,
|
||||
// filter on summary (pattern match)
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -543,6 +550,18 @@ async fn list_schedule(
|
||||
if let Some(path_start) = &lsq.path_start {
|
||||
sqlb.and_where_like_left("path", path_start);
|
||||
}
|
||||
if let Some(schedule_path) = &lsq.schedule_path {
|
||||
sqlb.and_where_eq("path", "?".bind(schedule_path));
|
||||
}
|
||||
if let Some(description) = &lsq.description {
|
||||
sqlb.and_where(&format!(
|
||||
"description ILIKE '%{}%'",
|
||||
description.replace("'", "''")
|
||||
));
|
||||
}
|
||||
if let Some(summary) = &lsq.summary {
|
||||
sqlb.and_where(&format!("summary ILIKE '%{}%'", summary.replace("'", "''")));
|
||||
}
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let rows = sqlx::query_as::<_, ScheduleLight>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
|
||||
@@ -4091,6 +4091,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path
|
||||
description: exact path match filter
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: description
|
||||
description: pattern match filter for description field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: value
|
||||
description: pattern match filter for non-secret variable values (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- $ref: "#/components/parameters/Page"
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
responses:
|
||||
@@ -5090,6 +5105,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: path
|
||||
description: exact path match filter
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: description
|
||||
description: pattern match filter for description field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: value
|
||||
description: JSONB subset match filter using base64 encoded JSON
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: resource list
|
||||
@@ -11120,7 +11150,7 @@ paths:
|
||||
- $ref: "#/components/parameters/PerPage"
|
||||
- $ref: "#/components/parameters/ArgsFilter"
|
||||
- name: path
|
||||
description: filter by path
|
||||
description: filter by path (script path)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -11134,6 +11164,21 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: schedule_path
|
||||
description: exact match on the schedule's path
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: description
|
||||
description: pattern match filter for description field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: summary
|
||||
description: pattern match filter for summary field (case-insensitive)
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: schedule list
|
||||
@@ -16885,6 +16930,16 @@ paths:
|
||||
description: Filter by asset kinds (multiple values allowed)
|
||||
schema:
|
||||
type: string
|
||||
- name: path
|
||||
in: query
|
||||
description: exact path match filter
|
||||
schema:
|
||||
type: string
|
||||
- name: columns
|
||||
in: query
|
||||
description: JSONB subset match filter for columns using base64 encoded JSON
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: paginated assets in the workspace
|
||||
@@ -17258,10 +17313,10 @@ components:
|
||||
type: integer
|
||||
JobTriggerKind:
|
||||
name: trigger_kind
|
||||
description: trigger kind (schedule, http, websocket...)
|
||||
description: "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
|
||||
in: query
|
||||
schema:
|
||||
$ref: "#/components/schemas/JobTriggerKind"
|
||||
type: string
|
||||
OrderDesc:
|
||||
name: order_desc
|
||||
description: order by desc order (default true)
|
||||
@@ -17270,19 +17325,19 @@ components:
|
||||
type: boolean
|
||||
CreatedBy:
|
||||
name: created_by
|
||||
description: mask to filter exact matching user creator
|
||||
description: "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
Label:
|
||||
name: label
|
||||
description: mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string in the array at key 'wm_labels')
|
||||
description: "filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
Worker:
|
||||
name: worker
|
||||
description: worker this job was ran on
|
||||
description: "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17348,7 +17403,7 @@ components:
|
||||
type: string
|
||||
ScriptStartPath:
|
||||
name: script_path_start
|
||||
description: mask to filter matching starting path
|
||||
description: "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17360,13 +17415,13 @@ components:
|
||||
type: string
|
||||
TriggerPath:
|
||||
name: trigger_path
|
||||
description: mask to filter by trigger path
|
||||
description: "filter by trigger path. Supports comma-separated list (e.g. 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
ScriptExactPath:
|
||||
name: script_path_exact
|
||||
description: mask to filter exact matching path
|
||||
description: "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17481,7 +17536,7 @@ components:
|
||||
type: string
|
||||
Tag:
|
||||
name: tag
|
||||
description: filter on jobs with a given tag/worker group
|
||||
description: "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
@@ -17525,9 +17580,7 @@ components:
|
||||
enum: [Create, Update, Delete, Execute]
|
||||
JobKinds:
|
||||
name: job_kinds
|
||||
description:
|
||||
filter on job kind (values 'preview', 'script', 'dependencies', 'flow')
|
||||
separated by,
|
||||
description: "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
|
||||
@@ -160,9 +160,13 @@ struct EditResource {
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ListResourceQuery {
|
||||
resource_type: Option<String>,
|
||||
resource_type_exclude: Option<String>,
|
||||
path_start: Option<String>,
|
||||
pub resource_type: Option<String>,
|
||||
pub resource_type_exclude: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub description: Option<String>,
|
||||
// filter by matching a subset of the value using base64 encoded json subset
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -282,6 +286,18 @@ async fn list_resources(
|
||||
sqlb.and_where_like_left("resource.path", path_start);
|
||||
}
|
||||
|
||||
if let Some(path) = &lq.path {
|
||||
sqlb.and_where_eq("resource.path", "?".bind(path));
|
||||
}
|
||||
|
||||
if let Some(description) = &lq.description {
|
||||
sqlb.and_where("resource.description ILIKE ?".bind(&format!("%{}%", description)));
|
||||
}
|
||||
|
||||
if let Some(value) = &lq.value {
|
||||
sqlb.and_where("resource.value @> ?".bind(&value.replace("'", "''")));
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -96,7 +96,11 @@ async fn list_contextual_variables(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListVariableQuery {
|
||||
path_start: Option<String>,
|
||||
pub path_start: Option<String>,
|
||||
pub path: Option<String>,
|
||||
pub description: Option<String>,
|
||||
// filter by matching the non-encrypted value (for non-secrets only)
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_variables(
|
||||
@@ -106,33 +110,76 @@ async fn list_variables(
|
||||
Query(lq): Query<ListVariableQuery>,
|
||||
Query(pagination): Query<Pagination>,
|
||||
) -> JsonResult<Vec<ListableVariable>> {
|
||||
use sql_builder::{bind::Bind, SqlBuilder};
|
||||
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let mut sqlb = SqlBuilder::select_from("variable")
|
||||
.fields(&[
|
||||
"variable.workspace_id",
|
||||
"variable.path",
|
||||
"CASE WHEN is_secret IS TRUE THEN null ELSE variable.value::text END as value",
|
||||
"is_secret",
|
||||
"variable.description",
|
||||
"variable.extra_perms",
|
||||
"account",
|
||||
"is_oauth",
|
||||
"(now() > account.expires_at) as is_expired",
|
||||
"account.refresh_error",
|
||||
"resource.path IS NOT NULL as is_linked",
|
||||
"account.refresh_token != '' as is_refreshed",
|
||||
"variable.expires_at",
|
||||
])
|
||||
.left()
|
||||
.join("account")
|
||||
.on(&format!(
|
||||
"variable.account = account.id AND account.workspace_id = '{}'",
|
||||
w_id
|
||||
))
|
||||
.left()
|
||||
.join("resource")
|
||||
.on(&format!(
|
||||
"resource.path = variable.path AND resource.workspace_id = '{}'",
|
||||
w_id
|
||||
))
|
||||
.and_where("variable.workspace_id = ?".bind(&w_id))
|
||||
.and_where(&format!(
|
||||
"variable.path NOT LIKE 'u/' || '{}' || '/secret_arg/%'",
|
||||
authed.username
|
||||
))
|
||||
.order_by("path", false)
|
||||
.limit(per_page)
|
||||
.offset(offset)
|
||||
.clone();
|
||||
|
||||
let rows = sqlx::query_as::<_, ListableVariable>(
|
||||
"SELECT variable.workspace_id, variable.path, CASE WHEN is_secret IS TRUE THEN null ELSE variable.value::text END as value,
|
||||
is_secret, variable.description, variable.extra_perms, account, is_oauth, (now() > account.expires_at) as is_expired,
|
||||
account.refresh_error,
|
||||
resource.path IS NOT NULL as is_linked,
|
||||
account.refresh_token != '' as is_refreshed,
|
||||
variable.expires_at
|
||||
from variable
|
||||
LEFT JOIN account ON variable.account = account.id AND account.workspace_id = $1
|
||||
LEFT JOIN resource ON resource.path = variable.path AND resource.workspace_id = $1
|
||||
WHERE variable.workspace_id = $1 AND variable.path NOT LIKE 'u/' || $2 || '/secret_arg/%'
|
||||
AND variable.path LIKE $3 || '%'
|
||||
ORDER BY path
|
||||
LIMIT $4 OFFSET $5
|
||||
",
|
||||
)
|
||||
.bind(&w_id)
|
||||
.bind(&authed.username)
|
||||
.bind(&lq.path_start.unwrap_or_default())
|
||||
.bind(per_page as i32)
|
||||
.bind(offset as i32)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
if let Some(path_start) = &lq.path_start {
|
||||
sqlb.and_where_like_left("variable.path", path_start);
|
||||
}
|
||||
|
||||
if let Some(path) = &lq.path {
|
||||
sqlb.and_where_eq("variable.path", "?".bind(path));
|
||||
}
|
||||
|
||||
if let Some(description) = &lq.description {
|
||||
sqlb.and_where(&format!(
|
||||
"variable.description ILIKE '%{}%'",
|
||||
description.replace("'", "''")
|
||||
));
|
||||
}
|
||||
|
||||
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 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::<_, ListableVariable>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
|
||||
@@ -260,6 +260,6 @@
|
||||
} as any)
|
||||
</script>
|
||||
|
||||
<div class="relative max-h-40">
|
||||
<div class="relative h-44">
|
||||
<Line {data} {options} />
|
||||
</div>
|
||||
|
||||
@@ -184,13 +184,13 @@
|
||||
unifiedSize="md"
|
||||
wrapperClasses="h-full"
|
||||
{disabled}
|
||||
iconOnly
|
||||
endIcon={{ icon: X }}
|
||||
on:click={() => {
|
||||
value = null
|
||||
dispatch('clear')
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
></Button>
|
||||
{/if}
|
||||
<!-- <div>
|
||||
<ToggleButtonGroup bind:selected={format} let:item>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
type Item = {
|
||||
label: string
|
||||
icon?: any
|
||||
right?: string
|
||||
onClick?: () => void
|
||||
onHover?: (hover: boolean) => void
|
||||
}
|
||||
export type Props = {
|
||||
closeCallback?: () => void
|
||||
items: Item[]
|
||||
}
|
||||
let { items, closeCallback }: Props = $props()
|
||||
</script>
|
||||
|
||||
<ul class="bg-surface-tertiary rounded-md border w-56 relative drop-shadow-base">
|
||||
{#each items as item}
|
||||
<li class="w-full">
|
||||
<button
|
||||
class="px-3 h-9 text-xs cursor-pointer hover:bg-surface-hover font-normal w-full text-left flex items-center gap-2.5"
|
||||
onclick={() => {
|
||||
item.onClick?.()
|
||||
item.onHover?.(false)
|
||||
closeCallback?.()
|
||||
}}
|
||||
onmouseenter={() => item.onHover?.(true)}
|
||||
onmouseleave={() => item.onHover?.(false)}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon size="16"></item.icon>
|
||||
{/if}
|
||||
<span class="flex-1">
|
||||
{item.label}
|
||||
</span>
|
||||
{#if item.right}
|
||||
<span class="text-xs text-hint">{item.right}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{#if items.length === 0}
|
||||
<li class="w-full">
|
||||
<div
|
||||
class="px-3 h-9 text-xs font-normal w-full text-left flex items-center gap-2.5 text-hint"
|
||||
>
|
||||
No actions available
|
||||
</div>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
items?: Item[]
|
||||
extraLabel?: import('svelte').Snippet
|
||||
selected: string
|
||||
selectedDisplayName?: string
|
||||
btnClasses?: string
|
||||
}
|
||||
|
||||
let { items = [], extraLabel, selected, selectedDisplayName, btnClasses }: Props = $props()
|
||||
|
||||
const filteredItems = $derived(items.filter((item) => item.id !== selected))
|
||||
</script>
|
||||
|
||||
<DropdownV2 items={filteredItems}>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class={twMerge(
|
||||
'p-2 h-8 flex flex-row items-center gap-2 border hover:bg-surface-hover cursor-pointer rounded-md',
|
||||
btnClasses
|
||||
)}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-1 pr-2 justify-between w-full">
|
||||
<span class="text-xs whitespace-nowrap">
|
||||
{selectedDisplayName ?? items.find((item) => item.id === selected)?.displayName ?? ''}
|
||||
</span>
|
||||
|
||||
{@render extraLabel?.()}
|
||||
</div>
|
||||
<ChevronDown size={12} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
@@ -157,7 +157,7 @@
|
||||
variant="subtle"
|
||||
startIcon={{ icon: EllipsisVertical }}
|
||||
btnClasses="bg-transparent"
|
||||
iconOnly
|
||||
iconOnly={!btnText}
|
||||
>
|
||||
{btnText}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
<script lang="ts" module>
|
||||
import { z } from 'zod'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { formatDatePretty, parsePrettyDate, type IconType } from '$lib/utils'
|
||||
|
||||
export type FilterSchemaRec = Record<string, FilterSchema>
|
||||
export type FilterSchema = (
|
||||
| {
|
||||
type: 'string' | 'number' | 'boolean'
|
||||
allowMultiple?: boolean
|
||||
format?: 'json'
|
||||
}
|
||||
| {
|
||||
type: 'date'
|
||||
mode?: 'single' | 'end' | 'start'
|
||||
otherField?: string // For range display
|
||||
allowMultiple?: undefined
|
||||
}
|
||||
| {
|
||||
type: 'oneof'
|
||||
options: { value: string; label?: string; description?: string }[]
|
||||
allowCustomValue?: boolean
|
||||
allowNegative?: boolean
|
||||
allowMultiple?: boolean
|
||||
}
|
||||
) & {
|
||||
label?: string
|
||||
description?: string
|
||||
icon?: IconType
|
||||
}
|
||||
export type FilterInstanceRec<T extends FilterSchemaRec> = {
|
||||
[K in keyof T]: FilterInstance<T[K]>
|
||||
}
|
||||
export type FilterInstance<T extends FilterSchema> = T extends { type: 'string' }
|
||||
? string
|
||||
: T extends { type: 'number' }
|
||||
? number
|
||||
: T extends { type: 'boolean' }
|
||||
? boolean
|
||||
: T extends { type: 'date' }
|
||||
? Date
|
||||
: T extends { type: 'oneof'; options: any; allowCustomValue?: infer A }
|
||||
? A extends true
|
||||
? string
|
||||
: T['options'] extends { value: string }[]
|
||||
? _NegativeFilterInstance<T>
|
||||
: never
|
||||
: never
|
||||
|
||||
type _NegativeFilterInstance<T extends { options: { value: string }[] }> = T extends {
|
||||
allowNegative: true
|
||||
}
|
||||
? T['options'][number]['value'] | `!${T['options'][number]['value']}`
|
||||
: T['options'][number]['value']
|
||||
|
||||
/**
|
||||
* Converts a FilterSchemaRec to a Zod schema for validation
|
||||
*/
|
||||
export function filterSchemaRecToZodSchema<T extends FilterSchemaRec>(
|
||||
schemaRec: T
|
||||
): z.ZodObject<{
|
||||
[K in keyof T]: z.ZodType<FilterInstance<T[K]>>
|
||||
}> {
|
||||
const zodSchemaShape: Record<string, z.ZodType> = {}
|
||||
|
||||
for (const [key, filterSchema] of Object.entries(schemaRec)) {
|
||||
let fieldSchema: z.ZodType
|
||||
|
||||
if (filterSchema.type === 'string') {
|
||||
fieldSchema = z.string().nullable().default(null)
|
||||
} else if (filterSchema.type === 'number') {
|
||||
fieldSchema = z.number().nullable().default(null)
|
||||
} else if (filterSchema.type === 'boolean') {
|
||||
fieldSchema = z.boolean().nullable().default(null)
|
||||
} else if (filterSchema.type === 'date') {
|
||||
fieldSchema = z.string().nullable().default(null)
|
||||
} else if (filterSchema.type === 'oneof') {
|
||||
if (filterSchema.allowCustomValue) {
|
||||
// If custom values are allowed, accept any string
|
||||
fieldSchema = z.string().nullable().default(null)
|
||||
} else {
|
||||
// Extract the enum values from options
|
||||
const values = filterSchema.options.map((o) => o.value) as [string, ...string[]]
|
||||
fieldSchema = z.enum(values).nullable().default(null)
|
||||
}
|
||||
} else {
|
||||
// Fallback for unknown types
|
||||
fieldSchema = z.any().nullable().default(null)
|
||||
}
|
||||
|
||||
zodSchemaShape[key] = fieldSchema
|
||||
}
|
||||
|
||||
return z.object(zodSchemaShape) as any
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a URL-synced filter instance that automatically syncs with URL search parameters
|
||||
*/
|
||||
export function useUrlSyncedFilterInstance<T extends FilterSchemaRec>(
|
||||
schemaRec: T
|
||||
): { val: Partial<FilterInstanceRec<T>> } {
|
||||
// Build the Zod schema from the filter schema
|
||||
const zodSchema = filterSchemaRecToZodSchema(schemaRec)
|
||||
|
||||
// Create URL-synced search params
|
||||
const urlFilter = useSearchParams(zodSchema) as Record<string, unknown>
|
||||
|
||||
// Create the filter instance object
|
||||
const filterInstance: { val: Partial<FilterInstanceRec<T>> } = $state({ val: {} })
|
||||
|
||||
// Sync URL params to filter instance on initialization and when URL changes
|
||||
for (const key of Object.keys(schemaRec)) {
|
||||
let urlValue = urlFilter[key]
|
||||
if (schemaRec[key].type === 'date' && typeof urlValue === 'string') {
|
||||
const d = new Date(urlValue)
|
||||
urlValue = isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
if (urlValue !== undefined && urlValue !== null) {
|
||||
;(filterInstance.val as any)[key] = urlValue
|
||||
}
|
||||
}
|
||||
|
||||
// Sync filter instance changes back to URL params
|
||||
for (const key of Object.keys(schemaRec)) {
|
||||
$effect(() => {
|
||||
let filterValue = (filterInstance.val as any)[key]
|
||||
if (schemaRec[key].type === 'date' && filterValue instanceof Date) {
|
||||
// Convert Date to ISO string for URL
|
||||
filterValue = filterValue.toISOString()
|
||||
}
|
||||
if (untrack(() => urlFilter[key]) == filterValue) return // Avoid unnecessary updates
|
||||
if (filterValue !== undefined && filterValue !== null) {
|
||||
urlFilter[key] = filterValue
|
||||
} else {
|
||||
urlFilter[key] = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return filterInstance
|
||||
}
|
||||
|
||||
function filterToText<F extends FilterSchema>(filter: FilterInstance<F>, schema: F): string {
|
||||
if (schema.type === 'date') {
|
||||
const date =
|
||||
typeof filter === 'string'
|
||||
? new Date(filter)
|
||||
: typeof filter === 'number'
|
||||
? new Date(filter)
|
||||
: (filter as Date)
|
||||
return formatDatePretty(date)
|
||||
}
|
||||
return String(filter)
|
||||
}
|
||||
|
||||
function textToFilter(text: string, schema: FilterSchema): FilterInstance<FilterSchema> | null {
|
||||
if (schema.type === 'string') return text
|
||||
if (schema.type === 'number') {
|
||||
const num = Number(text)
|
||||
return isNaN(num) ? null : (num as any)
|
||||
}
|
||||
if (schema.type === 'boolean') {
|
||||
if (text.toLowerCase() === 'true') return true as any
|
||||
if (text.toLowerCase() === 'false') return false as any
|
||||
return null
|
||||
}
|
||||
if (schema.type === 'date') {
|
||||
const date = parsePrettyDate(text)
|
||||
return date ? (date as any) : null
|
||||
}
|
||||
if (schema.type === 'oneof') {
|
||||
return text
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export type FilterValidationError = { fields: string[]; error: string }
|
||||
|
||||
/**
|
||||
* Validates a filter instance against its schema.
|
||||
* Returns a list of validation errors, each with the affected fields and an error message.
|
||||
*/
|
||||
export function validateFilterInstance<T extends FilterSchemaRec>(
|
||||
schemaRec: T,
|
||||
instance: Partial<FilterInstanceRec<T>>
|
||||
): FilterValidationError[] {
|
||||
const errors: FilterValidationError[] = []
|
||||
|
||||
for (const [key, rawValue] of Object.entries(instance)) {
|
||||
const schema = schemaRec[key]
|
||||
if (!schema) continue
|
||||
|
||||
if (schema.type === 'date') {
|
||||
if (!rawValue || !((rawValue as any) instanceof Date) || isNaN(rawValue.getTime())) {
|
||||
errors.push({ fields: [key], error: `Invalid date format` })
|
||||
}
|
||||
} else if (schema.type === 'oneof') {
|
||||
const strValue = String(rawValue)
|
||||
const elements = schema.allowMultiple ? strValue.split(',') : [strValue]
|
||||
const validValues = schema.options.map((o) => o.value)
|
||||
|
||||
if (schema.allowMultiple && schema.allowNegative) {
|
||||
const hasPositive = elements.some((v) => !v.startsWith('!'))
|
||||
const hasNegative = elements.some((v) => v.startsWith('!'))
|
||||
if (hasPositive && hasNegative) {
|
||||
errors.push({
|
||||
fields: [key],
|
||||
error: `Cannot mix positive and negative values`
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (!schema.allowCustomValue) {
|
||||
const invalid = elements
|
||||
.map((v) => v.replace(/^!/, ''))
|
||||
.filter((v) => !validValues.includes(v))
|
||||
if (invalid.length > 0) {
|
||||
errors.push({
|
||||
fields: [key],
|
||||
error: `Invalid value${invalid.length > 1 ? 's' : ''}: ${invalid.join(', ')}`
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (schema.type === 'string' && schema.format === 'json') {
|
||||
try {
|
||||
JSON.parse(String(rawValue))
|
||||
} catch (e) {
|
||||
errors.push({ fields: [key], error: `Invalid JSON format` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { inputBaseClass, inputBorderClass, inputSizeClasses } from './text_input/TextInput.svelte'
|
||||
import { MinusIcon, SearchIcon } from 'lucide-svelte'
|
||||
import { assignObjInPlace, clone } from '$lib/utils'
|
||||
import GenericDropdown from './select/GenericDropdown.svelte'
|
||||
import SimpleEditor from './SimpleEditor.svelte'
|
||||
import TaggedTextInput from './TaggedTextInput.svelte'
|
||||
import { DebouncedTempValue, useTransformedSyncedValue } from '$lib/svelte5Utils.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import CloseButton from './common/CloseButton.svelte'
|
||||
import Popover from './meltComponents/Popover.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import InlineCalendarInput, {
|
||||
fromCalendarDate,
|
||||
toCalendarDate
|
||||
} from './common/InlineCalendarInput.svelte'
|
||||
import { ButtonType } from './common'
|
||||
|
||||
type Props<SchemaT extends FilterSchemaRec> = {
|
||||
schema: SchemaT
|
||||
value: Partial<FilterInstanceRec<SchemaT>>
|
||||
presets?: { name: string; value: string }[]
|
||||
class?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
type SchemaT = FilterSchemaRec // TODO: Generic
|
||||
let {
|
||||
schema,
|
||||
value: valueInput = $bindable(),
|
||||
presets: _presets = [],
|
||||
class: className,
|
||||
placeholder = 'Filter...'
|
||||
}: Props<SchemaT> = $props()
|
||||
|
||||
let _value = new DebouncedTempValue(
|
||||
() => clone(valueInput),
|
||||
(v) => !errors.length && (valueInput = clone(v)),
|
||||
(t) => Object.entries(t)
|
||||
)
|
||||
let value = $derived(_value.current)
|
||||
let errors = $derived(validateFilterInstance(schema, value))
|
||||
|
||||
let currentTag: keyof SchemaT | undefined = $state()
|
||||
let currentTextSegment = $state({ text: '', start: 0, end: 0 })
|
||||
let open = $state(false)
|
||||
let inputElement: HTMLDivElement | undefined = $state()
|
||||
let highlightedIndex = $state(0)
|
||||
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())
|
||||
}))
|
||||
)
|
||||
|
||||
let keyHighlightRegex = $derived(new RegExp(`\\b(${Object.keys(schema).join('|')}):`, 'g'))
|
||||
|
||||
let errorKeys = $derived(new Set(errors.flatMap((e) => e.fields)))
|
||||
let errorHighlights = $derived(
|
||||
[...errorKeys].map((key) => ({
|
||||
regex: new RegExp(`(?<=\\b${key}:)(?:\\\\.|[^\\s])+`),
|
||||
classes: 'text-red-500 dark:text-red-400'
|
||||
}))
|
||||
)
|
||||
|
||||
let menuItems = $derived.by(() => {
|
||||
if (!currentTag) {
|
||||
const searchText = currentTextSegment.text.trim().toLowerCase()
|
||||
return Object.entries(schema)
|
||||
.filter(([k, _]) => !(k in value))
|
||||
.filter(([k, filterSchema]) => {
|
||||
if (!searchText) return true
|
||||
const label = (filterSchema.label || k).toLowerCase()
|
||||
const key = k.toLowerCase()
|
||||
return label.includes(searchText) || key.includes(searchText)
|
||||
})
|
||||
.map(([key, filterSchema]) => ({
|
||||
type: 'filter' as const,
|
||||
key,
|
||||
filterSchema,
|
||||
onClick: () => {
|
||||
// Replace the text segment with the new filter tag
|
||||
const before = asText.val.slice(0, currentTextSegment.start)
|
||||
const after = asText.val.slice(currentTextSegment.end)
|
||||
asText.val =
|
||||
`${before}${before && !before.endsWith(' ') ? ' ' : ''}${key}:\\\u00A0${after}`.trim() +
|
||||
'\u00A0'
|
||||
}
|
||||
}))
|
||||
} else {
|
||||
const filter = schema[currentTag]
|
||||
if (filter.type === 'oneof') {
|
||||
// When allowMultiple, split on comma and match against the last segment
|
||||
const currentVal = String(value[currentTag!] ?? '')
|
||||
let searchSuffix: string
|
||||
if (filter.allowMultiple) {
|
||||
const parts = currentVal.split(',')
|
||||
searchSuffix = parts[parts.length - 1].replace(/^!/, '').trim()
|
||||
} else {
|
||||
searchSuffix = currentVal
|
||||
}
|
||||
|
||||
// Already-selected values (for allowMultiple, to avoid suggesting duplicates)
|
||||
const selectedValues = filter.allowMultiple
|
||||
? currentVal
|
||||
.split(',')
|
||||
.slice(0, -1)
|
||||
.map((v) => v.replace(/^!/, '').trim())
|
||||
: []
|
||||
|
||||
return filter.options
|
||||
.filter((o) => {
|
||||
if (selectedValues.includes(o.value)) return false
|
||||
if (!searchSuffix) return true
|
||||
return o.value.includes(searchSuffix)
|
||||
})
|
||||
.map((option) => ({
|
||||
type: 'option' as const,
|
||||
option,
|
||||
onClick: () =>
|
||||
appendOrSetValueForCurrentTag((currentVal.includes('!') ? '!' : '') + option.value),
|
||||
onNegativeClick: filter.allowNegative
|
||||
? () => appendOrSetValueForCurrentTag('!' + option.value)
|
||||
: undefined
|
||||
}))
|
||||
} else if (filter.type === 'boolean') {
|
||||
return [
|
||||
{
|
||||
type: 'boolean' as const,
|
||||
value: true,
|
||||
label: 'True',
|
||||
onClick: () => setValueForCurrentTag(true)
|
||||
},
|
||||
{
|
||||
type: 'boolean' as const,
|
||||
value: false,
|
||||
label: 'False',
|
||||
onClick: () => setValueForCurrentTag(false)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Reset highlighted index when menu items change
|
||||
$effect(() => {
|
||||
menuItems
|
||||
open
|
||||
highlightedIndex = 0
|
||||
})
|
||||
|
||||
const kvRegex = /\b(\w+):((?:[^\s\\]|\\.)*)/g
|
||||
|
||||
function parseFromText(text: string): Partial<FilterInstanceRec<SchemaT>> {
|
||||
const parsed: Record<string, string> = {}
|
||||
let match
|
||||
while ((match = kvRegex.exec(text)) !== null) {
|
||||
let [_, key, val] = match
|
||||
if (key in schema) {
|
||||
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
|
||||
}
|
||||
}
|
||||
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'
|
||||
)
|
||||
}
|
||||
|
||||
let asText = useTransformedSyncedValue(
|
||||
[() => (Object.entries(value), value), (v) => assignObjInPlace(value, v)],
|
||||
parseToText,
|
||||
parseFromText
|
||||
)
|
||||
|
||||
function setValueForCurrentTag(val: any) {
|
||||
if (!currentTag) return
|
||||
value[currentTag!] = val
|
||||
asText.reparse()
|
||||
}
|
||||
|
||||
/**
|
||||
* For allowMultiple fields: appends a new value to the existing comma-separated list,
|
||||
* replacing the last (in-progress) segment. For non-allowMultiple fields, behaves like setValueForCurrentTag.
|
||||
*/
|
||||
function appendOrSetValueForCurrentTag(val: string) {
|
||||
if (!currentTag) return
|
||||
const filter = schema[currentTag]
|
||||
if (filter.allowMultiple) {
|
||||
const existing = String(value[currentTag!] ?? '')
|
||||
const parts = existing.split(',')
|
||||
// If any existing part is negative, force the new value to be negative too
|
||||
const isNegativeContext = parts.slice(0, -1).some((p) => p.startsWith('!'))
|
||||
if (isNegativeContext && !val.startsWith('!')) val = '!' + val
|
||||
// Replace the last in-progress segment with the selected value
|
||||
parts[parts.length - 1] = val
|
||||
value[currentTag!] = parts.join(',')
|
||||
} else {
|
||||
value[currentTag!] = val
|
||||
}
|
||||
asText.reparse()
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (!open) return
|
||||
if (e.key === 'Escape') {
|
||||
open = false
|
||||
return
|
||||
}
|
||||
|
||||
if (menuItems.length && e.key === 'ArrowDown') {
|
||||
highlightedIndex = (highlightedIndex + 1) % menuItems.length
|
||||
} else if (menuItems.length && e.key === 'ArrowUp') {
|
||||
highlightedIndex = (highlightedIndex - 1 + menuItems.length) % menuItems.length
|
||||
} else if (e.key === 'Enter') {
|
||||
if (menuItems[highlightedIndex]) {
|
||||
menuItems[highlightedIndex].onClick()
|
||||
} else {
|
||||
setValueForCurrentTag(value[currentTag!])
|
||||
taggedTextInput?.focusAtEnd()
|
||||
}
|
||||
const currTagSchema = currentTag ? schema[currentTag] : undefined
|
||||
if (currTagSchema && 'format' in currTagSchema && currTagSchema.format === 'json') {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
type Preset = { name: string; value: string }
|
||||
let presets: Preset[] = $derived(
|
||||
_presets.filter((p) => {
|
||||
// Only show presets that aren't already applied in asText
|
||||
return !asText.val.includes(p.value)
|
||||
})
|
||||
)
|
||||
|
||||
function appendFilterAsText(presetValue: string) {
|
||||
if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += ' '
|
||||
asText.val += presetValue + '\u00A0'
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onmousedown={() => (open = false)} onkeydown={handleKeyDown} />
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex items-center rounded-md bg-surface-input overflow-clip',
|
||||
inputBorderClass({ error: errors.length > 0, forceFocus: open }),
|
||||
ButtonType.UnifiedHeightClasses.md,
|
||||
className
|
||||
)}
|
||||
onmousedown={(e) => {
|
||||
if (!open) {
|
||||
e.preventDefault()
|
||||
if (!asText.val.endsWith('\u00A0') && !asText.val.endsWith(' ')) asText.val += '\u00A0'
|
||||
taggedTextInput?.focusAtEnd()
|
||||
}
|
||||
open = true
|
||||
e.stopPropagation()
|
||||
}}
|
||||
bind:this={inputElement}
|
||||
>
|
||||
<TaggedTextInput
|
||||
bind:this={taggedTextInput}
|
||||
bind:value={asText.val}
|
||||
{tags}
|
||||
highlights={[
|
||||
{ regex: /![a-zA-Z0-9_\-\/]+/, classes: 'text-yellow-600 dark:text-yellow-500' },
|
||||
{ regex: keyHighlightRegex, classes: 'text-hint' },
|
||||
{ regex: /,/, classes: 'text-hint mr-0.5' },
|
||||
...errorHighlights
|
||||
]}
|
||||
onCurrentTagChange={(tag) => (currentTag = tag ? (tag.id as keyof SchemaT) : undefined)}
|
||||
onTextSegmentAtCursorChange={(segment) => (currentTextSegment = segment)}
|
||||
class={twMerge(
|
||||
'overflow-x-auto !pr-24 bg-surface-input outline-none scrollbar-hidden text-nowrap flex-1 mr-2 mt-0.5',
|
||||
inputBaseClass,
|
||||
inputSizeClasses.md
|
||||
)}
|
||||
{placeholder}
|
||||
/>
|
||||
{#if asText.val}
|
||||
<CloseButton small class="mr-1.5" onClick={() => (_value.current = {})} />
|
||||
{:else}
|
||||
<div class="mr-3">
|
||||
<SearchIcon size={16} class="text-hint" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<GenericDropdown
|
||||
{open}
|
||||
getInputRect={() => inputElement?.getBoundingClientRect() ?? new DOMRect()}
|
||||
innerClass="!max-h-[30rem]"
|
||||
strictWidth
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="py-1 p-2 overflow-y-auto" onmousedown={(e) => e.stopPropagation()}>
|
||||
{#if !currentTag || !schema[currentTag]}
|
||||
{#if presets.length}
|
||||
<div class="text-xs px-2 my-2 font-bold">Presets</div>
|
||||
<div class="mb-3 px-2 flex gap-2 flex-wrap">
|
||||
{#each presets as preset}
|
||||
{@render presetTag(preset)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-xs px-2 my-2 font-bold">Filters</div>
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'filter' && item.filterSchema}
|
||||
{@render menuItem({
|
||||
Icon: item.filterSchema.icon || SearchIcon,
|
||||
onClick: item.onClick,
|
||||
label: item.filterSchema.label || item.key,
|
||||
description: item.filterSchema.description,
|
||||
highlighted: index === highlightedIndex
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
{#key currentTag}
|
||||
{@render suggestion(schema[currentTag])}
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
</GenericDropdown>
|
||||
|
||||
{#snippet suggestion(filter: FilterSchema)}
|
||||
{#if filter.description}
|
||||
<div class="text-xs text-secondary px-2 my-2">{filter.description}</div>
|
||||
{/if}
|
||||
{#if filter.allowMultiple && (filter.type === 'string' || filter.type === 'oneof')}
|
||||
<div class="text-2xs text-hint px-2 -mt-1 mb-2">Separate multiple values with commas</div>
|
||||
{/if}
|
||||
{#if filter.type === 'oneof'}
|
||||
<div class="max-h-60 overflow-y-auto">
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'option' && item.option}
|
||||
{@render menuItem({
|
||||
onClick: item.onClick,
|
||||
label: item.option.label || item.option.value,
|
||||
highlighted: index === highlightedIndex,
|
||||
onNegativeClick: item.onNegativeClick
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if filter.type === 'boolean'}
|
||||
{#each menuItems as item, index}
|
||||
{#if item.type === 'boolean' && item.label}
|
||||
{@render menuItem({
|
||||
onClick: item.onClick,
|
||||
label: item.label,
|
||||
highlighted: index === highlightedIndex
|
||||
})}
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if filter.type === 'date'}
|
||||
{@const filterMode = filter.mode}
|
||||
<div class="p-3 mb-1">
|
||||
{#if !filterMode || filterMode === 'single'}
|
||||
<InlineCalendarInput
|
||||
bind:value={
|
||||
() => toCalendarDate(value[currentTag!]),
|
||||
(v) => {
|
||||
setValueForCurrentTag(fromCalendarDate(v))
|
||||
taggedTextInput?.preventCursorMoveOnNextSync()
|
||||
}
|
||||
}
|
||||
/>
|
||||
{:else}
|
||||
{@const curr = toCalendarDate(value[currentTag!])}
|
||||
{@const obj =
|
||||
filterMode === 'end'
|
||||
? { start: toCalendarDate(value[filter.otherField as keyof SchemaT]), end: curr }
|
||||
: { end: toCalendarDate(value[filter.otherField as keyof SchemaT]), start: curr }}
|
||||
<InlineCalendarInput
|
||||
mode="range"
|
||||
onClickBehavior={`set-${filterMode}`}
|
||||
infiniteRange
|
||||
bind:value={
|
||||
() => obj,
|
||||
(v) => {
|
||||
setValueForCurrentTag(fromCalendarDate(v[filterMode]))
|
||||
taggedTextInput?.preventCursorMoveOnNextSync()
|
||||
}
|
||||
}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if filter.type === 'string' && filter.format === 'json'}
|
||||
<div class="px-2 pb-2">
|
||||
<SimpleEditor
|
||||
autofocus={String(value[currentTag!] ?? '').length === 0}
|
||||
lang="json"
|
||||
autoHeight
|
||||
small
|
||||
bind:code={
|
||||
() => String(value[currentTag!] ?? ''),
|
||||
(v) => {
|
||||
setValueForCurrentTag(v ?? '')
|
||||
taggedTextInput?.preventCursorMoveOnNextSync()
|
||||
}
|
||||
}
|
||||
class="border border-border-light rounded min-h-[4rem]"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet menuItem({
|
||||
Icon,
|
||||
onClick,
|
||||
label,
|
||||
description,
|
||||
highlighted = false,
|
||||
onNegativeClick
|
||||
}: {
|
||||
Icon?: IconType
|
||||
onClick: () => void
|
||||
label: string
|
||||
description?: string
|
||||
highlighted?: boolean
|
||||
onNegativeClick?: () => void
|
||||
})}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'py-1.5 px-2 rounded-md hover:bg-surface-hover cursor-pointer text-sm flex items-center gap-3',
|
||||
highlighted && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={onClick}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon size={16} class="inline" />
|
||||
{/if}
|
||||
<div class="inline flex-1 relative min-w-0">
|
||||
<div class="text-sm ellipsize">{label}</div>
|
||||
{#if description}
|
||||
<div class="text-xs text-hint">{description}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if onNegativeClick}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<Popover openOnHover portal={null}>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
onClick={(e) => (e?.stopPropagation(), onNegativeClick?.())}
|
||||
iconOnly
|
||||
endIcon={{ icon: MinusIcon }}
|
||||
unifiedSize="xs"
|
||||
destructive
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="text-xs">Exclude {label}</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet presetTag({ name, value }: Preset)}
|
||||
<Badge onclick={() => appendFilterAsText(value)} clickable>
|
||||
{name}
|
||||
</Badge>
|
||||
{/snippet}
|
||||
@@ -104,14 +104,13 @@
|
||||
filters: {
|
||||
show_skipped: false,
|
||||
path: runnableId,
|
||||
success: 'running',
|
||||
arg: searchArgs ? JSON.stringify(searchArgs) : '',
|
||||
per_page: 5
|
||||
status: 'running',
|
||||
arg: searchArgs ? JSON.stringify(searchArgs) : ''
|
||||
},
|
||||
perPage: 5,
|
||||
jobKinds: getJobKinds(runnableType),
|
||||
syncQueuedRunsCount: false,
|
||||
refreshRate: 10000,
|
||||
computeMinAndMax: undefined,
|
||||
currentWorkspace: $workspaceStore ?? '',
|
||||
skip: !runnableId
|
||||
}) satisfies UseJobLoaderArgs
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import { fly } from 'svelte/transition'
|
||||
import Portal from './Portal.svelte'
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
type Props = {
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
const { children }: Props = $props()
|
||||
|
||||
let _isOpen = $state(false)
|
||||
let mousePos = $state({ x: 0, y: 0 })
|
||||
export function open(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
_isOpen = true
|
||||
mousePos = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
export function close() {
|
||||
_isOpen = false
|
||||
}
|
||||
export function isOpen() {
|
||||
return _isOpen
|
||||
}
|
||||
</script>
|
||||
|
||||
<Portal>
|
||||
{#if _isOpen}
|
||||
<div
|
||||
in:fly={{ x: 0, y: -10, duration: 120 }}
|
||||
use:clickOutside={{
|
||||
onClickOutside: (e) => {
|
||||
_isOpen = false
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
}}
|
||||
class="absolute left-0 top-0 z-[9999] w-fit"
|
||||
style="transform: translate({mousePos.x + 2}px, {mousePos.y + 2}px)"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</Portal>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import 'chartjs-adapter-date-fns'
|
||||
import zoomPlugin from 'chartjs-plugin-zoom'
|
||||
import Tooltip2 from '$lib/components/Tooltip.svelte'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
Title,
|
||||
@@ -17,7 +16,6 @@
|
||||
} from 'chart.js'
|
||||
import type { CompletedJob } from '$lib/gen'
|
||||
import { getDbClockNow } from '$lib/forLater'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { Scatter } from '$lib/components/chartjs-wrappers/chartJs'
|
||||
import DarkModeObserver from './DarkModeObserver.svelte'
|
||||
|
||||
@@ -28,10 +26,7 @@
|
||||
maxTimeSet?: string | null
|
||||
selectedIds?: string[]
|
||||
canSelect?: boolean
|
||||
lastFetchWentToEnd?: boolean
|
||||
totalRowsFetched: number
|
||||
onPointClicked: (ids: string[]) => void
|
||||
onLoadExtra: () => void
|
||||
onZoom: (zoom: { min: Date; max: Date }) => void
|
||||
}
|
||||
|
||||
@@ -42,10 +37,7 @@
|
||||
maxTimeSet = null,
|
||||
selectedIds = $bindable([]),
|
||||
canSelect = true,
|
||||
lastFetchWentToEnd = false,
|
||||
totalRowsFetched,
|
||||
onPointClicked,
|
||||
onLoadExtra,
|
||||
onZoom
|
||||
}: Props = $props()
|
||||
|
||||
@@ -299,23 +291,6 @@
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<!-- {JSON.stringify(minTime)}
|
||||
{JSON.stringify(maxTime)}
|
||||
|
||||
{JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<!-- {minTime}
|
||||
{maxTime} -->
|
||||
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<div class="relative max-h-40">
|
||||
{#if !lastFetchWentToEnd}
|
||||
<div class="absolute top-[-28px] left-[220px]">
|
||||
<Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}>
|
||||
Load more
|
||||
<Tooltip2>
|
||||
There are more jobs to load but only the first {totalRowsFetched} were fetched
|
||||
</Tooltip2>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative h-44">
|
||||
<Scatter {data} options={scatterOptions} />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,12 +4,15 @@
|
||||
const bubble = createBubbler()
|
||||
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
|
||||
|
||||
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
|
||||
import TimeframeSelect, {
|
||||
serviceLogsTimeframes,
|
||||
useUrlSyncedTimeframe
|
||||
} from './runs/TimeframeSelect.svelte'
|
||||
import CalendarPicker from './common/calendarPicker/CalendarPicker.svelte'
|
||||
import LogViewer from './LogViewer.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { onDestroy, tick } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
|
||||
import LogSnippetViewer from './LogSnippetViewer.svelte'
|
||||
@@ -20,6 +23,7 @@
|
||||
import Select from './select/Select.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { watch } from 'runed'
|
||||
|
||||
interface Props {
|
||||
searchTerm: string
|
||||
@@ -32,9 +36,6 @@
|
||||
let minTs: undefined | string = $state(undefined)
|
||||
let maxTs: undefined | string = $state(undefined)
|
||||
|
||||
let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
|
||||
let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
|
||||
|
||||
let max_lines: undefined | number = $state(undefined)
|
||||
|
||||
// let lastSeen: undefined | string = undefined
|
||||
@@ -58,15 +59,17 @@
|
||||
let timeout: number | undefined = $state(undefined)
|
||||
|
||||
let allLogs: ByMode | undefined = $state(undefined)
|
||||
let manualPicker: ManuelDatePicker | undefined = $state(undefined)
|
||||
|
||||
let _timeframe = useUrlSyncedTimeframe(serviceLogsTimeframes)
|
||||
let timeframe = $derived(_timeframe.timeframe)
|
||||
|
||||
let [minTsManual, maxTsManual] = $derived(
|
||||
timeframe.type === 'manual' ? [timeframe.minTs ?? undefined, timeframe.maxTs ?? undefined] : []
|
||||
)
|
||||
|
||||
let upTo: undefined | string = $state(undefined)
|
||||
let upToIsLatest = $state(true)
|
||||
|
||||
function onManualChanges() {
|
||||
getAllLogs(minTsManual ?? maxTs, maxTsManual)
|
||||
}
|
||||
|
||||
function getAllLogs(queryMinTs: string | undefined, queryMaxTs: string | undefined) {
|
||||
timeout && clearTimeout(timeout)
|
||||
loading = true
|
||||
@@ -151,11 +154,6 @@
|
||||
if (autoRefresh && searchTerm === '' && !maxTsManual) {
|
||||
timeout = setTimeout(() => {
|
||||
if (searchTerm !== '') return
|
||||
let minMax = manualPicker?.computeMinMax()
|
||||
if (minMax) {
|
||||
maxTsManual = minMax?.maxTs ?? undefined
|
||||
minTsManual = minMax?.minTs ?? undefined
|
||||
}
|
||||
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
|
||||
getAllLogs(maxTsPlus1?.toISOString(), undefined)
|
||||
}, 5000)
|
||||
@@ -315,8 +313,6 @@
|
||||
) {
|
||||
const params = new URLSearchParams()
|
||||
if (searchTerm) params.set('searchTerm', searchTerm)
|
||||
if (minTs) params.set('minTs', minTs)
|
||||
if (maxTs) params.set('maxTs', maxTs)
|
||||
if (selected?.mode) params.set('mode', selected.mode)
|
||||
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
|
||||
if (selected?.hostname) params.set('hostname', selected.hostname)
|
||||
@@ -435,13 +431,22 @@
|
||||
)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
minTsManual || maxTsManual || untrack(() => onManualChanges())
|
||||
})
|
||||
$effect(() => {
|
||||
;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
|
||||
untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
|
||||
})
|
||||
watch(
|
||||
() => timeframe,
|
||||
() => {
|
||||
const ts = timeframe.computeMinMax()
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => [searchTerm, selected, timeframe, allLogs],
|
||||
() => {
|
||||
searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
|
||||
@@ -477,71 +482,19 @@
|
||||
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
|
||||
id="service-logs-date-pickers"
|
||||
>
|
||||
<div class="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={minTsManual
|
||||
? new Date(minTsManual).toLocaleTimeString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'min datetime'}
|
||||
disabled
|
||||
/>
|
||||
<CalendarPicker
|
||||
label="min datetime"
|
||||
date={minTsManual}
|
||||
on:change={({ detail }) => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
minTsManual = detail
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
}}
|
||||
placement="top-start"
|
||||
/>
|
||||
</div>
|
||||
<ManuelDatePicker
|
||||
bind:minTs={() => minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
|
||||
bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
|
||||
bind:this={manualPicker}
|
||||
<TimeframeSelect
|
||||
items={serviceLogsTimeframes}
|
||||
bind:value={timeframe}
|
||||
{loading}
|
||||
on:loadJobs={() => {
|
||||
wrapperClasses="w-full"
|
||||
onClick={() => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
const ts = timeframe.computeMinMax()
|
||||
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
|
||||
}}
|
||||
serviceLogsChoices
|
||||
loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
|
||||
/>
|
||||
<div class="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={maxTsManual
|
||||
? new Date(maxTsManual).toLocaleTimeString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'max datetime'}
|
||||
disabled
|
||||
/>
|
||||
<CalendarPicker
|
||||
label="max datetime"
|
||||
date={maxTsManual}
|
||||
on:change={({ detail }) => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
maxTsManual = detail
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row-reverse pb-4 -mt-2 gap-2"
|
||||
><Toggle
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
tags,
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
highlights,
|
||||
onCurrentTagChange,
|
||||
onTextSegmentAtCursorChange,
|
||||
class: className = ''
|
||||
}: {
|
||||
tags: { regex: RegExp; id: string; onClear?: () => void }[]
|
||||
value?: string
|
||||
placeholder?: string
|
||||
highlights?: { regex: RegExp; classes: string }[]
|
||||
onCurrentTagChange?: (tag: { id: string } | null) => void
|
||||
onTextSegmentAtCursorChange?: (segment: { text: string; start: number; end: number }) => void
|
||||
class?: string
|
||||
} = $props()
|
||||
|
||||
let contentEditableDiv: HTMLDivElement
|
||||
let isUpdating = false
|
||||
|
||||
$effect(() => {
|
||||
if (!value.trim() && value !== '') value = ''
|
||||
})
|
||||
|
||||
let _preventCursorMoveOnNextSync = false
|
||||
// Update the displayed HTML when value changes externally
|
||||
$effect(() => {
|
||||
if (contentEditableDiv && !isUpdating) {
|
||||
const currentText = getTextContent()
|
||||
if (currentText !== value) {
|
||||
updateDisplay(value)
|
||||
if (!_preventCursorMoveOnNextSync) {
|
||||
restoreCursor(value.length)
|
||||
const cursorPos = getCursorPosition()
|
||||
updateCurrentTag(cursorPos)
|
||||
}
|
||||
}
|
||||
}
|
||||
_preventCursorMoveOnNextSync = false
|
||||
})
|
||||
|
||||
export function preventCursorMoveOnNextSync() {
|
||||
_preventCursorMoveOnNextSync = true
|
||||
}
|
||||
|
||||
function getTextContent(): string {
|
||||
if (!contentEditableDiv) return ''
|
||||
return contentEditableDiv.textContent || ''
|
||||
}
|
||||
|
||||
function updateDisplay(text: string) {
|
||||
if (!contentEditableDiv) return
|
||||
|
||||
const html = highlightText(text)
|
||||
contentEditableDiv.innerHTML = html
|
||||
}
|
||||
|
||||
/** Apply secondary highlight spans within a raw-text chunk. Returns HTML. */
|
||||
function applyHighlightsToChunk(rawText: string): string {
|
||||
if (!highlights || highlights.length === 0) return escapeHtml(rawText)
|
||||
|
||||
// Find all highlight matches in the raw text
|
||||
const hlMatches: Array<{ start: number; end: number; classes: string }> = []
|
||||
for (const hl of highlights) {
|
||||
const regex = new RegExp(hl.regex, 'g')
|
||||
let m
|
||||
while ((m = regex.exec(rawText)) !== null) {
|
||||
hlMatches.push({ start: m.index, end: m.index + m[0].length, classes: hl.classes })
|
||||
}
|
||||
}
|
||||
if (hlMatches.length === 0) return escapeHtml(rawText)
|
||||
|
||||
// Sort and deduplicate (keep first on overlap)
|
||||
hlMatches.sort((a, b) => a.start - b.start)
|
||||
const filtered: typeof hlMatches = []
|
||||
let lastEnd = -1
|
||||
for (const m of hlMatches) {
|
||||
if (m.start >= lastEnd) {
|
||||
filtered.push(m)
|
||||
lastEnd = m.end
|
||||
}
|
||||
}
|
||||
|
||||
let result = ''
|
||||
let idx = 0
|
||||
for (const m of filtered) {
|
||||
if (m.start > idx) {
|
||||
result += escapeHtml(rawText.slice(idx, m.start))
|
||||
}
|
||||
result += `<span class="${m.classes}">${escapeHtml(rawText.slice(m.start, m.end))}</span>`
|
||||
idx = m.end
|
||||
}
|
||||
if (idx < rawText.length) {
|
||||
result += escapeHtml(rawText.slice(idx))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function highlightText(text: string): string {
|
||||
if (!text) return ''
|
||||
|
||||
// Create a list of all matches with their positions
|
||||
const matches: Array<{ start: number; end: number; tagIndex: number }> = []
|
||||
|
||||
tags.forEach((tag, tagIndex) => {
|
||||
const regex = new RegExp(tag.regex, 'g')
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
matches.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
tagIndex
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Sort matches by start position
|
||||
matches.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Remove overlapping matches (keep the first one)
|
||||
const filteredMatches: Array<{ start: number; end: number; tagIndex: number }> = []
|
||||
let lastEnd = -1
|
||||
for (const match of matches) {
|
||||
if (match.start >= lastEnd) {
|
||||
filteredMatches.push(match)
|
||||
lastEnd = match.end
|
||||
}
|
||||
}
|
||||
|
||||
// Build HTML with highlighted segments
|
||||
let html = ''
|
||||
let lastIndex = 0
|
||||
|
||||
for (const match of filteredMatches) {
|
||||
// Add text before the match (apply secondary highlights)
|
||||
if (match.start > lastIndex) {
|
||||
html += applyHighlightsToChunk(text.slice(lastIndex, match.start))
|
||||
}
|
||||
|
||||
// Add highlighted match (with secondary highlights applied inside)
|
||||
const matchedText = text.slice(match.start, match.end)
|
||||
const tagId = tags[match.tagIndex].id
|
||||
const hasClear = !!tags[match.tagIndex].onClear
|
||||
const clearBtn = hasClear
|
||||
? `<span data-clear-tag="${tagId}" class="inline-flex w-2.5 h-3 ml-1 cursor-pointer opacity-50 hover:opacity-100" style="vertical-align: middle;"><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="pointer-events:none"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></span>`
|
||||
: ''
|
||||
html += `<span class="bg-surface-sunken/50 border border-border-light py-0.5 px-1.5 rounded" data-tag-id="${tagId}">${applyHighlightsToChunk(matchedText)}${clearBtn}</span>`
|
||||
|
||||
lastIndex = match.end
|
||||
}
|
||||
|
||||
// Add remaining text (apply secondary highlights)
|
||||
if (lastIndex < text.length) {
|
||||
html += applyHighlightsToChunk(text.slice(lastIndex))
|
||||
}
|
||||
|
||||
return html
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div')
|
||||
div.textContent = text
|
||||
let html = div.innerHTML
|
||||
html = html.replace(/\\(n|r|.)/g, (match, c) => {
|
||||
const display = c === 'n' ? '↵' : c === 'r' ? '↵' : c
|
||||
return (
|
||||
'<span style="display: inline; width: 0; height: 0; overflow: hidden; position: absolute;">\\</span>' +
|
||||
display
|
||||
)
|
||||
})
|
||||
return html
|
||||
}
|
||||
|
||||
let lastText = ''
|
||||
|
||||
function applyTextUpdate(newText: string, newCursorPos: number) {
|
||||
value = newText
|
||||
updateDisplay(newText)
|
||||
restoreCursor(newCursorPos)
|
||||
updateCurrentTag(newCursorPos)
|
||||
lastText = newText
|
||||
isUpdating = false
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
isUpdating = true
|
||||
const cursorPos = getCursorPosition()
|
||||
let newText = getTextContent()
|
||||
|
||||
// Remove any "\." sequences that were added by browser smart punctuation
|
||||
// These would only be created by macOS/browser when user double-presses space
|
||||
if (newText.includes('\\.')) {
|
||||
const cleanedText = newText.replace(/\\\./g, '')
|
||||
const removedCount = (newText.length - cleanedText.length) / 2 // Each "\." is 2 chars
|
||||
applyTextUpdate(cleanedText, cursorPos - removedCount * 2)
|
||||
return
|
||||
}
|
||||
|
||||
// Escape any literal newlines (e.g. from Shift+Enter or IME input)
|
||||
if (newText.includes('\n') || newText.includes('\r')) {
|
||||
const before = newText.slice(0, cursorPos)
|
||||
const newlinesBefore = (before.match(/[\n\r]/g) || []).length
|
||||
const cleanedText = newText.replace(/\r\n/g, '\\n').replace(/[\n\r]/g, '\\n')
|
||||
// Each newline becomes 2 chars (\n), so cursor shifts by +1 per newline before it
|
||||
applyTextUpdate(cleanedText, cursorPos + newlinesBefore)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user just typed an escaped character
|
||||
if (
|
||||
newText.length > lastText.length &&
|
||||
(newText[cursorPos - 1] === ' ' ||
|
||||
newText[cursorPos - 1] === '\u00A0' ||
|
||||
newText[cursorPos - 1] === '\\')
|
||||
) {
|
||||
// Check if there's already an escaped space right before the cursor (e.g., "tag\ |")
|
||||
// If user types another space, just remove the backslash instead of adding "\ \"
|
||||
if (
|
||||
(newText[cursorPos - 1] === ' ' || newText[cursorPos - 1] === '\u00A0') &&
|
||||
newText[cursorPos - 3] === '\\' &&
|
||||
(newText[cursorPos - 2] === ' ' || newText[cursorPos - 2] === '\u00A0')
|
||||
) {
|
||||
// Remove the backslash before the existing space
|
||||
newText = newText.slice(0, cursorPos - 3) + newText.slice(cursorPos - 2)
|
||||
applyTextUpdate(newText, cursorPos - 1)
|
||||
return
|
||||
}
|
||||
|
||||
// Escape the space/backslash by adding backslash before it
|
||||
newText = newText.slice(0, cursorPos - 1) + '\\' + newText.slice(cursorPos - 1)
|
||||
applyTextUpdate(newText, cursorPos + 1)
|
||||
return
|
||||
}
|
||||
|
||||
applyTextUpdate(newText, cursorPos)
|
||||
}
|
||||
|
||||
function getTextSegmentAtCursor(cursorPos: number): {
|
||||
text: string
|
||||
start: number
|
||||
end: number
|
||||
} | null {
|
||||
// Find all tag positions
|
||||
const tagPositions: Array<{ start: number; end: number }> = []
|
||||
for (const tag of tags) {
|
||||
const regex = new RegExp(tag.regex, 'g')
|
||||
let match
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
tagPositions.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by start position
|
||||
tagPositions.sort((a, b) => a.start - b.start)
|
||||
|
||||
// Find the text segment containing the cursor
|
||||
let segmentStart = 0
|
||||
let segmentEnd = value.length
|
||||
|
||||
for (const tag of tagPositions) {
|
||||
if (cursorPos <= tag.start) {
|
||||
// Cursor is before this tag
|
||||
segmentEnd = tag.start
|
||||
break
|
||||
} else if (cursorPos > tag.end) {
|
||||
// Cursor is after this tag
|
||||
segmentStart = tag.end
|
||||
} else {
|
||||
// Cursor is inside a tag
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: value.slice(segmentStart, segmentEnd).trim(),
|
||||
start: segmentStart,
|
||||
end: segmentEnd
|
||||
}
|
||||
}
|
||||
|
||||
function updateCurrentTag(cursorPos: number) {
|
||||
let currentTag: { id: string } | null = null
|
||||
|
||||
for (const tag of tags) {
|
||||
const regex = new RegExp(tag.regex, 'g')
|
||||
let match
|
||||
while ((match = regex.exec(value)) !== null) {
|
||||
const start = match.index
|
||||
const end = match.index + match[0].length
|
||||
if (cursorPos >= start && cursorPos <= end) {
|
||||
currentTag = { id: tag.id }
|
||||
|
||||
onCurrentTagChange?.(currentTag)
|
||||
onTextSegmentAtCursorChange?.({ text: '', start: 0, end: 0 })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCurrentTagChange?.(null)
|
||||
|
||||
// Get text segment at cursor when not in a tag
|
||||
const textSegment = getTextSegmentAtCursor(cursorPos)
|
||||
if (textSegment) {
|
||||
onTextSegmentAtCursorChange?.(textSegment)
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
// Check if the click landed on a clear button
|
||||
const target = e.target as HTMLElement | null
|
||||
const clearTarget = target?.closest<HTMLElement>('[data-clear-tag]')
|
||||
if (clearTarget) {
|
||||
const tagId = clearTarget.dataset.clearTag!
|
||||
const tag = tags.find((t) => t.id === tagId)
|
||||
tag?.onClear?.()
|
||||
return
|
||||
}
|
||||
const cursorPos = getCursorPosition()
|
||||
updateCurrentTag(cursorPos)
|
||||
}
|
||||
|
||||
function handleKeyup(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
|
||||
const cursorPos = getCursorPosition()
|
||||
updateCurrentTag(cursorPos)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter') return
|
||||
const cursorPos = getCursorPosition()
|
||||
const text = getTextContent()
|
||||
|
||||
// Handle Backspace key to remove escape sequences
|
||||
if (e.key === 'Backspace') {
|
||||
// Check if we're right after an escaped character (e.g., "abc\ |def")
|
||||
// We want to remove both the backslash and the escaped character
|
||||
if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
const newText = text.slice(0, cursorPos - 2) + text.slice(cursorPos)
|
||||
applyTextUpdate(newText, cursorPos - 2)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Delete key to remove escape sequences
|
||||
if (e.key === 'Delete') {
|
||||
// Check if the character at cursor position is a backslash (escape character)
|
||||
if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
const newText = text.slice(0, cursorPos) + text.slice(cursorPos + 2)
|
||||
applyTextUpdate(newText, cursorPos)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrow key navigation to skip escape sequences
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
if (e.key === 'ArrowLeft' && cursorPos > 0) {
|
||||
// Moving left: check if we're right after an escaped character (e.g., "abc\ |def")
|
||||
// We want to skip over the backslash and the escaped character
|
||||
if (cursorPos >= 2 && text[cursorPos - 2] === '\\') {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
restoreCursor(cursorPos - 2)
|
||||
updateCurrentTag(cursorPos - 2)
|
||||
isUpdating = false
|
||||
return
|
||||
}
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
// Moving right: check if we're at a backslash (e.g., "abc|\ def")
|
||||
// We want to skip over the backslash and the escaped character
|
||||
if (cursorPos < text.length && text[cursorPos] === '\\' && cursorPos + 1 < text.length) {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
restoreCursor(cursorPos + 2)
|
||||
updateCurrentTag(cursorPos + 2)
|
||||
isUpdating = false
|
||||
return
|
||||
}
|
||||
|
||||
// If user pressed right arrow and is at the end, add a space if needed
|
||||
if (
|
||||
cursorPos === text.length &&
|
||||
text.length > 0 &&
|
||||
((text[text.length - 1] !== ' ' && text[text.length - 1] !== '\u00A0') ||
|
||||
text[text.length - 2] === '\\')
|
||||
) {
|
||||
e.preventDefault()
|
||||
isUpdating = true
|
||||
const newText = text + '\u00A0'
|
||||
applyTextUpdate(newText, newText.length)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCursorPosition(): number {
|
||||
if (!contentEditableDiv) return 0
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return 0
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const preCaretRange = range.cloneRange()
|
||||
preCaretRange.selectNodeContents(contentEditableDiv)
|
||||
preCaretRange.setEnd(range.endContainer, range.endOffset)
|
||||
|
||||
return preCaretRange.toString().length
|
||||
}
|
||||
|
||||
function restoreCursor(position: number) {
|
||||
if (!contentEditableDiv) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
let currentPos = 0
|
||||
let node: Node | null = null
|
||||
let offset = 0
|
||||
|
||||
function traverse(n: Node): boolean {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
const textLength = n.textContent?.length || 0
|
||||
if (currentPos + textLength >= position) {
|
||||
node = n
|
||||
offset = position - currentPos
|
||||
return true
|
||||
}
|
||||
currentPos += textLength
|
||||
} else {
|
||||
for (let i = 0; i < n.childNodes.length; i++) {
|
||||
if (traverse(n.childNodes[i])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
traverse(contentEditableDiv)
|
||||
|
||||
if (node) {
|
||||
const range = document.createRange()
|
||||
range.setStart(node, offset)
|
||||
range.collapse(true)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
// Ensure cursor is visible by scrolling if needed
|
||||
ensureCursorVisible()
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCursorVisible() {
|
||||
if (!contentEditableDiv) return
|
||||
|
||||
const selection = window.getSelection()
|
||||
if (!selection || selection.rangeCount === 0) return
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const rect = range.getBoundingClientRect()
|
||||
const containerRect = contentEditableDiv.getBoundingClientRect()
|
||||
|
||||
// Check if cursor is outside the visible area horizontally
|
||||
if (rect.left < containerRect.left) {
|
||||
// Cursor is to the left of visible area
|
||||
contentEditableDiv.scrollLeft -= containerRect.left - rect.left + 10
|
||||
} else if (rect.right > containerRect.right) {
|
||||
// Cursor is to the right of visible area
|
||||
contentEditableDiv.scrollLeft += rect.right - containerRect.right + 10
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
e.preventDefault()
|
||||
let text = e.clipboardData?.getData('text/plain') || ''
|
||||
// Escape backslashes, spaces, and newlines
|
||||
text = text
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/ /g, '\\ ')
|
||||
.replace(/\r\n/g, '\\n')
|
||||
.replace(/[\n\r]/g, '\\n')
|
||||
document.execCommand('insertText', false, text)
|
||||
}
|
||||
|
||||
export function focusAtEnd() {
|
||||
if (!contentEditableDiv) return
|
||||
contentEditableDiv.focus()
|
||||
restoreCursor(value.length)
|
||||
updateCurrentTag(value.length)
|
||||
contentEditableDiv.scrollLeft = contentEditableDiv.scrollWidth
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={contentEditableDiv}
|
||||
contenteditable="true"
|
||||
oninput={handleInput}
|
||||
onpaste={handlePaste}
|
||||
onclick={handleClick}
|
||||
onkeydown={handleKeyDown}
|
||||
onkeyup={handleKeyup}
|
||||
class="outline-none text-nowrap pt-[0.45rem] {className}"
|
||||
class:text-hint={value === ''}
|
||||
data-placeholder={placeholder}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
spellcheck="false"
|
||||
></div>
|
||||
|
||||
<style>
|
||||
[contenteditable][data-placeholder]:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -19,6 +19,7 @@
|
||||
markdownTooltip?: string | undefined
|
||||
customSize?: string
|
||||
class?: string
|
||||
Icon?: typeof InfoIcon
|
||||
children?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@
|
||||
markdownTooltip = undefined,
|
||||
customSize = '100%',
|
||||
class: classNames = '',
|
||||
Icon = InfoIcon,
|
||||
children
|
||||
}: Props = $props()
|
||||
const plugins = [gfmPlugin()]
|
||||
@@ -53,7 +55,7 @@
|
||||
? 'text-primary-inverse'
|
||||
: 'text-primary'} {classNames} relative"
|
||||
>
|
||||
<InfoIcon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
|
||||
<Icon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
|
||||
</div>
|
||||
{#snippet text()}
|
||||
{#if markdownTooltip}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FileCode, FolderIcon, Box, Braces } from 'lucide-svelte'
|
||||
import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildAssetsFilterSchema({
|
||||
paths,
|
||||
assetKinds
|
||||
}: {
|
||||
paths: string[]
|
||||
assetKinds: string[]
|
||||
}) {
|
||||
return {
|
||||
asset_path: {
|
||||
type: 'string' as const,
|
||||
label: 'Asset path pattern',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by asset path pattern (case-insensitive)'
|
||||
},
|
||||
asset_kinds: {
|
||||
type: 'oneof' as const,
|
||||
options: assetKinds.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: false,
|
||||
allowNegative: false,
|
||||
allowMultiple: true,
|
||||
label: 'Asset kind',
|
||||
icon: Box,
|
||||
description: 'Filter by asset kind (s3object, resource, variable, etc.)'
|
||||
},
|
||||
usage_path: {
|
||||
type: 'string' as const,
|
||||
label: 'Usage path pattern',
|
||||
icon: FileCode,
|
||||
description: 'Filter by usage path pattern (case-insensitive)'
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: paths.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Asset path',
|
||||
icon: FileCode,
|
||||
description: 'Filter by exact asset path'
|
||||
},
|
||||
columns: {
|
||||
type: 'string' as const,
|
||||
label: 'Columns',
|
||||
icon: Braces,
|
||||
description: 'Filter by comma-separated column names (e.g., col1,col2,col3)'
|
||||
}
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
<script module lang="ts">
|
||||
export interface CalendarDate {
|
||||
day: number | null
|
||||
month: number | null // 1-indexed
|
||||
year: number | null
|
||||
hour: number | null
|
||||
minute: number | null
|
||||
}
|
||||
|
||||
export interface CalendarRange {
|
||||
start: CalendarDate
|
||||
end: CalendarDate
|
||||
}
|
||||
|
||||
export function fromCalendarDate(cd: CalendarDate | null | undefined): Date | null {
|
||||
if (calendarDateIsNull(cd)) return null
|
||||
const now = new Date()
|
||||
return new Date(
|
||||
cd?.year ?? now.getFullYear(),
|
||||
(cd?.month ?? now.getMonth() + 1) - 1,
|
||||
cd?.day ?? now.getDate(),
|
||||
cd?.hour ?? 0,
|
||||
cd?.minute ?? 0
|
||||
)
|
||||
}
|
||||
export function toCalendarDate(date: Date | null | undefined): CalendarDate {
|
||||
if (!date) {
|
||||
return { day: null, month: null, year: null, hour: null, minute: null }
|
||||
}
|
||||
return {
|
||||
day: date.getDate(),
|
||||
month: date.getMonth() + 1,
|
||||
year: date.getFullYear(),
|
||||
hour: date.getHours(),
|
||||
minute: date.getMinutes()
|
||||
}
|
||||
}
|
||||
function calendarDateIsNull(cd: CalendarDate | undefined | null): boolean {
|
||||
if (!cd) return true
|
||||
return cd.day == null || cd.month == null || cd.year == null
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { startOfWeek, endOfWeek, eachDayOfInterval, isSameMonth, getDaysInMonth } from 'date-fns'
|
||||
import { ChevronLeft, ChevronRight, ClockIcon } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import Button from './button/Button.svelte'
|
||||
import Select from '../select/Select.svelte'
|
||||
import { isUSLocale } from '$lib/utils'
|
||||
|
||||
interface DateProps {
|
||||
mode?: 'date'
|
||||
value?: CalendarDate
|
||||
}
|
||||
|
||||
interface RangeProps {
|
||||
mode: 'range'
|
||||
value?: CalendarRange
|
||||
onClickBehavior?: 'set-range' | 'set-start' | 'set-end'
|
||||
infiniteRange?: boolean
|
||||
}
|
||||
|
||||
type Props = (DateProps | RangeProps) & {
|
||||
class?: string
|
||||
}
|
||||
|
||||
let { mode = 'date', value = $bindable(), class: className, ...rest }: Props = $props()
|
||||
|
||||
const onClickBehavior = $derived(
|
||||
mode === 'range' ? ((rest as RangeProps).onClickBehavior ?? 'set-range') : 'set-range'
|
||||
)
|
||||
|
||||
const infiniteRange = $derived(mode === 'range' && !!(rest as RangeProps).infiniteRange)
|
||||
|
||||
const emptyDate: CalendarDate = { day: null, month: null, year: null, hour: null, minute: null }
|
||||
|
||||
export function calendarDateIsNull(cd: CalendarDate | null | undefined): boolean {
|
||||
return !cd || (cd.day == null && cd.month == null && cd.year == null)
|
||||
}
|
||||
|
||||
// Pick the date that should be visible on mount
|
||||
function initialViewDate(): { month: number; year: number } {
|
||||
const fallback = { month: today.getMonth() + 1, year: today.getFullYear() }
|
||||
|
||||
let cd: CalendarDate | null | undefined
|
||||
if (mode === 'date') {
|
||||
cd = value as CalendarDate | undefined
|
||||
} else {
|
||||
const v = value as CalendarRange | undefined
|
||||
const behavior = (rest as RangeProps).onClickBehavior ?? 'set-range'
|
||||
if (behavior === 'set-start') {
|
||||
cd = v?.start
|
||||
} else if (behavior === 'set-end') {
|
||||
cd = v?.end
|
||||
} else {
|
||||
cd = !calendarDateIsNull(v?.start) ? v?.start : v?.end
|
||||
}
|
||||
}
|
||||
|
||||
if (!calendarDateIsNull(cd) && cd!.month != null && cd!.year != null) {
|
||||
return { month: cd!.month, year: cd!.year }
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
|
||||
// Internal navigation state (what month/year is displayed in the calendar)
|
||||
const _init = initialViewDate()
|
||||
let viewMonth = $state(_init.month)
|
||||
let viewYear = $state(_init.year)
|
||||
|
||||
// The date whose month/year the calendar should track (mirrors initialViewDate logic)
|
||||
const trackedDate = $derived.by((): CalendarDate | null => {
|
||||
if (mode === 'date') {
|
||||
const v = value as CalendarDate | undefined
|
||||
return v && !calendarDateIsNull(v) ? v : null
|
||||
}
|
||||
const v = value as CalendarRange | undefined
|
||||
if (onClickBehavior === 'set-start')
|
||||
return v?.start && !calendarDateIsNull(v.start) ? v.start : null
|
||||
if (onClickBehavior === 'set-end') return v?.end && !calendarDateIsNull(v.end) ? v.end : null
|
||||
return null
|
||||
})
|
||||
|
||||
// Keep the view in sync when value is changed externally
|
||||
$effect(() => {
|
||||
if (trackedDate?.month != null && trackedDate?.year != null) {
|
||||
viewMonth = trackedDate.month
|
||||
viewYear = trackedDate.year
|
||||
}
|
||||
})
|
||||
|
||||
// Range hover tracking
|
||||
let hoverDate: CalendarDate | null = $state(null)
|
||||
let rangeSelectingStart: boolean = $state(false)
|
||||
|
||||
// Month names for selector
|
||||
const MONTH_NAMES = [
|
||||
'January',
|
||||
'February',
|
||||
'March',
|
||||
'April',
|
||||
'May',
|
||||
'June',
|
||||
'July',
|
||||
'August',
|
||||
'September',
|
||||
'October',
|
||||
'November',
|
||||
'December'
|
||||
]
|
||||
|
||||
const YEAR_LIST_DELTA = 4
|
||||
const YEAR_LIST = Array.from(
|
||||
{ length: YEAR_LIST_DELTA * 2 + 1 },
|
||||
(_, i) => new Date().getFullYear() - YEAR_LIST_DELTA + i
|
||||
)
|
||||
|
||||
const DAY_LABELS = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
|
||||
|
||||
// Build the grid: 6 rows × 7 cols of CalendarDate (with nullish fields for padding)
|
||||
const calendarDays = $derived.by(() => {
|
||||
const firstDay = new Date(viewYear, viewMonth - 1, 1)
|
||||
const lastDay = new Date(viewYear, viewMonth - 1, getDaysInMonth(firstDay))
|
||||
const gridStart = startOfWeek(firstDay, { weekStartsOn: 0 })
|
||||
const gridEnd = endOfWeek(lastDay, { weekStartsOn: 0 })
|
||||
const allDays = eachDayOfInterval({ start: gridStart, end: gridEnd })
|
||||
return allDays.map((d) => ({
|
||||
day: d.getDate(),
|
||||
month: d.getMonth() + 1,
|
||||
year: d.getFullYear(),
|
||||
hour: null as number | null,
|
||||
minute: null as number | null,
|
||||
isCurrentMonth: isSameMonth(d, firstDay)
|
||||
}))
|
||||
})
|
||||
|
||||
// Year options for selector (current year ± 50)
|
||||
function calendarDateToDate(cd: CalendarDate): Date | null {
|
||||
if (cd.day == null || cd.month == null || cd.year == null) return null
|
||||
return new Date(cd.year, cd.month - 1, cd.day)
|
||||
}
|
||||
|
||||
function isSameCalDate(a: CalendarDate | null, b: CalendarDate | null): boolean {
|
||||
if (!a || !b) return false
|
||||
return a.day === b.day && a.month === b.month && a.year === b.year
|
||||
}
|
||||
|
||||
function isDaySelected(cell: CalendarDate): boolean {
|
||||
if (mode === 'date') {
|
||||
const v = value as CalendarDate | undefined
|
||||
if (!v) return false
|
||||
return isSameCalDate(cell, v)
|
||||
} else {
|
||||
const v = value as CalendarRange | undefined
|
||||
if (!v) return false
|
||||
if (onClickBehavior === 'set-start') return isSameCalDate(cell, v.start)
|
||||
if (onClickBehavior === 'set-end') return isSameCalDate(cell, v.end)
|
||||
return isSameCalDate(cell, v.start) || isSameCalDate(cell, v.end)
|
||||
}
|
||||
}
|
||||
|
||||
function isDayInRange(cell: CalendarDate): boolean {
|
||||
if (mode !== 'range') return false
|
||||
const v = value as CalendarRange | undefined
|
||||
const startDate = v?.start ? calendarDateToDate(v.start) : null
|
||||
const endDate = v?.end ? calendarDateToDate(v.end) : null
|
||||
const cellDate = calendarDateToDate(cell)
|
||||
if (!cellDate) return false
|
||||
|
||||
// If actively selecting (first click done, hovering), show hover range
|
||||
if (rangeSelectingStart && startDate && hoverDate) {
|
||||
const hDate = calendarDateToDate(hoverDate)
|
||||
if (hDate) {
|
||||
const lo = startDate < hDate ? startDate : hDate
|
||||
const hi = startDate < hDate ? hDate : startDate
|
||||
return cellDate > lo && cellDate < hi
|
||||
}
|
||||
}
|
||||
|
||||
if (infiniteRange) {
|
||||
if (!startDate && !endDate) return false
|
||||
if (!startDate) return cellDate < endDate!
|
||||
if (!endDate) return cellDate > startDate
|
||||
}
|
||||
if (!startDate || !endDate) return false
|
||||
return cellDate > startDate && cellDate < endDate
|
||||
}
|
||||
|
||||
function isDayRangeStart(cell: CalendarDate): boolean {
|
||||
if (mode !== 'range') return false
|
||||
const v = value as CalendarRange | undefined
|
||||
return isSameCalDate(cell, v?.start ?? null)
|
||||
}
|
||||
|
||||
function isDayDisabled(cell: CalendarDate): boolean {
|
||||
if (mode !== 'range') return false
|
||||
const v = value as CalendarRange | undefined
|
||||
const cellDate = calendarDateToDate(cell)
|
||||
if (!cellDate) return false
|
||||
|
||||
if (onClickBehavior === 'set-start') {
|
||||
const endDate = v?.end ? calendarDateToDate(v.end) : null
|
||||
return endDate != null && cellDate > endDate
|
||||
}
|
||||
if (onClickBehavior === 'set-end') {
|
||||
const startDate = v?.start ? calendarDateToDate(v.start) : null
|
||||
return startDate != null && cellDate < startDate
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isDayRangeEnd(cell: CalendarDate): boolean {
|
||||
if (mode !== 'range') return false
|
||||
const v = value as CalendarRange | undefined
|
||||
if (rangeSelectingStart && hoverDate) {
|
||||
const startDate = v?.start ? calendarDateToDate(v.start) : null
|
||||
const hDate = calendarDateToDate(hoverDate)
|
||||
const cellDate = calendarDateToDate(cell)
|
||||
if (startDate && hDate && cellDate) {
|
||||
const end = startDate < hDate ? hoverDate : (v?.start ?? null)
|
||||
return isSameCalDate(cell, end)
|
||||
}
|
||||
}
|
||||
return isSameCalDate(cell, v?.end ?? null)
|
||||
}
|
||||
|
||||
function handleDayClick(cell: {
|
||||
day: number
|
||||
month: number
|
||||
year: number
|
||||
isCurrentMonth: boolean
|
||||
}) {
|
||||
const cd: CalendarDate = {
|
||||
day: cell.day,
|
||||
month: cell.month,
|
||||
year: cell.year,
|
||||
hour: null,
|
||||
minute: null
|
||||
}
|
||||
|
||||
if (mode === 'date') {
|
||||
const v = value as CalendarDate | undefined
|
||||
value = isSameCalDate(cd, v ?? null)
|
||||
? emptyDate
|
||||
: { ...cd, hour: v?.hour ?? null, minute: v?.minute ?? null }
|
||||
} else {
|
||||
const v = value as CalendarRange | undefined
|
||||
|
||||
if (onClickBehavior === 'set-start') {
|
||||
const newStart = isSameCalDate(cd, v?.start ?? null)
|
||||
? emptyDate
|
||||
: { ...cd, hour: v?.start?.hour ?? null, minute: v?.start?.minute ?? null }
|
||||
value = { start: newStart, end: v?.end ?? emptyDate }
|
||||
} else if (onClickBehavior === 'set-end') {
|
||||
const newEnd = isSameCalDate(cd, v?.end ?? null)
|
||||
? emptyDate
|
||||
: { ...cd, hour: v?.end?.hour ?? null, minute: v?.end?.minute ?? null }
|
||||
value = { start: v?.start ?? emptyDate, end: newEnd }
|
||||
} else {
|
||||
// 'set-range': two-click flow
|
||||
if (!rangeSelectingStart) {
|
||||
// First click: toggle start (deselect if same day), clear end
|
||||
if (isSameCalDate(cd, v?.start ?? null)) {
|
||||
value = { start: emptyDate, end: emptyDate }
|
||||
} else {
|
||||
value = { start: cd, end: emptyDate }
|
||||
rangeSelectingStart = true
|
||||
}
|
||||
} else {
|
||||
// Second click: set end, auto-swap if needed
|
||||
const start = v?.start ?? emptyDate
|
||||
const startDate = calendarDateToDate(start)
|
||||
const endDate = calendarDateToDate(cd)
|
||||
if (startDate && endDate && endDate < startDate) {
|
||||
value = { start: cd, end: start }
|
||||
} else {
|
||||
value = { start, end: cd }
|
||||
}
|
||||
rangeSelectingStart = false
|
||||
hoverDate = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to that month if clicking an out-of-month day
|
||||
if (!cell.isCurrentMonth) {
|
||||
viewMonth = cell.month
|
||||
viewYear = cell.year
|
||||
}
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 1) {
|
||||
viewMonth = 12
|
||||
viewYear -= 1
|
||||
} else {
|
||||
viewMonth -= 1
|
||||
}
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (viewMonth === 12) {
|
||||
viewMonth = 1
|
||||
viewYear += 1
|
||||
} else {
|
||||
viewMonth += 1
|
||||
}
|
||||
}
|
||||
|
||||
// The CalendarDate whose hour/minute the time inputs control
|
||||
const timeTarget = $derived.by((): CalendarDate | null => {
|
||||
if (mode === 'date') return (value as CalendarDate | undefined) ?? null
|
||||
const v = value as CalendarRange | undefined
|
||||
if (onClickBehavior === 'set-start') return v?.start ?? null
|
||||
if (onClickBehavior === 'set-end') return v?.end ?? null
|
||||
return null // set-range: no single time target
|
||||
})
|
||||
|
||||
const showTime = $derived(timeTarget !== null)
|
||||
|
||||
// Returns the base CalendarDate to mutate, falling back to today if no date selected yet
|
||||
function withTodayFallback(cd: CalendarDate): CalendarDate {
|
||||
if (cd.day != null && cd.month != null && cd.year != null) return cd
|
||||
const t = new Date()
|
||||
return {
|
||||
...cd,
|
||||
day: cd.day ?? t.getDate(),
|
||||
month: cd.month ?? t.getMonth() + 1,
|
||||
year: cd.year ?? t.getFullYear()
|
||||
}
|
||||
}
|
||||
|
||||
const usLocale = $derived(isUSLocale())
|
||||
|
||||
function selectAllOnFocus(e: FocusEvent) {
|
||||
;(e.target as HTMLInputElement).select()
|
||||
}
|
||||
|
||||
function patchTarget(patch: Partial<CalendarDate>) {
|
||||
if (mode === 'date') {
|
||||
value = { ...withTodayFallback(value as CalendarDate), ...patch }
|
||||
} else {
|
||||
const v = value as CalendarRange
|
||||
if (onClickBehavior === 'set-start')
|
||||
value = { start: { ...withTodayFallback(v.start), ...patch }, end: v.end }
|
||||
else if (onClickBehavior === 'set-end')
|
||||
value = { start: v.start, end: { ...withTodayFallback(v.end), ...patch } }
|
||||
}
|
||||
}
|
||||
|
||||
function setDay(raw: string) {
|
||||
const d = parseInt(raw, 10)
|
||||
if (isNaN(d)) return
|
||||
patchTarget({ day: Math.max(1, Math.min(31, d)) })
|
||||
}
|
||||
|
||||
function setMonth(raw: string) {
|
||||
const mo = parseInt(raw, 10)
|
||||
if (isNaN(mo)) return
|
||||
patchTarget({ month: Math.max(1, Math.min(12, mo)) })
|
||||
}
|
||||
|
||||
function setYear(raw: string) {
|
||||
const y = parseInt(raw, 10)
|
||||
if (isNaN(y)) return
|
||||
patchTarget({ year: y })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col {className}">
|
||||
<!-- Header -->
|
||||
<div class="mb-3 flex items-center gap-1.5">
|
||||
<Button
|
||||
endIcon={{ icon: ChevronLeft }}
|
||||
iconOnly
|
||||
unifiedSize="md"
|
||||
onClick={prevMonth}
|
||||
wrapperClasses="bg-surface-input"
|
||||
/>
|
||||
|
||||
<div class="flex flex-1 divide-x">
|
||||
<Select
|
||||
class="basis-1/2"
|
||||
inputClass="text-center !rounded-r-none !border-r-0"
|
||||
disablePortal
|
||||
bind:value={viewMonth}
|
||||
items={MONTH_NAMES.map((name, i) => ({ label: name, value: i + 1 }))}
|
||||
/>
|
||||
<Select
|
||||
class="basis-1/2"
|
||||
inputClass="text-center !rounded-l-none !border-l-0"
|
||||
disablePortal
|
||||
bind:value={viewYear}
|
||||
onCreateItem={(val) => (viewYear = parseInt(val) || viewYear)}
|
||||
items={YEAR_LIST.map((year) => ({ label: year.toString(), value: year }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
endIcon={{ icon: ChevronRight }}
|
||||
iconOnly
|
||||
unifiedSize="md"
|
||||
onClick={nextMonth}
|
||||
wrapperClasses="bg-surface-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Day-of-week labels -->
|
||||
<div class="mb-1 grid grid-cols-7">
|
||||
{#each DAY_LABELS as label (label)}
|
||||
<div
|
||||
class="flex h-7 items-center justify-center text-3xs font-medium uppercase tracking-wide text-secondary"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Day grid -->
|
||||
<div class="grid grid-cols-7">
|
||||
{#each calendarDays as cell (`${cell.year}-${cell.month}-${cell.day}`)}
|
||||
{@const selected = isDaySelected(cell)}
|
||||
{@const inRange = isDayInRange(cell)}
|
||||
{@const isStart = isDayRangeStart(cell)}
|
||||
{@const isEnd = isDayRangeEnd(cell)}
|
||||
{@const disabled = isDayDisabled(cell)}
|
||||
{@const isToday =
|
||||
cell.day === today.getDate() &&
|
||||
cell.month === today.getMonth() + 1 &&
|
||||
cell.year === today.getFullYear()}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => !disabled && handleDayClick(cell)}
|
||||
onmouseenter={() => {
|
||||
if (rangeSelectingStart) hoverDate = cell
|
||||
}}
|
||||
onmouseleave={() => {
|
||||
if (rangeSelectingStart) hoverDate = null
|
||||
}}
|
||||
class={twMerge(
|
||||
'relative flex my-0.5 h-9 min-w-9 w-full items-center justify-center text-sm transition-colors focus:outline-none',
|
||||
disabled ? 'opacity-15' : !cell.isCurrentMonth ? 'text-hint' : 'text-primary',
|
||||
!disabled && selected ? 'font-semibold' : 'font-normal',
|
||||
!disabled && inRange ? 'bg-surface-secondary' : '',
|
||||
!disabled && (isStart || isEnd) ? 'bg-surface-secondary' : '',
|
||||
!disabled && isStart && mode === 'range' ? 'rounded-l' : '',
|
||||
!disabled && isEnd && mode === 'range' ? 'rounded-r' : ''
|
||||
)}
|
||||
aria-label="{cell.year}-{String(cell.month).padStart(2, '0')}-{String(cell.day).padStart(
|
||||
2,
|
||||
'0'
|
||||
)}"
|
||||
aria-pressed={selected}
|
||||
aria-disabled={disabled}
|
||||
>
|
||||
<!-- Selection circle / highlight -->
|
||||
{#if selected}
|
||||
<span class="absolute inset-0 z-0 rounded-md bg-surface-accent-primary"></span>
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'relative z-10',
|
||||
selected ? 'text-white dark:text-white' : isToday && !disabled ? 'text-accent' : ''
|
||||
)}
|
||||
>
|
||||
{cell.day}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- Time inputs -->
|
||||
{#if showTime}
|
||||
<div class="border-t my-4"></div>
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
class="px-2 !h-8 flex border bg-surface-secondary dark:bg-surface rounded-l-md w-fit items-center gap-0"
|
||||
>
|
||||
{#if usLocale}
|
||||
<!-- MM / DD / YYYY -->
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
value={timeTarget?.month != null ? String(timeTarget.month).padStart(2, '0') : ''}
|
||||
placeholder="MM"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => setMonth((e.target as HTMLInputElement).value)}
|
||||
style="background: transparent !important;"
|
||||
class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono"
|
||||
aria-label="Month"
|
||||
/>
|
||||
<span class="text-sm font-medium font-mono text-secondary">/</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
value={timeTarget?.day != null ? String(timeTarget.day).padStart(2, '0') : ''}
|
||||
placeholder="DD"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => setDay((e.target as HTMLInputElement).value)}
|
||||
style="background: transparent !important;"
|
||||
class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono"
|
||||
aria-label="Day"
|
||||
/>
|
||||
{:else}
|
||||
<!-- DD / MM / YYYY -->
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
value={timeTarget?.day != null ? String(timeTarget.day).padStart(2, '0') : ''}
|
||||
placeholder="DD"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => setDay((e.target as HTMLInputElement).value)}
|
||||
style="background: transparent !important;"
|
||||
class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono"
|
||||
aria-label="Day"
|
||||
/>
|
||||
<span class="text-sm font-medium font-mono text-secondary">/</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
value={timeTarget?.month != null ? String(timeTarget.month).padStart(2, '0') : ''}
|
||||
placeholder="MM"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => setMonth((e.target as HTMLInputElement).value)}
|
||||
style="background: transparent !important;"
|
||||
class="!border-none !w-8 !h-7 !px-1.5 text-center font-mono"
|
||||
aria-label="Month"
|
||||
/>
|
||||
{/if}
|
||||
<span class="text-sm font-medium font-mono text-secondary">/</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="4"
|
||||
value={timeTarget?.year != null ? String(timeTarget.year) : ''}
|
||||
placeholder="YYYY"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => setYear((e.target as HTMLInputElement).value)}
|
||||
style="background: transparent !important;"
|
||||
class="!border-none !w-12 !h-7 !px-1.5 text-center font-mono"
|
||||
aria-label="Year"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pl-2 !h-8 flex border border-l-0 rounded-r-md w-fit items-center gap-0 bg-surface-input"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
value={timeTarget?.hour != null ? String(timeTarget.hour).padStart(2, '0') : ''}
|
||||
placeholder="HH"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => {
|
||||
const h = Math.max(0, Math.min(23, parseInt((e.target as HTMLInputElement).value, 10)))
|
||||
if (!isNaN(h)) patchTarget({ hour: h })
|
||||
}}
|
||||
class="!border-none !w-8 !h-7 !px-1.5 text-right font-mono"
|
||||
aria-label="Hour"
|
||||
/>
|
||||
<span class="text-sm font-medium font-mono text-secondary">:</span>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength="2"
|
||||
value={timeTarget?.minute != null ? String(timeTarget.minute).padStart(2, '0') : ''}
|
||||
placeholder="MM"
|
||||
onfocus={selectAllOnFocus}
|
||||
onchange={(e) => {
|
||||
const m = Math.max(0, Math.min(59, parseInt((e.target as HTMLInputElement).value, 10)))
|
||||
if (!isNaN(m)) patchTarget({ minute: m })
|
||||
}}
|
||||
class="!border-none !w-8 !h-7 !px-1.5 text-left font-mono"
|
||||
aria-label="Minute"
|
||||
/>
|
||||
<ClockIcon size={14} class="mr-3" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -34,9 +34,11 @@ export async function copyFirstStepSchema(
|
||||
})
|
||||
return
|
||||
}
|
||||
return sendUserToast('Only scripts can be used as a input schema', true)
|
||||
sendUserToast('Only scripts can be used as a input schema', true)
|
||||
return
|
||||
}
|
||||
return sendUserToast('No first step found', true)
|
||||
sendUserToast('No first step found', true)
|
||||
return
|
||||
}
|
||||
|
||||
export async function getFirstStepSchema(flowState: FlowState, flow: OpenFlow) {
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
[])
|
||||
: undefined
|
||||
} catch (err) {
|
||||
console.error('Error fetching top hub scripts')
|
||||
sendUserToast('Failed to fetch hub scripts: ' + err, 'error')
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
@@ -38,7 +38,7 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, untrack } from 'svelte'
|
||||
import { Skeleton } from '$lib/components/common'
|
||||
import { classNames, createCache } from '$lib/utils'
|
||||
import { classNames, createCache, sendUserToast } from '$lib/utils'
|
||||
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
|
||||
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
|
||||
import { Circle, ExternalLink } from 'lucide-svelte'
|
||||
@@ -100,7 +100,7 @@
|
||||
(x) => x.name
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Hub is not available')
|
||||
sendUserToast('Failed to fetch hub integrations: ' + err, 'error')
|
||||
allApps = []
|
||||
hubNotAvailable = true
|
||||
}
|
||||
@@ -144,7 +144,7 @@
|
||||
try {
|
||||
await ScriptService.pickHubScriptByPath({ path: item.path })
|
||||
} catch (error) {
|
||||
console.error('Failed to track hub script pick:', error)
|
||||
sendUserToast('Failed to call ScriptService.pickHubScriptByPath: ' + error, 'error')
|
||||
// Don't block the flow if tracking fails
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Boxes, FileCode, FileText, FolderIcon, Braces, Users } from 'lucide-svelte'
|
||||
import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildResourcesFilterSchema({
|
||||
paths,
|
||||
resourceTypes,
|
||||
owners,
|
||||
showUserFoldersFilter,
|
||||
userFoldersLabel
|
||||
}: {
|
||||
paths: string[]
|
||||
resourceTypes: string[]
|
||||
owners: string[]
|
||||
showUserFoldersFilter?: boolean
|
||||
userFoldersLabel?: string
|
||||
}) {
|
||||
return {
|
||||
resource_type: {
|
||||
type: 'oneof' as const,
|
||||
options: resourceTypes.map((s) => ({ label: s, value: s })),
|
||||
allowNegative: false,
|
||||
allowMultiple: true,
|
||||
label: 'Resource type',
|
||||
icon: Boxes,
|
||||
description: 'Filter by resource type'
|
||||
},
|
||||
owner: {
|
||||
type: 'oneof' as const,
|
||||
options: owners.map((s) => ({ label: s, value: s })),
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Owner',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by owner (folder or user path)'
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: paths.map((s) => ({ label: s, value: s })),
|
||||
allowNegative: true,
|
||||
allowMultiple: true,
|
||||
label: 'Path',
|
||||
icon: FileCode,
|
||||
description: 'Filter by exact resource path'
|
||||
},
|
||||
path_start: {
|
||||
type: 'string' as const,
|
||||
label: 'Path prefix',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by path prefix (e.g., "f/folder/")'
|
||||
},
|
||||
description: {
|
||||
type: 'string' as const,
|
||||
label: 'Description',
|
||||
icon: FileText,
|
||||
description: 'Search in resource description'
|
||||
},
|
||||
value: {
|
||||
type: 'string' as const,
|
||||
format: 'json' as const,
|
||||
label: 'Value subset',
|
||||
icon: Braces,
|
||||
description: 'Filter by JSON subset match (e.g., {"bucket": "my-bucket"})'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only resources in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
import type { Schema } from '$lib/common'
|
||||
import InputTransformForm from '../InputTransformForm.svelte'
|
||||
import type { FlowPropPickerConfig, PropPickerContext } from '../prop_picker'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import { setContext } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import type { PickableProperties } from '../flows/previousResults'
|
||||
import Alert from '../common/alert/Alert.svelte'
|
||||
@@ -27,27 +27,34 @@
|
||||
mergeSchemasForBatchReruns
|
||||
} from '$lib/components/jobs/batchReruns'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import { TriangleAlert } from 'lucide-svelte'
|
||||
import { RefreshCwIcon, TriangleAlert } from 'lucide-svelte'
|
||||
import { readFieldsRecursively } from '$lib/utils'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
|
||||
import { resource, watch } from 'runed'
|
||||
|
||||
let {
|
||||
selectedIds,
|
||||
options = $bindable()
|
||||
onCancel,
|
||||
onConfirm
|
||||
}: {
|
||||
selectedIds: string[]
|
||||
options: BatchReRunOptions
|
||||
onCancel: () => void
|
||||
onConfirm: (options: BatchReRunOptions) => void
|
||||
} = $props()
|
||||
|
||||
let selected: JobGroup | undefined = $state()
|
||||
$effect(() => {
|
||||
jobGroupsPromise.then((jobGroups) => {
|
||||
let options: BatchReRunOptions = $state({ flow: {}, script: {} })
|
||||
watch(
|
||||
() => jobGroups.current,
|
||||
() => {
|
||||
selected = selected
|
||||
? jobGroups.find((g) => g.script_path === selected?.script_path && g.kind === selected.kind)
|
||||
: jobGroups[0]
|
||||
})
|
||||
})
|
||||
? jobGroups.current?.find(
|
||||
(g) => g.script_path === selected?.script_path && g.kind === selected.kind
|
||||
)
|
||||
: jobGroups.current?.[0]
|
||||
}
|
||||
)
|
||||
|
||||
setContext<PropPickerContext>('PropPickerContext', {
|
||||
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
|
||||
@@ -108,7 +115,7 @@
|
||||
group.schemas.find((s) => s.script_hash === jobSchema.script_hash) ??
|
||||
group.schemas[
|
||||
group.schemas.push({
|
||||
schema: jobSchema.schema as Schema,
|
||||
schema: (jobSchema.schema as Schema) ?? {},
|
||||
job_ids: [],
|
||||
script_hash: jobSchema.script_hash
|
||||
}) - 1
|
||||
@@ -130,7 +137,7 @@
|
||||
}
|
||||
function propertyAlwaysExists(propertyName: string, group: JobGroup): boolean {
|
||||
for (const s of group.schemas) {
|
||||
if (!(propertyName in (s.schema as Schema).properties)) return false
|
||||
if (!(propertyName in ((s.schema as Schema)?.properties ?? {}))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -138,7 +145,7 @@
|
||||
function propertyAlwaysHasSameType(propertyName: string, group: JobGroup): boolean {
|
||||
let prevType = 'INIT'
|
||||
for (const s of group.schemas) {
|
||||
const currType = (s.schema as Schema).properties[propertyName]?.type
|
||||
const currType = (s.schema as Schema)?.properties?.[propertyName]?.type
|
||||
if (currType === undefined) continue
|
||||
if (prevType !== 'INIT' && currType !== prevType) return false
|
||||
prevType = currType
|
||||
@@ -152,25 +159,23 @@
|
||||
(options[selected.kind][selected.script_path]?.use_latest_version ?? false))
|
||||
)
|
||||
|
||||
const jobGroupsPromise = $derived.by(() => {
|
||||
readFieldsRecursively(selectedIds)
|
||||
return untrack(() => fetchJobGroups())
|
||||
})
|
||||
const jobGroups = resource(() => readFieldsRecursively(selectedIds), fetchJobGroups)
|
||||
|
||||
let hideRunnableSelector = $derived(!(jobGroups.current?.length !== 1 && selectedIds.length > 1))
|
||||
</script>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<p class="ml-4 mt-4 text-xs font-semibold truncate">Batch re-run options</p>
|
||||
<div class="border overflow-auto rounded-md m-4 flex-1">
|
||||
<div class="flex-1 flex flex-col h-full">
|
||||
<div class="border overflow-auto rounded-md mb-4 flex-1">
|
||||
<Splitpanes>
|
||||
<Pane size={32} class="bg-surface-secondary relative">
|
||||
<PanelSection
|
||||
title="Runnables"
|
||||
class="bg-surface-secondary overflow-y-scroll absolute inset-0"
|
||||
id="batch-rerun-options-runnable-list"
|
||||
>
|
||||
<div class="w-full flex flex-col gap-1">
|
||||
{#await jobGroupsPromise then jobGroup}
|
||||
{#each jobGroup as group}
|
||||
{#if !hideRunnableSelector}
|
||||
<Pane size={32} class="bg-surface-secondary relative">
|
||||
<PanelSection
|
||||
title="Runnables"
|
||||
class="bg-surface-secondary overflow-y-scroll absolute inset-0"
|
||||
id="batch-rerun-options-runnable-list"
|
||||
>
|
||||
<div class="w-full flex flex-col gap-1">
|
||||
{#each jobGroups.current ?? [] as group}
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
@@ -183,101 +188,113 @@
|
||||
<span class="text-hint">({jobGroupTotalCount(group)})</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{/await}
|
||||
</div>
|
||||
</PanelSection>
|
||||
</Pane>
|
||||
<Pane size={68} class="relative">
|
||||
<PanelSection
|
||||
title="Inputs"
|
||||
class="overflow-y-scroll absolute inset-0"
|
||||
id="batch-rerun-options-args"
|
||||
>
|
||||
{#if selected}
|
||||
<div class="text-sm w-full pb-2">
|
||||
<Alert type="info" title="Available expressions :">
|
||||
Use the <code>job</code> object to access data about the original job
|
||||
</Alert>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={selectedUsesLatestSchema}
|
||||
disabled={selected?.kind === 'flow'}
|
||||
on:change={(e) => {
|
||||
if (!selected) return
|
||||
;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
|
||||
e.detail as boolean
|
||||
}}
|
||||
size="sm"
|
||||
options={{
|
||||
right: 'Always use latest version',
|
||||
rightTooltip:
|
||||
selected.kind === 'flow'
|
||||
? 'Flow jobs will always run on the latest version of the flow'
|
||||
: 'Run all jobs with the latest version of the script even if they originally ran an older version'
|
||||
}}
|
||||
/>
|
||||
</PanelSection>
|
||||
</Pane>
|
||||
{/if}
|
||||
<Pane size={hideRunnableSelector ? 100 : 68} class="relative">
|
||||
<div class="flex flex-col absolute inset-0 bg-surface-tertiary">
|
||||
<PanelSection
|
||||
title="Inputs"
|
||||
class="overflow-y-scroll flex-1"
|
||||
id="batch-rerun-options-args"
|
||||
>
|
||||
{#if selected}
|
||||
<div class="text-sm w-full pb-2">
|
||||
<Alert type="info" title="Available expressions :">
|
||||
Use the <code>job</code> object to access data about the original job
|
||||
</Alert>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={selectedUsesLatestSchema}
|
||||
disabled={selected?.kind === 'flow'}
|
||||
on:change={(e) => {
|
||||
if (!selected) return
|
||||
;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
|
||||
e.detail as boolean
|
||||
}}
|
||||
size="sm"
|
||||
options={{
|
||||
right: 'Always use latest version',
|
||||
rightTooltip:
|
||||
selected.kind === 'flow'
|
||||
? 'Flow jobs will always run on the latest version of the flow'
|
||||
: 'Run all jobs with the latest version of the script even if they originally ran an older version'
|
||||
}}
|
||||
/>
|
||||
|
||||
<!-- Even if we use the latest schema, we want the editor -->
|
||||
<!-- to only lint the original jobs' values -->
|
||||
{@const displayedSchema = selectedUsesLatestSchema
|
||||
? (selected.latest_schema as Schema)
|
||||
: mergeSchemasForBatchReruns(selected.schemas.map((s) => s.schema as Schema))}
|
||||
{@const extraLib = buildExtraLibForBatchReruns({
|
||||
schemas: selected.schemas,
|
||||
script_path: selected.script_path
|
||||
})}
|
||||
<div class="w-full h-full">
|
||||
{#key [selected, displayedSchema]}
|
||||
{#each Object.keys(displayedSchema.properties) as propertyName}
|
||||
<ResizeTransitionWrapper vertical innerClass="w-full">
|
||||
<InputTransformForm
|
||||
class="items-start mb-6"
|
||||
arg={options[selected.kind][selected.script_path]?.input_transforms?.[
|
||||
propertyName
|
||||
] ?? {
|
||||
type: 'javascript',
|
||||
expr: batchReRunDefaultPropertyExpr(propertyName, selected.schemas)
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (!selected) return
|
||||
const newArg = e.detail.arg as InputTransform
|
||||
;((options[selected.kind][selected.script_path] ??= {}).input_transforms ??=
|
||||
{})[propertyName] = newArg
|
||||
}}
|
||||
argName={propertyName}
|
||||
schema={displayedSchema}
|
||||
{extraLib}
|
||||
previousModuleId={undefined}
|
||||
pickableProperties={{
|
||||
hasResume: false,
|
||||
previousId: undefined,
|
||||
priorIds: {},
|
||||
flow_input: {}
|
||||
}}
|
||||
hideHelpButton
|
||||
{...propertyAlwaysExists(propertyName, selected)
|
||||
? {}
|
||||
: {
|
||||
headerTooltip:
|
||||
'This property does not exist on all versions of the script. You can handle different cases in the code below',
|
||||
HeaderTooltipIcon: TriangleAlert,
|
||||
headerTooltipIconClass: 'text-orange-500'
|
||||
}}
|
||||
{...propertyAlwaysHasSameType(propertyName, selected)
|
||||
? {}
|
||||
: {
|
||||
headerTooltip:
|
||||
'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
|
||||
HeaderTooltipIcon: TriangleAlert,
|
||||
headerTooltipIconClass: 'text-orange-500'
|
||||
}}
|
||||
/>
|
||||
</ResizeTransitionWrapper>
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</PanelSection>
|
||||
<!-- Even if we use the latest schema, we want the editor -->
|
||||
<!-- to only lint the original jobs' values -->
|
||||
{@const displayedSchema = selectedUsesLatestSchema
|
||||
? (selected.latest_schema as Schema | undefined)
|
||||
: mergeSchemasForBatchReruns(
|
||||
selected.schemas.map((s) => (s.schema as Schema) ?? {})
|
||||
)}
|
||||
{@const extraLib = buildExtraLibForBatchReruns({
|
||||
schemas: selected.schemas,
|
||||
script_path: selected.script_path
|
||||
})}
|
||||
<div class="w-full h-full">
|
||||
{#key [selected, displayedSchema]}
|
||||
{#each Object.keys(displayedSchema?.properties ?? {}) as propertyName}
|
||||
<ResizeTransitionWrapper vertical innerClass="w-full">
|
||||
<InputTransformForm
|
||||
class="items-start mb-6"
|
||||
arg={options[selected.kind][selected.script_path]?.input_transforms?.[
|
||||
propertyName
|
||||
] ?? {
|
||||
type: 'javascript',
|
||||
expr: batchReRunDefaultPropertyExpr(propertyName, selected.schemas)
|
||||
}}
|
||||
on:change={(e) => {
|
||||
if (!selected) return
|
||||
const newArg = e.detail.arg as InputTransform
|
||||
;((options[selected.kind][selected.script_path] ??=
|
||||
{}).input_transforms ??= {})[propertyName] = newArg
|
||||
}}
|
||||
argName={propertyName}
|
||||
schema={displayedSchema ?? {}}
|
||||
{extraLib}
|
||||
previousModuleId={undefined}
|
||||
pickableProperties={{
|
||||
hasResume: false,
|
||||
previousId: undefined,
|
||||
priorIds: {},
|
||||
flow_input: {}
|
||||
}}
|
||||
hideHelpButton
|
||||
{...propertyAlwaysExists(propertyName, selected)
|
||||
? {}
|
||||
: {
|
||||
headerTooltip:
|
||||
'This property does not exist on all versions of the script. You can handle different cases in the code below',
|
||||
HeaderTooltipIcon: TriangleAlert,
|
||||
headerTooltipIconClass: 'text-orange-500'
|
||||
}}
|
||||
{...propertyAlwaysHasSameType(propertyName, selected)
|
||||
? {}
|
||||
: {
|
||||
headerTooltip:
|
||||
'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
|
||||
HeaderTooltipIcon: TriangleAlert,
|
||||
headerTooltipIconClass: 'text-orange-500'
|
||||
}}
|
||||
/>
|
||||
</ResizeTransitionWrapper>
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
{/if}
|
||||
</PanelSection>
|
||||
<div class="flex justify-end gap-2 w-full pt-2 pb-2 pr-4">
|
||||
<Button variant="subtle" onClick={onCancel}>Cancel</Button>
|
||||
<Button
|
||||
variant="accent"
|
||||
onClick={() => onConfirm(options)}
|
||||
endIcon={{ icon: RefreshCwIcon }}>Run again</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
</div>
|
||||
|
||||
@@ -119,8 +119,8 @@
|
||||
bind:this={jobLoader}
|
||||
/>
|
||||
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div class="flex flex-col items-start p-4 pb-8 min-h-full">
|
||||
<div class="h-full">
|
||||
<div class="flex flex-col items-start pb-4 min-h-full">
|
||||
{#if isLoadingJobDetails}
|
||||
<div class="w-full flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
minTs: string | null
|
||||
maxTs: string | null
|
||||
loading?: boolean
|
||||
selectedManualDate?: number
|
||||
loadText?: string | undefined
|
||||
serviceLogsChoices?: boolean
|
||||
numberOfLastJobsToFetch?: number
|
||||
}
|
||||
|
||||
let {
|
||||
minTs = $bindable(),
|
||||
maxTs = $bindable(),
|
||||
loading = false,
|
||||
selectedManualDate = $bindable(0),
|
||||
loadText = undefined,
|
||||
serviceLogsChoices = false,
|
||||
numberOfLastJobsToFetch = 1000
|
||||
}: Props = $props()
|
||||
|
||||
export function computeMinMax(): { minTs: string; maxTs: string | null } | undefined {
|
||||
return manualDates[selectedManualDate].computeMinMax()
|
||||
}
|
||||
|
||||
export function resetChoice() {
|
||||
selectedManualDate = 0
|
||||
}
|
||||
|
||||
function computeMinMaxInc(inc: number) {
|
||||
let minTs = new Date(new Date().getTime() - inc).toISOString()
|
||||
let maxTs = null
|
||||
return { minTs, maxTs }
|
||||
}
|
||||
|
||||
const fixedManualDates: {
|
||||
label: string
|
||||
computeMinMax: () => { minTs: string; maxTs: string | null } | undefined
|
||||
}[] = [
|
||||
...(!serviceLogsChoices
|
||||
? [
|
||||
{
|
||||
label: 'Within 30 seconds',
|
||||
computeMinMax: () => computeMinMaxInc(30 * 1000)
|
||||
},
|
||||
{
|
||||
label: 'Within last minute',
|
||||
computeMinMax: () => computeMinMaxInc(60 * 1000)
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: 'Within last 5 minutes',
|
||||
computeMinMax: () => computeMinMaxInc(5 * 60 * 1000)
|
||||
},
|
||||
{
|
||||
label: 'Within last 30 minutes',
|
||||
computeMinMax: () => computeMinMaxInc(30 * 60 * 1000)
|
||||
},
|
||||
{
|
||||
label: 'Within last 24 hours',
|
||||
computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000)
|
||||
},
|
||||
{
|
||||
label: 'Within last 7 days',
|
||||
computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000)
|
||||
},
|
||||
{
|
||||
label: 'Within last month',
|
||||
computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
]
|
||||
|
||||
let manualDates = $derived([
|
||||
{
|
||||
label: loadText ?? `Last ${numberOfLastJobsToFetch} runs`,
|
||||
computeMinMax: () => {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
...fixedManualDates
|
||||
])
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
const ts = computeMinMax()
|
||||
if (ts) {
|
||||
minTs = ts.minTs
|
||||
maxTs = ts.maxTs
|
||||
}
|
||||
dispatch('loadJobs', { minTs, maxTs })
|
||||
}}
|
||||
dropdownItems={[
|
||||
...manualDates.map((d, i) => ({
|
||||
label: d.label,
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
selectedManualDate = i
|
||||
const ts = d.computeMinMax()
|
||||
if (ts) {
|
||||
minTs = ts.minTs
|
||||
maxTs = ts.maxTs
|
||||
} else {
|
||||
minTs = null
|
||||
maxTs = null
|
||||
}
|
||||
dispatch('loadJobs')
|
||||
}
|
||||
}))
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
|
||||
{manualDates[selectedManualDate].label}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -7,10 +7,8 @@
|
||||
truncateHash,
|
||||
truncateRev,
|
||||
isScriptPreview,
|
||||
isJobSelectable,
|
||||
msToReadableTime,
|
||||
isFlowPreview,
|
||||
type RunsSelectionMode,
|
||||
getJobKindIcon
|
||||
} from '$lib/utils'
|
||||
import { Button } from '../common'
|
||||
@@ -39,7 +37,7 @@
|
||||
containsLabel?: boolean
|
||||
showTag?: boolean
|
||||
activeLabel: string | null
|
||||
selectionMode?: RunsSelectionMode | false
|
||||
manualSelectionMode?: undefined | 'cancel' | 'rerun'
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -49,7 +47,7 @@
|
||||
containsLabel = false,
|
||||
showTag = true,
|
||||
activeLabel,
|
||||
selectionMode = false
|
||||
manualSelectionMode
|
||||
}: Props = $props()
|
||||
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
|
||||
@@ -68,36 +66,33 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'hover:bg-surface-hover cursor-pointer',
|
||||
selected ? 'bg-surface-accent-selected' : '',
|
||||
'cursor-pointer',
|
||||
selected ? 'bg-surface-accent-selected' : 'hover:bg-surface-hover',
|
||||
'grid items-center h-full'
|
||||
)}
|
||||
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
|
||||
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
|
||||
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
|
||||
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
|
||||
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
|
||||
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-selection-no-tag={containsLabel && selectionMode && !showTag}
|
||||
class:grid-runs-table={!containsLabel && !manualSelectionMode && showTag}
|
||||
class:grid-runs-table-with-labels={containsLabel && !manualSelectionMode && showTag}
|
||||
class:grid-runs-table-selection={!containsLabel && manualSelectionMode && showTag}
|
||||
class:grid-runs-table-with-labels-selection={containsLabel && manualSelectionMode && showTag}
|
||||
class:grid-runs-table-no-tag={!containsLabel && !manualSelectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-no-tag={containsLabel && !manualSelectionMode && !showTag}
|
||||
class:grid-runs-table-selection-no-tag={!containsLabel && manualSelectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
|
||||
manualSelectionMode &&
|
||||
!showTag}
|
||||
style="width: {containerWidth}px"
|
||||
onclick={() => {
|
||||
if (!selectionMode || isJobSelectable(selectionMode)(job)) {
|
||||
dispatch('select')
|
||||
}
|
||||
}}
|
||||
onclick={() => dispatch('select')}
|
||||
oncontextmenu={(e) => !selected && dispatch('select')}
|
||||
>
|
||||
<!-- Selection column (only when in selection mode) -->
|
||||
{#if selectionMode}
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-4 h-4">
|
||||
<input type="checkbox" checked={selected} disabled={!isJobSelectable(selectionMode)(job)} />
|
||||
</div>
|
||||
{#if manualSelectionMode}
|
||||
<div class="w-4 h-4 ml-4 pointer-events-none">
|
||||
<input type="checkbox" checked={selected} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-start pl-2">
|
||||
<div class="flex items-center justify-start pl-4">
|
||||
<JobStatusIcon {job} {isExternal} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { userStore, superadmin } from '$lib/stores'
|
||||
import { X, Check, ChevronDown, Loader2, SquareMousePointer } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import type { RunsSelectionMode } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
isLoading?: boolean
|
||||
selectionCount: number
|
||||
selectionMode: RunsSelectionMode | false
|
||||
small?: boolean
|
||||
onSetSelectionMode: (mode: RunsSelectionMode | false) => void
|
||||
onCancelSelectedJobs: () => void
|
||||
onCancelFilteredJobs: () => void
|
||||
onReRunSelectedJobs: () => void
|
||||
onReRunFilteredJobs: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
isLoading = false,
|
||||
selectionCount,
|
||||
selectionMode,
|
||||
small = false,
|
||||
onSetSelectionMode,
|
||||
onCancelSelectedJobs,
|
||||
onCancelFilteredJobs,
|
||||
onReRunSelectedJobs,
|
||||
onReRunFilteredJobs
|
||||
}: Props = $props()
|
||||
|
||||
function jobCountString(count: number) {
|
||||
return `${count} ${count == 1 ? 'job' : 'jobs'}`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<Button size="xs" color="light" disabled>
|
||||
<Loader2 class="animate-spin" size={20} />
|
||||
</Button>
|
||||
{:else if selectionMode}
|
||||
<div class="h-8 flex flex-row items-center gap-1">
|
||||
<Button
|
||||
startIcon={{ icon: X }}
|
||||
iconOnly
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
on:click={() => onSetSelectionMode(false)}
|
||||
/>
|
||||
{#if selectionMode == 'cancel'}
|
||||
<Button
|
||||
disabled={selectionCount == 0}
|
||||
startIcon={{ icon: Check }}
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
destructive
|
||||
on:click={onCancelSelectedJobs}
|
||||
>
|
||||
Cancel {jobCountString(selectionCount)}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if selectionMode == 're-run'}
|
||||
<Button
|
||||
disabled={selectionCount == 0}
|
||||
startIcon={{ icon: Check }}
|
||||
unifiedSize="md"
|
||||
variant="accent"
|
||||
on:click={onReRunSelectedJobs}
|
||||
>
|
||||
Re-run {jobCountString(selectionCount)}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<DropdownV2
|
||||
class="w-fit"
|
||||
items={[
|
||||
{
|
||||
displayName: 'Select jobs to cancel',
|
||||
action: () => onSetSelectionMode('cancel')
|
||||
},
|
||||
...($userStore?.is_admin || $superadmin
|
||||
? [{ displayName: 'Cancel all jobs matching filters', action: onCancelFilteredJobs }]
|
||||
: []),
|
||||
{
|
||||
displayName: 'Select jobs to re-run',
|
||||
action: () => onSetSelectionMode('re-run')
|
||||
},
|
||||
...($userStore?.is_admin || $superadmin
|
||||
? [{ displayName: 'Re-run all jobs matching filters', action: onReRunFilteredJobs }]
|
||||
: [])
|
||||
]}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
variant="default"
|
||||
unifiedSize="md"
|
||||
startIcon={{ icon: SquareMousePointer }}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
>
|
||||
{#if !small}
|
||||
<span>Batch actions</span>
|
||||
{/if}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,13 +2,25 @@
|
||||
import type { Job } from '$lib/gen'
|
||||
import RunRow from './RunRow.svelte'
|
||||
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import {
|
||||
AlertTriangle,
|
||||
CircleXIcon,
|
||||
Code2Icon,
|
||||
ExternalLinkIcon,
|
||||
RefreshCwIcon
|
||||
} from 'lucide-svelte'
|
||||
import Popover from '../Popover.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import './runs-grid.css'
|
||||
import type { RunsSelectionMode } from '$lib/utils'
|
||||
import { useKeyPressed } from '$lib/svelte5Utils.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import RightClickPopover from '../RightClickPopover.svelte'
|
||||
import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte'
|
||||
import { clickOutside, isJobCancelable, isJobReRunnable } from '$lib/utils'
|
||||
import { goto } from '$lib/navigation'
|
||||
import BarsStaggered from '../icons/BarsStaggered.svelte'
|
||||
|
||||
interface Props {
|
||||
//import InfiniteLoading from 'svelte-infinite-loading'
|
||||
@@ -16,13 +28,15 @@
|
||||
externalJobs?: Job[]
|
||||
omittedObscuredJobs: boolean
|
||||
showExternalJobs?: boolean
|
||||
selectionMode?: RunsSelectionMode | false
|
||||
selectedIds?: string[]
|
||||
selectedWorkspace?: string | undefined
|
||||
activeLabel?: string | null
|
||||
// const loadMoreQuantity: number = 100
|
||||
lastFetchWentToEnd?: boolean
|
||||
perPage?: number
|
||||
batchRerunOptionsIsOpen?: boolean
|
||||
manualSelectionMode: undefined | 'cancel' | 'rerun'
|
||||
onCancelJobs: (jobIds: string[]) => void
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -30,14 +44,44 @@
|
||||
externalJobs = [],
|
||||
omittedObscuredJobs,
|
||||
showExternalJobs = false,
|
||||
selectionMode = false,
|
||||
selectedIds = $bindable([]),
|
||||
selectedWorkspace = $bindable(undefined),
|
||||
activeLabel = null,
|
||||
lastFetchWentToEnd = false,
|
||||
perPage = 1000
|
||||
perPage = 1000,
|
||||
manualSelectionMode,
|
||||
onCancelJobs,
|
||||
batchRerunOptionsIsOpen = $bindable()
|
||||
}: Props = $props()
|
||||
|
||||
let hasClickFocus = $state(false)
|
||||
const keysPressed = useKeyPressed(['Shift', 'Control', 'Meta', 'A', 'ArrowDown', 'ArrowUp'], {
|
||||
onKeyDown(key, e) {
|
||||
if (!hasClickFocus) return
|
||||
if (key === 'A' && (keysPressed.Control || keysPressed.Meta)) {
|
||||
if (batchRerunOptionsIsOpen) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
selectedIds = flatJobs
|
||||
? flatJobs
|
||||
.filter((jobOrDate) => jobOrDate.type === 'job')
|
||||
.map((jobOrDate) => jobOrDate.job.id)
|
||||
: []
|
||||
} else if ((key === 'ArrowDown' || key === 'ArrowUp') && selectedIds.length === 1) {
|
||||
const idx = flatJobs?.findIndex(
|
||||
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
|
||||
)
|
||||
if (idx == undefined) return
|
||||
let nextJob = flatJobs?.[idx + (key === 'ArrowDown' ? 1 : -1)]
|
||||
if (nextJob?.type === 'date') nextJob = flatJobs?.[idx + (key === 'ArrowDown' ? 2 : -2)]
|
||||
if (nextJob?.type !== 'job') return
|
||||
selectedIds = [nextJob.job.id]
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
})
|
||||
let rightClickPopover: RightClickPopover | undefined = $state(undefined)
|
||||
|
||||
function getTime(job: Job): string | undefined {
|
||||
return job['completed_at'] ?? job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
|
||||
}
|
||||
@@ -116,7 +160,6 @@
|
||||
}
|
||||
|
||||
let tableHeight: number = $state(0)
|
||||
let headerHeight: number = $state(0)
|
||||
let containerWidth: number = $state(0)
|
||||
// const MAX_ITEMS = perPage
|
||||
|
||||
@@ -136,22 +179,21 @@
|
||||
}
|
||||
*/
|
||||
|
||||
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
|
||||
function jobCountString(
|
||||
jobCount: number | undefined,
|
||||
lastFetchWentToEnd: boolean,
|
||||
hideLabel?: boolean
|
||||
): string {
|
||||
if (jobCount === undefined) {
|
||||
return ''
|
||||
}
|
||||
const jc = jobCount
|
||||
const isTruncated = jc >= perPage && !lastFetchWentToEnd
|
||||
|
||||
return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
|
||||
if (hideLabel) return `${jc}${isTruncated ? '+' : ''}`
|
||||
else return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
function computeHeight() {
|
||||
tableHeight = document.querySelector('#runs-table-wrapper')!.parentElement?.clientHeight ?? 0
|
||||
}
|
||||
onMount(() => {
|
||||
computeHeight()
|
||||
})
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let scrollToIndex = $state(0)
|
||||
@@ -193,34 +235,129 @@
|
||||
return nstickyIndices
|
||||
})
|
||||
|
||||
const showTag = $derived(containerWidth > 700)
|
||||
let showTag = $derived(containerWidth > 700)
|
||||
let selectedIdsPossibleActions = $derived.by(() => {
|
||||
const cancellableJobIds: string[] = []
|
||||
const rerunnableJobIds: string[] = []
|
||||
for (const jobId of selectedIds) {
|
||||
const job = flatJobs?.find(
|
||||
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === jobId
|
||||
)
|
||||
if (job?.type === 'job') {
|
||||
if (isJobCancelable(job.job)) cancellableJobIds.push(job.job.id)
|
||||
if (isJobReRunnable(job.job)) rerunnableJobIds.push(job.job.id)
|
||||
}
|
||||
}
|
||||
return { cancellableJobIds, rerunnableJobIds }
|
||||
})
|
||||
let hoveredDropdownAction: 'cancel' | 'rerun' | null = $state(null)
|
||||
|
||||
let dropdownActions: DropdownMenuProps['items'] = $derived.by(() => {
|
||||
let rerunnable = selectedIdsPossibleActions.rerunnableJobIds.length
|
||||
let cancellable = selectedIdsPossibleActions.cancellableJobIds.length
|
||||
const actions: DropdownMenuProps['items'] = []
|
||||
if (selectedIds.length === 1) {
|
||||
actions.push({
|
||||
label: 'Show run details',
|
||||
icon: ExternalLinkIcon,
|
||||
onClick: () => goto(`/run/${selectedIds[0]}`)
|
||||
})
|
||||
const job = flatJobs?.find(
|
||||
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
|
||||
)
|
||||
if (job?.type === 'job') {
|
||||
if (job.job.job_kind === 'script') {
|
||||
actions.push({
|
||||
label: 'Go to script page',
|
||||
icon: Code2Icon,
|
||||
onClick: () => goto(`/scripts/get/${job.job.script_hash}`)
|
||||
})
|
||||
}
|
||||
if (job.job.job_kind === 'flow') {
|
||||
actions.push({
|
||||
label: 'Go to flow page',
|
||||
icon: BarsStaggered,
|
||||
onClick: () => goto(`/flows/get/${job.job.script_path}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rerunnable)
|
||||
actions.push({
|
||||
label: 'Run again',
|
||||
icon: RefreshCwIcon,
|
||||
right: selectedIds.length >= 2 ? `${rerunnable}` : undefined,
|
||||
onClick: () => {
|
||||
selectedIds = selectedIdsPossibleActions.rerunnableJobIds
|
||||
batchRerunOptionsIsOpen = true
|
||||
},
|
||||
onHover: (hover) => (hoveredDropdownAction = hover ? 'rerun' : null)
|
||||
})
|
||||
if (cancellable)
|
||||
actions.push({
|
||||
label: 'Cancel',
|
||||
icon: CircleXIcon,
|
||||
right: selectedIds.length >= 2 ? `${cancellable}` : undefined,
|
||||
onClick: () => onCancelJobs?.(selectedIdsPossibleActions.cancellableJobIds),
|
||||
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
|
||||
})
|
||||
return actions
|
||||
})
|
||||
|
||||
function jobIsSelectable(job: Job) {
|
||||
if (
|
||||
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'cancel') ||
|
||||
manualSelectionMode === 'cancel'
|
||||
)
|
||||
return isJobCancelable(job)
|
||||
if (
|
||||
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'rerun') ||
|
||||
manualSelectionMode === 'rerun' ||
|
||||
batchRerunOptionsIsOpen
|
||||
)
|
||||
return isJobReRunnable(job)
|
||||
return true
|
||||
}
|
||||
|
||||
let selectableJobs = $derived(jobs?.filter(jobIsSelectable) ?? [])
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={() => computeHeight()} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="divide-y h-full border min-w-[650px]"
|
||||
class="divide-y h-full flex flex-col min-w-[650px]"
|
||||
id="runs-table-wrapper"
|
||||
onclick={() => (hasClickFocus = true)}
|
||||
use:clickOutside={{ onClickOutside: () => (hasClickFocus = false) }}
|
||||
bind:clientWidth={containerWidth}
|
||||
>
|
||||
<div bind:clientHeight={headerHeight}>
|
||||
<div>
|
||||
<div
|
||||
class="grid bg-surface-secondary sticky top-0 w-full py-2 pr-4"
|
||||
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
|
||||
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
|
||||
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
|
||||
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
|
||||
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
|
||||
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
|
||||
class="grid sticky top-0 w-full min-h-6 my-2 pr-4 items-end"
|
||||
class:grid-runs-table={!containsLabel && !manualSelectionMode && showTag}
|
||||
class:grid-runs-table-with-labels={containsLabel && !manualSelectionMode && showTag}
|
||||
class:grid-runs-table-selection={!containsLabel && manualSelectionMode && showTag}
|
||||
class:grid-runs-table-with-labels-selection={containsLabel && manualSelectionMode && showTag}
|
||||
class:grid-runs-table-no-tag={!containsLabel && !manualSelectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-no-tag={containsLabel && !manualSelectionMode && !showTag}
|
||||
class:grid-runs-table-selection-no-tag={!containsLabel && manualSelectionMode && !showTag}
|
||||
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
|
||||
selectionMode &&
|
||||
manualSelectionMode &&
|
||||
!showTag}
|
||||
>
|
||||
{#if selectionMode}
|
||||
<div class="text-xs font-semibold pl-4"></div>
|
||||
{#if manualSelectionMode}
|
||||
{@const allSelected = selectedIds.length === selectableJobs?.length}
|
||||
<div class="w-4 h-4 ml-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={
|
||||
() => allSelected,
|
||||
() => (selectedIds = allSelected ? [] : (selectableJobs.map((j) => j.id) ?? []))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="text-2xs px-2 flex flex-row items-center gap-2">
|
||||
<div class="text-2xs px-4 flex flex-row items-center gap-2 leading-3">
|
||||
{#if showExternalJobs && externalJobs.length > 0}
|
||||
<div class="flex flex-row">
|
||||
{jobs
|
||||
@@ -239,125 +376,186 @@
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
|
||||
{@const jobCount = jobs
|
||||
? jobCountString(jobs.length, lastFetchWentToEnd, selectedIds.length >= 2)
|
||||
: ''}
|
||||
{selectedIds.length >= 2 ? `${selectedIds.length}/` : ''}<wbr />
|
||||
{jobCount}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs font-semibold"></div>
|
||||
<div class="text-xs font-semibold">Duration</div>
|
||||
<div class="text-xs font-semibold">Path</div>
|
||||
<div class="text-xs font-semibold leading-3">Started</div>
|
||||
<div class="text-xs font-semibold leading-3">Duration</div>
|
||||
<div class="text-xs font-semibold leading-3">Path</div>
|
||||
{#if containsLabel}
|
||||
<div class="text-xs font-semibold">Label</div>
|
||||
<div class="text-xs font-semibold leading-3">Label</div>
|
||||
{/if}
|
||||
<div class="text-xs font-semibold">Triggered by</div>
|
||||
<div class="text-xs font-semibold leading-3">Triggered by</div>
|
||||
{#if showTag}
|
||||
<div class="text-xs font-semibold">Tag</div>
|
||||
<div class="text-xs font-semibold leading-3">Tag</div>
|
||||
{/if}
|
||||
<div class=""></div>
|
||||
<div> </div>
|
||||
</div>
|
||||
</div>
|
||||
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
|
||||
<div class="text-xs text-secondary p-8"> No jobs found for the selected filters. </div>
|
||||
{:else}
|
||||
<VirtualList
|
||||
width="100%"
|
||||
height={tableHeight - headerHeight}
|
||||
itemCount={flatJobs?.length ?? 3}
|
||||
itemSize={42}
|
||||
overscanCount={20}
|
||||
{stickyIndices}
|
||||
{scrollToIndex}
|
||||
scrollToAlignment="center"
|
||||
>
|
||||
{#snippet header()}{/snippet}
|
||||
{#snippet item({ index, style })}
|
||||
<div {style} class="w-full">
|
||||
{#if flatJobs}
|
||||
{@const jobOrDate = flatJobs[index]}
|
||||
|
||||
{#if jobOrDate}
|
||||
{#if jobOrDate?.type === 'date'}
|
||||
<div
|
||||
class="bg-surface-secondary py-2 font-semibold text-xs pl-2 h-[42px] flex items-center"
|
||||
>
|
||||
{jobOrDate.date}
|
||||
</div>
|
||||
<div
|
||||
bind:clientHeight={tableHeight}
|
||||
class="relative flex-1 border rounded-t-md overflow-clip bg-surface-tertiary [&>.virtual-list-wrapper::-webkit-scrollbar-track]:bg-surface-tertiary"
|
||||
>
|
||||
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
|
||||
<div class="text-xs text-secondary p-8"> No jobs found for the selected filters. </div>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 -mt-3"
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault()
|
||||
rightClickPopover?.open(e)
|
||||
}}
|
||||
>
|
||||
<VirtualList
|
||||
width="100%"
|
||||
height={tableHeight}
|
||||
itemCount={flatJobs?.length ?? 3}
|
||||
itemSize={42}
|
||||
overscanCount={20}
|
||||
{stickyIndices}
|
||||
{scrollToIndex}
|
||||
scrollToAlignment="center"
|
||||
>
|
||||
{#snippet header()}{/snippet}
|
||||
{#snippet item({ index, style })}
|
||||
<div {style} class="w-full bg-surface-tertiary">
|
||||
{#if flatJobs}
|
||||
{@const jobOrDate = flatJobs[index]}
|
||||
{#if jobOrDate}
|
||||
{#if jobOrDate?.type === 'date'}
|
||||
<div
|
||||
class={twMerge(
|
||||
'border-b py-1.5 font-semibold text-xs pl-4 h-[42px] flex items-end bg-surface-tertiary'
|
||||
)}
|
||||
>
|
||||
{jobOrDate.date}
|
||||
</div>
|
||||
{:else}
|
||||
{@const selected =
|
||||
jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
|
||||
{@const nonSelectable = !jobIsSelectable(jobOrDate.job)}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'flex flex-row items-center h-full w-full select-none transition-opacity',
|
||||
nonSelectable || (rightClickPopover?.isOpen() && !selected)
|
||||
? 'opacity-20'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
<RunRow
|
||||
{manualSelectionMode}
|
||||
{containsLabel}
|
||||
{showTag}
|
||||
job={jobOrDate.job}
|
||||
{selected}
|
||||
on:select={() => {
|
||||
const jobId = jobOrDate.job.id
|
||||
if (keysPressed.Shift && selectedIds.length > 0) {
|
||||
if (nonSelectable) return
|
||||
const lastSelectedId = selectedIds[selectedIds.length - 1]
|
||||
const lastSelectedIndex = flatJobs?.findIndex(
|
||||
(jobOrDate) =>
|
||||
jobOrDate.type === 'job' && jobOrDate.job.id === lastSelectedId
|
||||
)
|
||||
if (lastSelectedIndex != undefined && flatJobs) {
|
||||
const [start, end] =
|
||||
index < lastSelectedIndex
|
||||
? [index, lastSelectedIndex]
|
||||
: [lastSelectedIndex, index]
|
||||
const newSelectedIds = flatJobs
|
||||
.slice(start, end + 1)
|
||||
.filter((jobOrDate) => jobOrDate.type === 'job')
|
||||
.map((jobOrDate) => jobOrDate.job.id)
|
||||
selectedIds = Array.from(new Set([...selectedIds, ...newSelectedIds]))
|
||||
}
|
||||
} else if (
|
||||
keysPressed.Control ||
|
||||
keysPressed.Meta ||
|
||||
manualSelectionMode
|
||||
) {
|
||||
if (nonSelectable) return
|
||||
if (selectedIds.includes(jobOrDate.job.id)) {
|
||||
selectedIds = selectedIds.filter((id) => id != jobId)
|
||||
} else {
|
||||
selectedIds.push(jobId)
|
||||
selectedIds = selectedIds
|
||||
}
|
||||
} else {
|
||||
if (batchRerunOptionsIsOpen) batchRerunOptionsIsOpen = false
|
||||
if (
|
||||
selectedIds.length !== 1 ||
|
||||
selectedIds[0] !== jobOrDate.job.id ||
|
||||
selectedWorkspace !== jobOrDate.job.workspace_id
|
||||
) {
|
||||
selectedWorkspace = jobOrDate.job.workspace_id
|
||||
selectedIds = [jobOrDate.job.id]
|
||||
dispatch('select')
|
||||
} else {
|
||||
selectedIds = []
|
||||
selectedWorkspace = undefined
|
||||
dispatch('select')
|
||||
}
|
||||
}
|
||||
}}
|
||||
{activeLabel}
|
||||
on:filterByLabel
|
||||
on:filterByPath
|
||||
on:filterByUser
|
||||
on:filterByFolder
|
||||
on:filterByConcurrencyKey
|
||||
on:filterBySchedule
|
||||
on:filterByWorker
|
||||
{containerWidth}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{JSON.stringify(jobOrDate)}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-row items-center h-full w-full">
|
||||
<RunRow
|
||||
{containsLabel}
|
||||
{showTag}
|
||||
job={jobOrDate.job}
|
||||
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
|
||||
{selectionMode}
|
||||
on:select={() => {
|
||||
const jobId = jobOrDate.job.id
|
||||
if (selectionMode) {
|
||||
if (selectedIds.includes(jobOrDate.job.id)) {
|
||||
selectedIds = selectedIds.filter((id) => id != jobId)
|
||||
} else {
|
||||
selectedIds.push(jobId)
|
||||
selectedIds = selectedIds
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
JSON.stringify(selectedIds) !== JSON.stringify([jobOrDate.job.id]) ||
|
||||
selectedWorkspace !== jobOrDate.job.workspace_id
|
||||
) {
|
||||
selectedWorkspace = jobOrDate.job.workspace_id
|
||||
selectedIds = [jobOrDate.job.id]
|
||||
dispatch('select')
|
||||
} else {
|
||||
selectedIds = []
|
||||
selectedWorkspace = undefined
|
||||
dispatch('select')
|
||||
}
|
||||
}
|
||||
}}
|
||||
{activeLabel}
|
||||
on:filterByLabel
|
||||
on:filterByPath
|
||||
on:filterByUser
|
||||
on:filterByFolder
|
||||
on:filterByConcurrencyKey
|
||||
on:filterBySchedule
|
||||
on:filterByWorker
|
||||
{containerWidth}
|
||||
/>
|
||||
<div class="w-1/12 text-2xs">...</div>
|
||||
<div class="w-4/12 text-xs">...</div>
|
||||
<div class="w-4/12 text-xs">...</div>
|
||||
<div class="w-3/12 text-xs">...</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
{JSON.stringify(jobOrDate)}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-row items-center h-full w-full">
|
||||
<div class="w-1/12 text-2xs">...</div>
|
||||
<div class="w-4/12 text-xs">...</div>
|
||||
<div class="w-4/12 text-xs">...</div>
|
||||
<div class="w-3/12 text-xs">...</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div
|
||||
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
|
||||
<button
|
||||
class="text-xs text-accent text-center w-full pb-2"
|
||||
onclick={() => {
|
||||
dispatch('loadExtra')
|
||||
}}
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div
|
||||
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
|
||||
<button
|
||||
class="text-xs text-accent text-center w-full pb-2"
|
||||
onclick={() => {
|
||||
dispatch('loadExtra')
|
||||
}}
|
||||
>
|
||||
Load next {perPage} jobs
|
||||
</button>
|
||||
{/if}</div
|
||||
>
|
||||
Load next {perPage} jobs
|
||||
</button>
|
||||
{/if}</div
|
||||
>
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</VirtualList>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RightClickPopover bind:this={rightClickPopover}>
|
||||
<DropdownMenu closeCallback={() => rightClickPopover?.close()} items={dropdownActions} />
|
||||
</RightClickPopover>
|
||||
|
||||
<style>
|
||||
:global(.virtual-list-wrapper:hover::-webkit-scrollbar) {
|
||||
:global(.virtual-list-wrapper::-webkit-scrollbar) {
|
||||
width: 8px !important;
|
||||
height: 8px !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script module lang="ts">
|
||||
function computeMinMaxInc(inc: number) {
|
||||
let minTs = new Date(new Date().getTime() - inc).toISOString()
|
||||
let maxTs = new Date().toISOString()
|
||||
return { minTs, maxTs }
|
||||
}
|
||||
|
||||
export type Timeframe =
|
||||
| {
|
||||
label: string
|
||||
computeMinMax: () => { minTs: string | null; maxTs: string | null }
|
||||
type: 'dynamic'
|
||||
}
|
||||
| {
|
||||
label: string
|
||||
computeMinMax: () => { minTs: string | null; maxTs: string | null }
|
||||
minTs: string | null
|
||||
maxTs: string | null
|
||||
type: 'manual'
|
||||
}
|
||||
|
||||
export function buildManualTimeframe(minTs: string | null, maxTs: string | null): Timeframe {
|
||||
return {
|
||||
label: formatDateRange(minTs ?? undefined, maxTs ?? undefined),
|
||||
minTs,
|
||||
maxTs,
|
||||
type: 'manual',
|
||||
computeMinMax: () => ({ minTs, maxTs })
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceLogsTimeframes: Timeframe[] = [
|
||||
{ label: '1000 last service logs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
|
||||
{ label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
|
||||
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
|
||||
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
|
||||
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
|
||||
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
|
||||
].map((item) => ({ ...item, type: 'dynamic' }))
|
||||
|
||||
export const runsTimeframes: Timeframe[] = [
|
||||
{ label: 'Latest runs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
|
||||
{ label: 'Within 30 seconds', computeMinMax: () => computeMinMaxInc(30 * 1000) },
|
||||
{ label: 'Within last minute', computeMinMax: () => computeMinMaxInc(60 * 1000) },
|
||||
{ label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
|
||||
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
|
||||
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
|
||||
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
|
||||
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
|
||||
].map((item) => ({ ...item, type: 'dynamic' }))
|
||||
|
||||
export function useUrlSyncedTimeframe(timeframes: Timeframe[]) {
|
||||
let obj = $state({ timeframe: timeframes[0] })
|
||||
let timeframe = $derived(obj.timeframe)
|
||||
watch(
|
||||
() => [page, timeframe],
|
||||
() => {
|
||||
const url = new URL(page.url)
|
||||
|
||||
if (timeframe.type === 'manual' && timeframe.minTs)
|
||||
url.searchParams.set('min_ts', timeframe.minTs)
|
||||
else url.searchParams.delete('min_ts')
|
||||
|
||||
if (timeframe.type === 'manual' && timeframe.maxTs)
|
||||
url.searchParams.set('max_ts', timeframe.maxTs)
|
||||
else url.searchParams.delete('max_ts')
|
||||
|
||||
if (timeframe.type === 'dynamic' && timeframe.label !== timeframes[0].label)
|
||||
url.searchParams.set('timeframe', timeframe.label)
|
||||
else url.searchParams.delete('timeframe')
|
||||
|
||||
history.replaceState(null, '', url)
|
||||
}
|
||||
)
|
||||
|
||||
if (page.url.searchParams.get('min_ts') || page.url.searchParams.get('max_ts')) {
|
||||
obj.timeframe = buildManualTimeframe(
|
||||
page.url.searchParams.get('min_ts') || null,
|
||||
page.url.searchParams.get('max_ts') || null
|
||||
)
|
||||
} else {
|
||||
const tfLabel = page.url.searchParams.get('timeframe')
|
||||
const tf = timeframes.find((tf) => tf.label === tfLabel)
|
||||
if (tf) obj.timeframe = { ...tf }
|
||||
}
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
export function useSyncedTimeframe(
|
||||
timeframes: Timeframe[],
|
||||
getter: () => { minTs?: string | null; maxTs?: string | null; timeframe?: string | null },
|
||||
setter: (v: { minTs?: string | null; maxTs?: string | null; timeframe?: string | null }) => void
|
||||
) {
|
||||
const val = $derived.by(() => {
|
||||
const v = getter()
|
||||
if (v.minTs || v.maxTs) {
|
||||
return buildManualTimeframe(v.minTs ?? null, v.maxTs ?? null)
|
||||
} else if (v.timeframe) {
|
||||
const tf = timeframes.find((tf) => tf.label === v.timeframe)
|
||||
if (tf) return { ...tf }
|
||||
}
|
||||
return timeframes[0]
|
||||
})
|
||||
return {
|
||||
get val() {
|
||||
return val
|
||||
},
|
||||
set val(v: Timeframe) {
|
||||
if (v.type === 'manual') {
|
||||
setter({ minTs: v.minTs, maxTs: v.maxTs, timeframe: null })
|
||||
} else {
|
||||
setter({ minTs: null, maxTs: null, timeframe: v.label })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
import { formatDateRange } from '$lib/utils'
|
||||
import { watch } from 'runed'
|
||||
import { page } from '$app/state'
|
||||
import InlineCalendarInput, {
|
||||
fromCalendarDate,
|
||||
toCalendarDate
|
||||
} from '../common/InlineCalendarInput.svelte'
|
||||
|
||||
interface Props {
|
||||
loading?: boolean
|
||||
items: Timeframe[]
|
||||
value: Timeframe
|
||||
wrapperClasses?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
let { loading = false, onClick, items, value = $bindable(), wrapperClasses }: Props = $props()
|
||||
|
||||
let isOpen = $state(false)
|
||||
|
||||
function onManualInput(input: { minTs?: string | null; maxTs?: string | null }) {
|
||||
if (value.type !== 'manual')
|
||||
value = buildManualTimeframe(input.minTs ?? null, input.maxTs ?? null)
|
||||
else {
|
||||
value = buildManualTimeframe(
|
||||
'minTs' in input ? (input.minTs ?? null) : value.minTs,
|
||||
'maxTs' in input ? (input.maxTs ?? null) : value.maxTs
|
||||
)
|
||||
}
|
||||
if (value.type == 'manual' && value.minTs == null && value.maxTs == null) {
|
||||
value = { ...items[0] }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative flex {wrapperClasses}">
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
wrapperClasses="flex-1"
|
||||
btnClasses="!rounded-r-none whitespace-nowrap"
|
||||
onClick={() => onClick?.()}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
|
||||
{value.label}
|
||||
</div>
|
||||
</Button>
|
||||
{#if value.type === 'manual'}
|
||||
<Button
|
||||
btnClasses="!rounded-none border-l-0"
|
||||
unifiedSize="md"
|
||||
onClick={() => (value = { ...items[0] })}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
{/if}
|
||||
<Popover enableFlyTransition bind:isOpen>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
iconOnly
|
||||
btnClasses="!rounded-l-none border-l-0"
|
||||
endIcon={{ icon: CalendarIcon }}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{@const range = {
|
||||
end: toCalendarDate(
|
||||
value.type === 'manual' && value.maxTs ? new Date(value.maxTs) : undefined
|
||||
),
|
||||
start: toCalendarDate(
|
||||
value.type === 'manual' && value.minTs ? new Date(value.minTs) : undefined
|
||||
)
|
||||
}}
|
||||
<div class="flex divide-x">
|
||||
<div class="flex flex-col p-2">
|
||||
{#each items as item}
|
||||
<Button
|
||||
onClick={() => (value = { ...item })}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
selected={value.label === item.label}
|
||||
btnClasses="justify-start text-nowrap"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<InlineCalendarInput
|
||||
class="p-4 max-w-[18rem]"
|
||||
infiniteRange
|
||||
mode="range"
|
||||
onClickBehavior="set-start"
|
||||
bind:value={
|
||||
() => range,
|
||||
(v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null })
|
||||
}
|
||||
/>
|
||||
<InlineCalendarInput
|
||||
class="p-4 max-w-[18rem]"
|
||||
infiniteRange
|
||||
mode="range"
|
||||
onClickBehavior="set-end"
|
||||
bind:value={
|
||||
() => range,
|
||||
(v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -0,0 +1,221 @@
|
||||
import type { JobTriggerKind } from '$lib/gen'
|
||||
import {
|
||||
Braces,
|
||||
Calendar,
|
||||
Clock,
|
||||
ServerCog,
|
||||
FileCode,
|
||||
FolderIcon,
|
||||
Hash,
|
||||
ListFilter,
|
||||
Lock,
|
||||
Tag,
|
||||
UserIcon,
|
||||
Zap,
|
||||
CirclePlayIcon
|
||||
} from 'lucide-svelte'
|
||||
import { triggerDisplayNamesMap } from '../triggers/utils'
|
||||
import type { FilterInstanceRec, FilterSchemaRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildRunsFilterSearchbarSchema({
|
||||
paths,
|
||||
usernames,
|
||||
folders,
|
||||
jobTriggerKinds,
|
||||
isSuperAdmin
|
||||
}: {
|
||||
paths: string[]
|
||||
usernames: string[]
|
||||
folders: string[]
|
||||
jobTriggerKinds: JobTriggerKind[]
|
||||
isSuperAdmin: boolean
|
||||
}) {
|
||||
return {
|
||||
min_ts: {
|
||||
type: 'date' as const,
|
||||
label: 'From',
|
||||
icon: Calendar,
|
||||
description: 'Only include jobs that completed after this date',
|
||||
mode: 'start',
|
||||
otherField: 'max_ts'
|
||||
},
|
||||
max_ts: {
|
||||
type: 'date' as const,
|
||||
label: 'To',
|
||||
icon: Calendar,
|
||||
description: 'Only include jobs that completed before this date',
|
||||
mode: 'end',
|
||||
otherField: 'min_ts'
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: paths.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: true,
|
||||
allowMultiple: true,
|
||||
label: 'Path',
|
||||
icon: FileCode,
|
||||
description: 'Filter by script or flow path'
|
||||
},
|
||||
user: {
|
||||
type: 'oneof' as const,
|
||||
options: usernames.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: true,
|
||||
allowMultiple: true,
|
||||
label: 'User',
|
||||
icon: UserIcon,
|
||||
description: 'Filter by user who created the job'
|
||||
},
|
||||
folder: {
|
||||
type: 'oneof' as const,
|
||||
options: folders.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: true,
|
||||
allowMultiple: true,
|
||||
label: 'Folder',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by folder containing the script or flow'
|
||||
},
|
||||
label: {
|
||||
type: 'string' as const,
|
||||
allowMultiple: true,
|
||||
label: 'Label',
|
||||
icon: Tag,
|
||||
description: 'Filter by custom label attached to jobs'
|
||||
},
|
||||
tag: {
|
||||
type: 'string' as const,
|
||||
allowMultiple: true,
|
||||
label: 'Tag',
|
||||
icon: Hash,
|
||||
description: 'Filter by worker tag'
|
||||
},
|
||||
worker: {
|
||||
type: 'string' as const,
|
||||
allowMultiple: true,
|
||||
label: 'Worker',
|
||||
icon: ServerCog,
|
||||
description: 'Filter by specific worker instance'
|
||||
},
|
||||
schedule_path: {
|
||||
type: 'string' as const,
|
||||
label: 'Schedule path',
|
||||
icon: Clock,
|
||||
description: 'Filter by schedule that triggered the job'
|
||||
},
|
||||
concurrency_key: {
|
||||
type: 'string' as const,
|
||||
label: 'Concurrency key',
|
||||
icon: Lock,
|
||||
description: 'Filter by concurrency limit key'
|
||||
},
|
||||
job_kinds: {
|
||||
type: 'oneof' as const,
|
||||
options: [
|
||||
{ label: 'All', value: 'all' as const },
|
||||
{
|
||||
label: 'Runs (default)',
|
||||
value: 'runs' as const,
|
||||
description:
|
||||
'Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook'
|
||||
},
|
||||
{
|
||||
label: 'Dependencies',
|
||||
value: 'dependencies' as const,
|
||||
description:
|
||||
'Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies.'
|
||||
},
|
||||
{
|
||||
label: 'Previews',
|
||||
value: 'previews' as const,
|
||||
description: 'Previews are jobs that have been started in the editor as "Tests"'
|
||||
},
|
||||
{
|
||||
label: 'Sync',
|
||||
value: 'deploymentcallbacks' as const,
|
||||
description:
|
||||
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
|
||||
}
|
||||
],
|
||||
label: 'Job kinds',
|
||||
icon: ListFilter,
|
||||
description: 'Filter by job category'
|
||||
},
|
||||
status: {
|
||||
type: 'oneof' as const,
|
||||
options: [
|
||||
{ label: 'All (default)', value: 'all' as const },
|
||||
{ label: 'Running', value: 'running' as const },
|
||||
{ label: 'Success', value: 'success' as const },
|
||||
{ label: 'Failure', value: 'failure' as const },
|
||||
{ label: 'Waiting', value: 'waiting' as const },
|
||||
{ label: 'Suspended', value: 'suspended' as const }
|
||||
],
|
||||
label: 'Status',
|
||||
icon: CirclePlayIcon,
|
||||
description: 'Filter by job execution status'
|
||||
},
|
||||
show_skipped: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Show skipped',
|
||||
description: 'Include skipped flow steps'
|
||||
},
|
||||
job_trigger_kind: {
|
||||
type: 'oneof' as const,
|
||||
label: 'Trigger kind',
|
||||
icon: Zap,
|
||||
options: jobTriggerKinds.map((value) => ({
|
||||
label: triggerDisplayNamesMap[value],
|
||||
value
|
||||
})),
|
||||
allowNegative: true,
|
||||
allowMultiple: true,
|
||||
description: 'Filter by how the job was triggered'
|
||||
},
|
||||
arg: {
|
||||
type: 'string' as const,
|
||||
format: 'json' as const,
|
||||
label: 'Args',
|
||||
icon: Braces,
|
||||
description: 'Filter by job arguments (JSON format)'
|
||||
},
|
||||
result: {
|
||||
type: 'string' as const,
|
||||
format: 'json' as const,
|
||||
label: 'Result',
|
||||
icon: Braces,
|
||||
description: 'Filter by job result (JSON format)'
|
||||
},
|
||||
show_future_jobs: {
|
||||
type: 'boolean' as const,
|
||||
label: 'Show future jobs (Default: true)',
|
||||
description: 'Include jobs that are planned later'
|
||||
},
|
||||
...(isSuperAdmin && {
|
||||
all_workspaces: {
|
||||
type: 'boolean' as const,
|
||||
label: 'All workspaces',
|
||||
description: 'Show jobs of all workspaces (superadmin only)'
|
||||
}
|
||||
})
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
|
||||
export type RunsFilterSearchbarSchema = ReturnType<typeof buildRunsFilterSearchbarSchema>
|
||||
export type RunsFilterInstance = FilterInstanceRec<RunsFilterSearchbarSchema>
|
||||
|
||||
export function allowWildcards(filters: Partial<RunsFilterInstance> | undefined) {
|
||||
return (
|
||||
filters?.label?.includes('*') ||
|
||||
filters?.worker?.includes('*') ||
|
||||
filters?.tag?.includes('*') ||
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
export const buildRunsFilterPresets = ({ isSuperadmin }: { isSuperadmin: boolean }) => [
|
||||
{ name: 'Hide schedules', value: 'job_trigger_kind:\\ !schedule' },
|
||||
{ name: 'Hide future jobs', value: 'show_future_jobs:\\ false' },
|
||||
...(isSuperadmin ? [{ name: 'All workspaces', value: 'all_workspaces:\\ true' }] : [])
|
||||
]
|
||||
@@ -15,7 +15,8 @@ import { sendUserToast } from '$lib/toast'
|
||||
import { tweened, type Tweened } from 'svelte/motion'
|
||||
import { subtractDaysFromDateString } from '$lib/utils'
|
||||
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
|
||||
import type { RunsFilters } from './RunsFilter.svelte'
|
||||
import type { Timeframe } from './TimeframeSelect.svelte'
|
||||
import { allowWildcards as _allowWildcards, type RunsFilterInstance } from './runsFilter'
|
||||
|
||||
export function computeJobKinds(jobKindsCat: string | null): string {
|
||||
if (jobKindsCat == 'all') {
|
||||
@@ -46,7 +47,8 @@ export function computeJobKinds(jobKindsCat: string | null): string {
|
||||
|
||||
export interface UseJobLoaderArgs {
|
||||
currentWorkspace: string
|
||||
filters?: Partial<RunsFilters>
|
||||
filters?: Partial<RunsFilterInstance>
|
||||
timeframe?: Timeframe
|
||||
jobKinds?: string
|
||||
autoRefresh?: boolean
|
||||
argError?: string
|
||||
@@ -54,9 +56,8 @@ export interface UseJobLoaderArgs {
|
||||
refreshRate?: number
|
||||
syncQueuedRunsCount?: boolean
|
||||
skip?: boolean
|
||||
computeMinAndMax?: (() => { minTs: string; maxTs: string | null } | undefined) | undefined
|
||||
lookback?: number
|
||||
onSetMinMaxTs?: (minTs: string | null, maxTs: string | null) => void
|
||||
perPage?: number
|
||||
onSetPerPage?: (perPage: number) => void
|
||||
}
|
||||
|
||||
@@ -71,32 +72,29 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
let resultError = $derived(_args.resultError ?? '')
|
||||
let refreshRate = $derived(_args.refreshRate ?? 5000)
|
||||
let syncQueuedRunsCount = $derived(_args.syncQueuedRunsCount ?? true)
|
||||
let computeMinAndMax = $derived(_args.computeMinAndMax)
|
||||
let lookback = $derived(_args.lookback ?? 0)
|
||||
let onSetMinMaxTs = $derived(_args.onSetMinMaxTs)
|
||||
let onSetPerPage = $derived(_args.onSetPerPage)
|
||||
let timeframe = $derived(_args?.timeframe)
|
||||
let perPage = $derived(_args?.perPage ?? 1000)
|
||||
|
||||
let label = $derived(filters?.label ?? null)
|
||||
let worker = $derived(filters?.worker ?? null)
|
||||
let success = $derived(filters?.success ?? null)
|
||||
let success = $derived(filters?.status ?? null)
|
||||
let showSkipped = $derived(filters?.show_skipped ?? false)
|
||||
let showSchedules = $derived(filters?.show_schedules ?? true)
|
||||
let showSchedules = $derived(!filters?.job_trigger_kind?.includes('!schedule'))
|
||||
let showFutureJobs = $derived(filters?.show_future_jobs ?? true)
|
||||
let resultFilter = $derived(filters?.result)
|
||||
let jobTriggerKind = $derived(filters?.job_trigger_kind ?? null)
|
||||
let schedulePath = $derived(filters?.schedule_path ?? null)
|
||||
let jobKindsCat = $derived(filters?.job_kinds ?? null)
|
||||
let allWorkspaces = $derived(filters?.all_workspaces ?? false)
|
||||
let allowWildcards = $derived(filters?.allow_wildcards ?? false)
|
||||
let allowWildcards = $derived(_allowWildcards(filters))
|
||||
let concurrencyKey = $derived(filters?.concurrency_key)
|
||||
let tag = $derived(filters?.tag)
|
||||
let user = $derived(filters?.user)
|
||||
let folder = $derived(filters?.folder)
|
||||
let path = $derived(filters?.path)
|
||||
let argFilter = $derived(filters?.arg)
|
||||
let minTs = $derived(filters?.min_ts ?? null)
|
||||
let maxTs = $derived(filters?.max_ts ?? null)
|
||||
let perPage = $derived(filters?.per_page ?? 100)
|
||||
|
||||
let queue_count: Tweened<number> | undefined = $state()
|
||||
let suspended_count: Tweened<number> | undefined = $state()
|
||||
@@ -162,6 +160,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
// const minCreated = lastJob?.created_at
|
||||
const minCreated = new Date(new Date(ts).getTime() - 1).toISOString()
|
||||
|
||||
const minTs = timeframe?.computeMinMax().minTs ?? null
|
||||
let olderJobs = await fetchJobs(minCreated, minTs, undefined)
|
||||
jobs = updateWithNewJobs(olderJobs ?? [], jobs ?? [])
|
||||
computeCompletedJobs()
|
||||
@@ -178,8 +177,8 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
): CancelablePromise<Job[]> {
|
||||
if (_args.skip) return CancelablePromiseUtils.pure<Job[]>([])
|
||||
loadingFetch = true
|
||||
let scriptPathStart = folder === null || folder === '' ? undefined : `f/${folder}/`
|
||||
let scriptPathExact = path === null || path === '' ? undefined : path
|
||||
let scriptPathStart = folder == null || folder === '' ? undefined : `f/${folder}/`
|
||||
let scriptPathExact = path == null || path === '' ? undefined : path
|
||||
let promise = JobService.listJobs({
|
||||
workspace: currentWorkspace,
|
||||
completedBefore: completedBefore ?? undefined,
|
||||
@@ -187,7 +186,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
createdAfterQueue,
|
||||
schedulePath: schedulePath ?? undefined,
|
||||
scriptPathExact,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
createdBy: user == null || user === '' ? undefined : user,
|
||||
scriptPathStart: scriptPathStart,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
@@ -200,9 +199,9 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
// isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
hasNullParent: jobKindsCat != 'all' ? true : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
worker: worker === null || worker === '' ? undefined : worker,
|
||||
label: label == null || label === '' ? undefined : label,
|
||||
tag: tag == null || tag === '' ? undefined : tag,
|
||||
worker: worker == null || worker === '' ? undefined : worker,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
suspended: success == 'waiting' ? false : success == 'suspended' ? true : undefined,
|
||||
scheduledForBeforeNow:
|
||||
@@ -250,16 +249,16 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
// createdOrStartedAfter: startedAfter,
|
||||
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
|
||||
schedulePath: schedulePath ?? undefined,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
scriptPathExact: path == null || path === '' ? undefined : path,
|
||||
createdBy: user == null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder == null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
label: label == null || label === '' ? undefined : label,
|
||||
tag: tag == null || tag === '' ? undefined : tag,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
scheduledForBeforeNow: showFutureJobs == false ? true : undefined,
|
||||
args:
|
||||
@@ -299,6 +298,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
intervalId = setInterval(syncer, refreshRate)
|
||||
}
|
||||
function loadJobsIntern(shouldGetCount?: boolean): CancelablePromise<void> {
|
||||
const { minTs, maxTs } = timeframe?.computeMinMax() ?? { minTs: null, maxTs: null }
|
||||
if (shouldGetCount) {
|
||||
getCount()
|
||||
}
|
||||
@@ -374,9 +374,6 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
let lastQueueTs: string | undefined = undefined
|
||||
|
||||
async function syncer() {
|
||||
if (success == 'waiting') {
|
||||
onSetMinMaxTs?.(null, null)
|
||||
}
|
||||
if (loadingFetch) {
|
||||
return
|
||||
}
|
||||
@@ -385,15 +382,8 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
getCount()
|
||||
}
|
||||
|
||||
const ts = computeMinAndMax?.()
|
||||
if (ts) {
|
||||
onSetMinMaxTs?.(ts.minTs, ts.maxTs)
|
||||
if (maxTs != undefined) {
|
||||
loadJobsIntern(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (jobs && maxTs == undefined) {
|
||||
const { minTs, maxTs } = timeframe?.computeMinMax() ?? { minTs: null, maxTs: null }
|
||||
if (jobs) {
|
||||
if (success == 'running') {
|
||||
loadJobsIntern(false)
|
||||
} else {
|
||||
@@ -531,9 +521,13 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
Object.keys(filters ?? {}).map((k) => filters?.[k as keyof RunsFilters])
|
||||
Object.keys(filters ?? {}).map((k) => filters?.[k as keyof RunsFilterInstance])
|
||||
currentWorkspace
|
||||
lookback
|
||||
timeframe
|
||||
perPage
|
||||
showSchedules
|
||||
showFutureJobs
|
||||
let p = untrack(() => onParamChanges())
|
||||
return () => p.cancel()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { FileCode, FileText, Clock, Braces, Users } from 'lucide-svelte'
|
||||
import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildSchedulesFilterSchema({
|
||||
paths,
|
||||
scriptPaths,
|
||||
showUserFoldersFilter,
|
||||
userFoldersLabel
|
||||
}: {
|
||||
paths: string[]
|
||||
scriptPaths: string[]
|
||||
showUserFoldersFilter?: boolean
|
||||
userFoldersLabel?: string
|
||||
}) {
|
||||
return {
|
||||
schedule_path: {
|
||||
type: 'oneof' as const,
|
||||
options: paths.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Schedule path',
|
||||
icon: Clock,
|
||||
description: 'Filter by exact schedule path'
|
||||
},
|
||||
path_start: {
|
||||
type: 'string' as const,
|
||||
label: 'Path prefix',
|
||||
icon: FileCode,
|
||||
description: 'Filter by schedule path prefix'
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: scriptPaths.map((s) => ({ label: s, value: s })),
|
||||
allowCustomValue: true,
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Script/Flow path',
|
||||
icon: FileCode,
|
||||
description: 'Filter by the script or flow path that the schedule runs'
|
||||
},
|
||||
description: {
|
||||
type: 'string' as const,
|
||||
label: 'Description',
|
||||
icon: FileText,
|
||||
description: 'Search in schedule description'
|
||||
},
|
||||
summary: {
|
||||
type: 'string' as const,
|
||||
label: 'Summary',
|
||||
icon: FileText,
|
||||
description: 'Search in schedule summary'
|
||||
},
|
||||
args: {
|
||||
type: 'string' as const,
|
||||
format: 'json' as const,
|
||||
label: 'Args subset',
|
||||
icon: Braces,
|
||||
description: 'Filter by JSON args subset match (e.g., {"param": "value"})'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only schedules in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import ConditionalPortal from '../common/drawer/ConditionalPortal.svelte'
|
||||
import { untrack, type Snippet } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
import { watch } from 'runed'
|
||||
|
||||
let {
|
||||
listAutoWidth = true,
|
||||
strictWidth = false,
|
||||
disablePortal = false,
|
||||
open,
|
||||
disabled = false,
|
||||
class: className = '',
|
||||
innerClass = '',
|
||||
maxHeight = 256,
|
||||
getInputRect,
|
||||
children
|
||||
}: {
|
||||
listAutoWidth?: boolean
|
||||
strictWidth?: boolean
|
||||
disablePortal?: boolean
|
||||
open: boolean
|
||||
disabled?: boolean
|
||||
class?: string
|
||||
innerClass?: string
|
||||
maxHeight?: number
|
||||
getInputRect?: () => DOMRect
|
||||
children?: Snippet
|
||||
} = $props()
|
||||
|
||||
let listEl: HTMLDivElement | undefined = $state()
|
||||
let dropdownPos = $state(computeDropdownPos())
|
||||
let reducedMotion = useReducedMotion()
|
||||
|
||||
function computeDropdownPos(): {
|
||||
width: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
isBelow: boolean
|
||||
} {
|
||||
if (!getInputRect || !listEl) return { width: 0, height: 0, x: 0, y: 0, isBelow: true }
|
||||
let inputR = getInputRect()
|
||||
const listR = listEl.getBoundingClientRect()
|
||||
const isBelow = inputR.y + inputR.height + listR.height <= window.innerHeight
|
||||
let [x, y] = disablePortal ? [0, 0] : [inputR.x, inputR.y]
|
||||
if (isBelow)
|
||||
return { width: inputR.width, height: listR.height, x: x, y: y + inputR.height, isBelow }
|
||||
else {
|
||||
return { width: inputR.width, height: listR.height, x: x, y: y - listR.height, isBelow }
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
function updateDropdownPos() {
|
||||
let nPos = computeDropdownPos()
|
||||
if (!deepEqual(nPos, dropdownPos)) dropdownPos = nPos
|
||||
if (open) requestAnimationFrame(updateDropdownPos)
|
||||
}
|
||||
if (open) untrack(() => updateDropdownPos())
|
||||
})
|
||||
|
||||
// We do not want to render the dropdown when it is closed for performance reasons
|
||||
// but we want to keep it in the DOM for a short time to allow for transitions to finish
|
||||
//
|
||||
// We do not use Svelte transitions because they can not animate in the opposite direction
|
||||
// when the dropdown is opens above the input
|
||||
// Also CSS transitions are smoother because they do not rely on JS / animation frames
|
||||
let uiState = $state({ domExists: open, visible: open, timeout: null as number | null })
|
||||
let initial = true
|
||||
watch(
|
||||
() => open && !disabled,
|
||||
(isOpen) => {
|
||||
untrack(() => {
|
||||
if (initial) {
|
||||
initial = false
|
||||
return
|
||||
}
|
||||
if (reducedMotion.val) {
|
||||
uiState = {
|
||||
domExists: open && !disabled,
|
||||
visible: open && !disabled,
|
||||
timeout: null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (uiState.timeout) clearTimeout(uiState.timeout)
|
||||
uiState = {
|
||||
domExists: true,
|
||||
visible: !isOpen,
|
||||
timeout: setTimeout(() => {
|
||||
if (isOpen) {
|
||||
uiState.visible = true
|
||||
uiState.timeout = null
|
||||
} else if (!isOpen) {
|
||||
uiState.visible = false
|
||||
uiState.timeout = setTimeout(() => {
|
||||
uiState.domExists = false
|
||||
uiState.timeout = null
|
||||
}, 500) // leave time for transition to finish
|
||||
}
|
||||
}, 0) // We need the height to be 0 then change immediately for the transition to play
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<ConditionalPortal condition={!disablePortal} name="dropdown-portal">
|
||||
{#if uiState.domExists}
|
||||
<div
|
||||
class={twMerge(
|
||||
open ? 'dropdown-open' : 'dropdown-closed',
|
||||
disablePortal ? 'absolute z-[5002]' : 'fixed z-[10000]',
|
||||
'text-primary text-sm select-none',
|
||||
dropdownPos.isBelow ? '' : 'flex flex-col justify-end',
|
||||
uiState.visible ? '' : 'pointer-events-none',
|
||||
className
|
||||
)}
|
||||
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
|
||||
? `${strictWidth ? 'width' : 'min-width'}: ${dropdownPos.width}px; height: ${dropdownPos.height}px;`
|
||||
: ''}"
|
||||
>
|
||||
<div
|
||||
class={twMerge(
|
||||
'overflow-clip rounded-md drop-shadow-base',
|
||||
!reducedMotion.val ? 'transition-height' : '',
|
||||
dropdownPos.isBelow ? '' : 'flex flex-col justify-end'
|
||||
)}
|
||||
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
|
||||
>
|
||||
<div
|
||||
bind:this={listEl}
|
||||
class="flex flex-col rounded-md bg-surface-input {innerClass}"
|
||||
style="max-height: {maxHeight}px;"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</ConditionalPortal>
|
||||
@@ -16,6 +16,7 @@
|
||||
inputSizeClasses
|
||||
} from '../text_input/TextInput.svelte'
|
||||
import { ButtonType } from '../common/button/model'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
|
||||
type Value = Item['value']
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
RightIcon,
|
||||
createText,
|
||||
noItemsMsg,
|
||||
tooltip,
|
||||
open = $bindable(false),
|
||||
id,
|
||||
itemLabelWrapperClasses,
|
||||
@@ -72,12 +74,13 @@
|
||||
createText?: string
|
||||
noItemsMsg?: string
|
||||
open?: boolean
|
||||
tooltip?: string
|
||||
id?: string
|
||||
itemLabelWrapperClasses?: string
|
||||
itemButtonWrapperClasses?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
showPlaceholderOnOpen?: boolean
|
||||
transformInputSelectedText?: (text: string) => string
|
||||
transformInputSelectedText?: (text: string, value: Value) => string
|
||||
groupBy?: (item: Item) => string
|
||||
sortBy?: (a: Item, b: Item) => number
|
||||
onFocus?: () => void
|
||||
@@ -106,7 +109,9 @@
|
||||
if (!open) filterText = ''
|
||||
})
|
||||
|
||||
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
|
||||
let valueEntry = $derived(
|
||||
value != null ? processedItems?.find((item) => deepEqual(item.value, value)) : undefined
|
||||
)
|
||||
|
||||
function setValue(item: ProcessedItem<Value>) {
|
||||
if (item.__is_create && onCreateItem) {
|
||||
@@ -126,12 +131,12 @@
|
||||
|
||||
let inputText = $derived.by(() => {
|
||||
let text = valueEntry?.label ?? getLabel({ value }) ?? ''
|
||||
return transformInputSelectedText?.(text) ?? text
|
||||
return transformInputSelectedText?.(text, value) ?? text
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`relative ${className}`}
|
||||
class={`relative h-fit ${className}`}
|
||||
use:clickOutside={{ onClickOutside: () => (open = false) }}
|
||||
onpointerdown={() => onFocus?.()}
|
||||
onfocus={() => onFocus?.()}
|
||||
@@ -155,6 +160,13 @@
|
||||
<RightIcon size={iconSize} class="text-secondary" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tooltip}
|
||||
<div class="absolute z-10 right-2 h-full flex items-center">
|
||||
<Tooltip>{tooltip}</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
{autofocus}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import ConditionalPortal from '../common/drawer/ConditionalPortal.svelte'
|
||||
import { untrack, type Snippet } from 'svelte'
|
||||
import type { ProcessedItem } from './utils.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { PlusIcon } from 'lucide-svelte'
|
||||
import { useReducedMotion } from '$lib/svelte5Utils.svelte'
|
||||
import { watch } from 'runed'
|
||||
import GenericDropdown from './GenericDropdown.svelte'
|
||||
import { Debounced } from 'runed'
|
||||
|
||||
let {
|
||||
processedItems: _processedItems,
|
||||
@@ -33,7 +31,7 @@
|
||||
processedItems?: ProcessedItem<T>[]
|
||||
value: T | undefined
|
||||
filterText?: string
|
||||
listAutoWidth?: Boolean
|
||||
listAutoWidth?: boolean
|
||||
disabled?: boolean
|
||||
disablePortal?: boolean
|
||||
open: boolean
|
||||
@@ -62,93 +60,25 @@
|
||||
)
|
||||
)
|
||||
|
||||
let listEl: HTMLDivElement | undefined = $state()
|
||||
let dropdownPos = $state(computeDropdownPos())
|
||||
let keyArrowPos = $state<number | undefined>()
|
||||
let reducedMotion = useReducedMotion()
|
||||
|
||||
function computeDropdownPos(): {
|
||||
width: number
|
||||
height: number
|
||||
x: number
|
||||
y: number
|
||||
isBelow: boolean
|
||||
} {
|
||||
if (!getInputRect || !listEl) return { width: 0, height: 0, x: 0, y: 0, isBelow: true }
|
||||
let inputR = getInputRect()
|
||||
const listR = listEl.getBoundingClientRect()
|
||||
const isBelow = inputR.y + inputR.height + listR.height <= window.innerHeight
|
||||
let [x, y] = disablePortal ? [0, 0] : [inputR.x, inputR.y]
|
||||
if (isBelow)
|
||||
return { width: inputR.width, height: listR.height, x: x, y: y + inputR.height, isBelow }
|
||||
else {
|
||||
return { width: inputR.width, height: listR.height, x: x, y: y - listR.height, isBelow }
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
function updateDropdownPos() {
|
||||
let nPos = computeDropdownPos()
|
||||
if (!deepEqual(nPos, dropdownPos)) dropdownPos = nPos
|
||||
if (open) requestAnimationFrame(updateDropdownPos)
|
||||
}
|
||||
if (open) untrack(() => updateDropdownPos())
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
;[open, processedItems]
|
||||
untrack(() => (keyArrowPos = open && (filterText || highlightFirstOnOpen) ? 0 : undefined))
|
||||
})
|
||||
|
||||
// We do not want to render the dropdown when it is closed for performance reasons
|
||||
// but we want to keep it in the DOM for a short time to allow for transitions to finish
|
||||
//
|
||||
// We do not use Svelte transitions because they can not animate in the opposite direction
|
||||
// when the dropdown is opens above the input
|
||||
// Also CSS transitions are smoother because they do not rely on JS / animation frames
|
||||
let uiState = $state({ domExists: open, visible: open, timeout: null as number | null })
|
||||
let initial = true
|
||||
watch(
|
||||
() => open && !disabled,
|
||||
(isOpen) => {
|
||||
untrack(() => {
|
||||
if (initial) {
|
||||
initial = false
|
||||
return
|
||||
}
|
||||
if (reducedMotion.val) {
|
||||
uiState = {
|
||||
domExists: open && !disabled,
|
||||
visible: open && !disabled,
|
||||
timeout: null
|
||||
}
|
||||
return
|
||||
}
|
||||
if (uiState.timeout) clearTimeout(uiState.timeout)
|
||||
uiState = {
|
||||
domExists: true,
|
||||
visible: !isOpen,
|
||||
timeout: setTimeout(() => {
|
||||
if (isOpen) {
|
||||
uiState.visible = true
|
||||
uiState.timeout = null
|
||||
} else if (!isOpen) {
|
||||
uiState.visible = false
|
||||
uiState.timeout = setTimeout(() => {
|
||||
uiState.domExists = false
|
||||
uiState.timeout = null
|
||||
}, 500) // leave time for transition to finish
|
||||
}
|
||||
}, 0) // We need the height to be 0 then change immediately for the transition to play
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
// Expose whether the dropdown is visually open for keyboard nav guard.
|
||||
// We mirror the same logic GenericDropdown uses: open && !disabled.
|
||||
let isVisible = $derived(open && !disabled)
|
||||
|
||||
// Dirty fix to prevent a rendering bug where the ul is present in the layout but
|
||||
// displays as empty. It only happens with overflow-y-auto set.
|
||||
let enableOverflowYAuto = new Debounced(() => isVisible, 15)
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
on:keydown={(e) => {
|
||||
if (!uiState.visible || !processedItems?.length) return
|
||||
if (!isVisible || !processedItems?.length) return
|
||||
if (e.key === 'ArrowUp' && keyArrowPos !== undefined && processedItems.length > 0) {
|
||||
keyArrowPos = keyArrowPos <= 0 ? undefined : keyArrowPos - 1
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
@@ -167,85 +97,68 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConditionalPortal condition={!disablePortal} name="select-dropdown-portal">
|
||||
{#if uiState.domExists}
|
||||
<div
|
||||
class={twMerge(
|
||||
open ? 'select-dropdown-open' : 'select-dropdown-closed',
|
||||
disablePortal ? 'absolute z-[5002]' : 'fixed z-[10000]',
|
||||
'text-primary text-sm select-none',
|
||||
dropdownPos.isBelow ? '' : 'flex flex-col justify-end',
|
||||
uiState.visible ? '' : 'pointer-events-none',
|
||||
className
|
||||
)}
|
||||
style="{`top: ${dropdownPos.y}px; left: ${dropdownPos.x}px;`} {listAutoWidth
|
||||
? `min-width: ${dropdownPos.width}px; height: ${dropdownPos.height}px;`
|
||||
: ''}"
|
||||
>
|
||||
<div
|
||||
class={twMerge(
|
||||
'overflow-clip rounded-md drop-shadow-base',
|
||||
!reducedMotion.val ? 'transition-height' : '',
|
||||
dropdownPos.isBelow ? '' : 'flex flex-col justify-end'
|
||||
)}
|
||||
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
|
||||
>
|
||||
<div
|
||||
bind:this={listEl}
|
||||
class="flex flex-col rounded-md bg-surface-input"
|
||||
style="max-height: {maxHeight}px;"
|
||||
>
|
||||
{@render header?.()}
|
||||
{#if processedItems?.length === 0}
|
||||
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
|
||||
{/if}
|
||||
<ul class={twMerge('flex-1 overflow-y-auto flex flex-col', ulClass)}>
|
||||
{#each processedItems ?? [] as item, itemIndex}
|
||||
{#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
|
||||
<li
|
||||
class={twMerge(
|
||||
'mx-4 pb-1 mb-2 text-xs font-semibold text-primary border-b border-border-light',
|
||||
itemIndex === 0 ? 'mt-3' : 'mt-6'
|
||||
)}
|
||||
>
|
||||
{item.__select_group}
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button
|
||||
class={twMerge(
|
||||
'py-2 px-4 w-full font-normal text-left text-primary text-xs',
|
||||
itemIndex === keyArrowPos || item.value === value
|
||||
? 'bg-surface-secondary dark:bg-surface-tertiary'
|
||||
: 'hover:bg-surface-hover',
|
||||
endSnippet || item.__is_create ? 'flex items-center justify-between gap-2' : '',
|
||||
itemButtonWrapperClasses,
|
||||
item.disabled ? 'cursor-not-allowed text-disabled' : ''
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopImmediatePropagation()
|
||||
if (!item.disabled) onSelectValue(item)
|
||||
}}
|
||||
>
|
||||
{@render startSnippet?.({ item, close: () => (open = false) })}
|
||||
<span class={itemLabelWrapperClasses}>
|
||||
{item.label || '\xa0'}
|
||||
</span>
|
||||
{#if item.__is_create}
|
||||
<PlusIcon class="inline ml-auto" size={16} />
|
||||
{:else}
|
||||
{@render endSnippet?.({ item, close: () => (open = false) })}
|
||||
{/if}
|
||||
{#if item.subtitle}
|
||||
<div class="text-2xs text-secondary">{item.subtitle}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{@render bottomSnippet?.({ close: () => (open = false) })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<GenericDropdown
|
||||
{listAutoWidth}
|
||||
{disablePortal}
|
||||
{open}
|
||||
{disabled}
|
||||
{maxHeight}
|
||||
{getInputRect}
|
||||
class={className}
|
||||
>
|
||||
{@render header?.()}
|
||||
{#if processedItems?.length === 0}
|
||||
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
|
||||
{/if}
|
||||
</ConditionalPortal>
|
||||
<ul
|
||||
class={twMerge(
|
||||
'flex-1 flex flex-col',
|
||||
enableOverflowYAuto.current ? 'overflow-y-auto' : '',
|
||||
ulClass
|
||||
)}
|
||||
>
|
||||
{#each processedItems ?? [] as item, itemIndex (item.value)}
|
||||
{#if (item.__select_group && itemIndex === 0) || processedItems?.[itemIndex - 1]?.__select_group !== item.__select_group}
|
||||
<li
|
||||
class={twMerge(
|
||||
'mx-4 pb-1 mb-2 text-xs font-semibold text-primary border-b border-border-light',
|
||||
itemIndex === 0 ? 'mt-3' : 'mt-6'
|
||||
)}
|
||||
>
|
||||
{item.__select_group}
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button
|
||||
class={twMerge(
|
||||
'py-2 px-4 w-full font-normal text-left text-primary text-xs',
|
||||
itemIndex === keyArrowPos || item.value === value
|
||||
? 'bg-surface-secondary dark:bg-surface-tertiary'
|
||||
: 'hover:bg-surface-hover',
|
||||
endSnippet || item.__is_create ? 'flex items-center justify-between gap-2' : '',
|
||||
itemButtonWrapperClasses,
|
||||
item.disabled ? 'cursor-not-allowed text-disabled' : ''
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopImmediatePropagation()
|
||||
if (!item.disabled) onSelectValue(item)
|
||||
}}
|
||||
>
|
||||
{@render startSnippet?.({ item, close: () => (open = false) })}
|
||||
<span class={itemLabelWrapperClasses}>
|
||||
{item.label || '\xa0'}
|
||||
</span>
|
||||
{#if item.__is_create}
|
||||
<PlusIcon class="inline ml-auto" size={16} />
|
||||
{:else}
|
||||
{@render endSnippet?.({ item, close: () => (open = false) })}
|
||||
{/if}
|
||||
{#if item.subtitle}
|
||||
<div class="text-2xs text-secondary">{item.subtitle}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{@render bottomSnippet?.({ close: () => (open = false) })}
|
||||
</GenericDropdown>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { FileCode, FileText, FolderIcon, Key, Users } from 'lucide-svelte'
|
||||
import type { FilterSchemaRec } from '../FilterSearchbar.svelte'
|
||||
|
||||
export function buildVariablesFilterSchema({
|
||||
paths,
|
||||
owners,
|
||||
showUserFoldersFilter,
|
||||
userFoldersLabel
|
||||
}: {
|
||||
paths: string[]
|
||||
owners: string[]
|
||||
showUserFoldersFilter?: boolean
|
||||
userFoldersLabel?: string
|
||||
}) {
|
||||
return {
|
||||
owner: {
|
||||
type: 'oneof' as const,
|
||||
options: owners.map((s) => ({ label: s, value: s })),
|
||||
allowNegative: false,
|
||||
allowMultiple: false,
|
||||
label: 'Owner',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by owner (folder or user path)'
|
||||
},
|
||||
path: {
|
||||
type: 'oneof' as const,
|
||||
options: paths.map((s) => ({ label: s, value: s })),
|
||||
allowNegative: true,
|
||||
allowMultiple: true,
|
||||
label: 'Path',
|
||||
icon: FileCode,
|
||||
description: 'Filter by exact variable path'
|
||||
},
|
||||
path_start: {
|
||||
type: 'string' as const,
|
||||
label: 'Path prefix',
|
||||
icon: FolderIcon,
|
||||
description: 'Filter by path prefix (e.g., "f/folder/")'
|
||||
},
|
||||
description: {
|
||||
type: 'string' as const,
|
||||
label: 'Description',
|
||||
icon: FileText,
|
||||
description: 'Search in variable description'
|
||||
},
|
||||
value: {
|
||||
type: 'string' as const,
|
||||
label: 'Value',
|
||||
icon: Key,
|
||||
description: 'Search in non-secret variable values'
|
||||
},
|
||||
...(showUserFoldersFilter
|
||||
? {
|
||||
user_folders_only: {
|
||||
type: 'boolean' as const,
|
||||
label: userFoldersLabel || 'User folders only',
|
||||
icon: Users,
|
||||
description: 'Show only variables in user folders'
|
||||
}
|
||||
}
|
||||
: {})
|
||||
} satisfies FilterSchemaRec
|
||||
}
|
||||
@@ -415,3 +415,187 @@ export function useInfiniteQuery<TData, TPageParam = number>(
|
||||
reset
|
||||
}
|
||||
}
|
||||
|
||||
export function useKeyPressed<Key extends string>(
|
||||
keys: Key[],
|
||||
params?: {
|
||||
onKeyUp?: (key: Key, e: KeyboardEvent) => void
|
||||
onKeyDown?: (key: Key, e: KeyboardEvent) => void
|
||||
}
|
||||
): Record<Key, boolean> {
|
||||
if (typeof window === 'undefined')
|
||||
return Object.fromEntries(keys.map((key) => [key, false])) as Record<Key, boolean>
|
||||
let obj = $state(Object.fromEntries(keys.map((key) => [key, false])) as Record<Key, boolean>)
|
||||
$effect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
for (const key of keys) {
|
||||
if (event.key.toLowerCase() === key.toLowerCase()) {
|
||||
obj[key] = true
|
||||
params?.onKeyDown?.(key, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
for (const key of keys) {
|
||||
if (event.key.toLowerCase() === key.toLowerCase()) {
|
||||
obj[key] = false
|
||||
params?.onKeyUp?.(key, event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset all keys when window loses focus or visibility changes to prevent stuck keys
|
||||
const resetAllKeys = () => {
|
||||
for (const key of keys) obj[key] = false
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('keyup', handleKeyUp)
|
||||
window.addEventListener('blur', resetAllKeys)
|
||||
document.addEventListener('visibilitychange', resetAllKeys)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('keyup', handleKeyUp)
|
||||
window.removeEventListener('blur', resetAllKeys)
|
||||
document.removeEventListener('visibilitychange', resetAllKeys)
|
||||
}
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
export function useTransformedSyncedValue<T, U>(
|
||||
source: [() => T, (val: T) => void],
|
||||
transform: (val: T) => U,
|
||||
inverseTransform: (val: U) => T
|
||||
) {
|
||||
let st = $state(transform(source[0]()))
|
||||
|
||||
let skipUpdate = false
|
||||
watch(source[0], (val) => {
|
||||
if (skipUpdate) {
|
||||
skipUpdate = false
|
||||
return
|
||||
}
|
||||
st = transform(val)
|
||||
})
|
||||
|
||||
return {
|
||||
get val() {
|
||||
return st
|
||||
},
|
||||
set val(newVal) {
|
||||
skipUpdate = true
|
||||
st = newVal
|
||||
source[1](inverseTransform(newVal))
|
||||
setTimeout(() => {
|
||||
skipUpdate = false
|
||||
})
|
||||
},
|
||||
reparse() {
|
||||
st = transform(source[0]())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains a local copy of a value that syncs back to the parent state in a debounced way.
|
||||
*
|
||||
* Useful for inputs where you want immediate local reactivity but don't want to
|
||||
* flood the parent with updates (e.g. text fields, sliders).
|
||||
*
|
||||
* @param getter - Reads the canonical value from the parent
|
||||
* @param setter - Writes a new canonical value to the parent (called debounced)
|
||||
* @param react - A function that reads the fields to react to.
|
||||
* @param delay - Debounce delay in ms (default: 300)
|
||||
*
|
||||
* @example
|
||||
* const debounced = new DebouncedTempValue(
|
||||
* () => props.value,
|
||||
* (v) => { props.value = v },
|
||||
* (t) => t, // react to the value itself
|
||||
* 300
|
||||
* )
|
||||
* // Use debounced.current in the template; writes are debounced to the parent.
|
||||
*/
|
||||
export class DebouncedTempValue<T> {
|
||||
current: T
|
||||
#timer: ReturnType<typeof setTimeout> | undefined
|
||||
#skipNextParentUpdate = false
|
||||
#skipNextCurrentUpdate = false
|
||||
|
||||
constructor(
|
||||
getter: () => T,
|
||||
private setter: (val: T) => void,
|
||||
react: (t: T) => void,
|
||||
private delay: number = 300
|
||||
) {
|
||||
this.current = $state(untrack(getter))
|
||||
|
||||
watch(getter, (val) => {
|
||||
if (this.#timer !== undefined) {
|
||||
// An in-flight local edit is pending — don't overwrite it with stale parent data
|
||||
return
|
||||
}
|
||||
if (this.#skipNextParentUpdate) {
|
||||
// The parent just updated in response to our local edit — skip this update as it's not new data
|
||||
this.#skipNextParentUpdate = false
|
||||
return
|
||||
}
|
||||
this.#skipNextCurrentUpdate = true
|
||||
this.current = val
|
||||
setTimeout(() => (this.#skipNextCurrentUpdate = false), 0)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => react(this.current),
|
||||
() => {
|
||||
if (this.#skipNextCurrentUpdate) return
|
||||
if (this.#timer !== undefined) clearTimeout(this.#timer)
|
||||
this.#timer = setTimeout(() => {
|
||||
this.#timer = undefined
|
||||
this.#skipNextParentUpdate = true
|
||||
this.setter(this.current)
|
||||
setTimeout(() => (this.#skipNextParentUpdate = false), 0)
|
||||
}, this.delay)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Flush any pending debounced write immediately. */
|
||||
flush(): void {
|
||||
if (this.#timer !== undefined) {
|
||||
clearTimeout(this.#timer)
|
||||
this.#timer = undefined
|
||||
this.setter(this.current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useLocalStorageValue<T>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
typ?: 'string' | 'number' | 'boolean'
|
||||
): { val: T } {
|
||||
const serialize = (val: T) =>
|
||||
typ === 'string' || typ === 'number' || typ === 'boolean' ? String(val) : JSON.stringify(val)
|
||||
const deserialize = (val: string): T => {
|
||||
if (typ === 'string') return val as any
|
||||
if (typ === 'number') return Number(val) as any
|
||||
if (typ === 'boolean') return (val === 'true') as any
|
||||
return JSON.parse(val) as T
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return { val: defaultValue }
|
||||
const savedValue = localStorage.getItem(key)
|
||||
let s = $state(savedValue ? (deserialize(savedValue) as T) : defaultValue)
|
||||
return {
|
||||
get val() {
|
||||
return s
|
||||
},
|
||||
set val(newVal: T) {
|
||||
localStorage.setItem(key, serialize(newVal))
|
||||
s = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,121 @@
|
||||
// SvelteKit-specific utilities
|
||||
// This file should only be imported in SvelteKit apps as it depends on $app/environment
|
||||
|
||||
import * as runed from 'runed/kit'
|
||||
import type z from 'zod'
|
||||
import { z } from 'zod'
|
||||
|
||||
// The original from runed has a weird behavior with dedup reads causing duplicate effect runs
|
||||
// (Every field has to be derived to avoid it : https://runed.dev/docs/utilities/use-search-params)
|
||||
export function useSearchParams<S extends z.ZodType>(
|
||||
schema: S,
|
||||
options?: runed.SearchParamsOptions
|
||||
): runed.ReturnUseSearchParams<S> {
|
||||
let params = runed.useSearchParams(schema, options)
|
||||
let keys = Object.keys((schema as any).shape ?? {})
|
||||
let obj = { ...params }
|
||||
export type SearchParamsResult<S extends z.ZodType> =
|
||||
S extends z.ZodObject<infer Shape>
|
||||
? { -readonly [K in keyof Shape]: z.infer<Shape[K]> } & Record<string, unknown>
|
||||
: z.infer<S> & Record<string, unknown>
|
||||
|
||||
/** Serialize a value to a URL search param string. Primitives are written as-is; anything else is JSON. */
|
||||
function serializeParam(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number') return String(value)
|
||||
if (typeof value === 'boolean') return String(value)
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** Parse a raw string from the URL back to a typed value guided by the zod field schema. */
|
||||
function deserializeParam(raw: string, fieldSchema: z.ZodType): unknown {
|
||||
// Unwrap nullable / optional / default wrappers to get the inner type
|
||||
let inner: z.ZodType = fieldSchema
|
||||
while (
|
||||
inner instanceof (z as any).ZodNullable ||
|
||||
inner instanceof (z as any).ZodOptional ||
|
||||
inner instanceof (z as any).ZodDefault
|
||||
) {
|
||||
inner = (inner as any)._def.innerType ?? (inner as any)._def.type
|
||||
}
|
||||
|
||||
if (inner instanceof (z as any).ZodNumber) {
|
||||
const n = Number(raw)
|
||||
return isNaN(n) ? null : n
|
||||
}
|
||||
if (inner instanceof (z as any).ZodBoolean) {
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
return null
|
||||
}
|
||||
// ZodString, ZodEnum, ZodLiteral, ZodAny, or unknown → try as plain string first,
|
||||
// fall back to JSON parse for non-string schemas
|
||||
if (
|
||||
inner instanceof (z as any).ZodString ||
|
||||
inner instanceof (z as any).ZodEnum ||
|
||||
inner instanceof (z as any).ZodLiteral
|
||||
) {
|
||||
return raw
|
||||
}
|
||||
// For any other type (object, array, …) try JSON
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a reactive object whose properties are synced to the browser's URL search params.
|
||||
* Reading a property returns the current value from the URL (reactive).
|
||||
* Writing a property updates the URL via history.replaceState (no navigation).
|
||||
*
|
||||
* Primitive types (string / number / boolean) are serialized as plain text.
|
||||
* Everything else is JSON-serialized.
|
||||
*/
|
||||
export function useSearchParams<S extends z.ZodType>(schema: S): SearchParamsResult<S> {
|
||||
const shape: Record<string, z.ZodType> = (schema as any).shape ?? {}
|
||||
const keys = Object.keys(shape)
|
||||
|
||||
// Reactive snapshot of search params - one $state cell per key
|
||||
const values: Record<string, unknown> = $state(
|
||||
Object.fromEntries(
|
||||
keys.map((k) => {
|
||||
const raw = new URLSearchParams(window.location.search).get(k)
|
||||
const parsed = raw != null ? deserializeParam(raw, shape[k]) : null
|
||||
return [k, parsed]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
function syncFromUrl() {
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
for (const k of keys) {
|
||||
const raw = sp.get(k)
|
||||
const parsed = raw != null ? deserializeParam(raw, shape[k]) : null
|
||||
;(values as any)[k] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Keep in sync when the user navigates back/forward
|
||||
$effect(() => {
|
||||
window.addEventListener('popstate', syncFromUrl)
|
||||
return () => window.removeEventListener('popstate', syncFromUrl)
|
||||
})
|
||||
|
||||
// Build the proxy object: reads come from $state, writes go to the URL + $state
|
||||
const proxy: Record<string, unknown> = {}
|
||||
for (const key of keys) {
|
||||
// Somehow using $derived does not trigger reactivity sometimes ...
|
||||
// (e.g: filters.arg in RunsPage.svelte updates in the URL but does not trigger reactivity)
|
||||
let derivedVal = $state(params[key])
|
||||
Object.defineProperty(obj, key, {
|
||||
get: () => {
|
||||
if (typeof derivedVal === 'string') return decodeURIComponent(derivedVal)
|
||||
return derivedVal
|
||||
Object.defineProperty(proxy, key, {
|
||||
get() {
|
||||
return (values as any)[key]
|
||||
},
|
||||
set: (v) => {
|
||||
const val = typeof v === 'string' ? encodeURIComponent(v) : v
|
||||
params[key] = val
|
||||
derivedVal = val
|
||||
set(v: unknown) {
|
||||
;(values as any)[key] = v
|
||||
const sp = new URLSearchParams(window.location.search)
|
||||
if (v == null) {
|
||||
sp.delete(key)
|
||||
} else {
|
||||
sp.set(key, serializeParam(v))
|
||||
}
|
||||
const newUrl = sp.toString()
|
||||
? `${window.location.pathname}?${sp}`
|
||||
: window.location.pathname
|
||||
history.replaceState(history.state, '', newUrl)
|
||||
},
|
||||
enumerable: true,
|
||||
configurable: true
|
||||
})
|
||||
}
|
||||
return obj
|
||||
|
||||
return proxy as SearchParamsResult<S>
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ export function sendUserToast(
|
||||
actions: ToastAction[] = [],
|
||||
errorMessage: string | undefined = undefined,
|
||||
duration: number = 5000
|
||||
): void {
|
||||
): {
|
||||
destroy: () => void
|
||||
} {
|
||||
const type = typeof _type === 'boolean' ? (_type ? 'error' : 'success') : _type
|
||||
const error = type === 'error'
|
||||
if (globalThis.windmillToast) {
|
||||
@@ -30,9 +32,9 @@ export function sendUserToast(
|
||||
errorMessage,
|
||||
duration
|
||||
})
|
||||
return
|
||||
return { destroy: () => {} }
|
||||
}
|
||||
toast.push({
|
||||
const id = toast.push({
|
||||
component: {
|
||||
// https://github.com/zerodevx/svelte-toast/issues/115
|
||||
// Svelte 5 changed its component type and svelte-toast is not up to date yet
|
||||
@@ -59,4 +61,8 @@ export function sendUserToast(
|
||||
'--toastBoxShadow': 'none'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
destroy: () => toast.pop(id)
|
||||
}
|
||||
}
|
||||
|
||||
+195
-10
@@ -19,8 +19,6 @@ import type { TriggerKind } from './components/triggers'
|
||||
import { stateSnapshot } from './svelte5Utils.svelte'
|
||||
import { validate, dereference } from '@scalar/openapi-parser'
|
||||
|
||||
export type RunsSelectionMode = 'cancel' | 're-run'
|
||||
|
||||
export namespace OpenApi {
|
||||
export enum OpenApiVersion {
|
||||
V2,
|
||||
@@ -88,14 +86,6 @@ export function isJobReRunnable(j: Job): boolean {
|
||||
|
||||
export const WORKER_NAME_PREFIX = 'wk'
|
||||
|
||||
export function isJobSelectable(selectionType: RunsSelectionMode) {
|
||||
const f: (j: Job) => boolean = {
|
||||
cancel: isJobCancelable,
|
||||
're-run': isJobReRunnable
|
||||
}[selectionType]
|
||||
return f
|
||||
}
|
||||
|
||||
export function escapeHtml(unsafe: string) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
@@ -1590,6 +1580,44 @@ export function formatDateShort(dateString: string | undefined): string {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatDateRange(
|
||||
start: string | Date | undefined,
|
||||
end: string | Date | undefined
|
||||
): string {
|
||||
if (typeof start === 'string') start = new Date(start)
|
||||
if (typeof end === 'string') end = new Date(end)
|
||||
|
||||
if (start && end) {
|
||||
const differentDays =
|
||||
start.getFullYear() !== end.getFullYear() ||
|
||||
start.getMonth() !== end.getMonth() ||
|
||||
start.getDate() !== end.getDate()
|
||||
|
||||
const differentYears = start.getFullYear() !== end.getFullYear()
|
||||
|
||||
if (differentDays || differentYears) {
|
||||
// Clone to avoid mutating originals
|
||||
start = new Date(start)
|
||||
end = new Date(end)
|
||||
// Zero out time for display
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
}
|
||||
|
||||
if (differentYears) {
|
||||
// Zero out month and day for display
|
||||
start.setMonth(0, 1)
|
||||
end.setMonth(0, 1)
|
||||
}
|
||||
|
||||
return `${formatDatePretty(start)} to ${formatDatePretty(end)}`
|
||||
}
|
||||
|
||||
if (!end && start) return `After ${formatDatePretty(start)}`
|
||||
if (!start && end) return `Before ${formatDatePretty(end)}`
|
||||
return 'No input'
|
||||
}
|
||||
|
||||
export function toJsonStr(result: any) {
|
||||
try {
|
||||
// console.log(result)
|
||||
@@ -2053,3 +2081,160 @@ export function formatMemory(
|
||||
|
||||
return display
|
||||
}
|
||||
|
||||
export function assignObjInPlace(
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
options: { onDelete?: 'Delete' | 'SetNull' } = { onDelete: 'Delete' }
|
||||
) {
|
||||
for (const key in target) {
|
||||
if (!(key in source)) {
|
||||
if (options?.onDelete === 'Delete') delete target[key]
|
||||
else if (options?.onDelete === 'SetNull') target[key] = null
|
||||
}
|
||||
}
|
||||
for (const key in source) target[key] = source[key]
|
||||
}
|
||||
|
||||
export function isUSLocale(): boolean {
|
||||
try {
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||
return locale.startsWith('en-US')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDatePretty(date: Date): string {
|
||||
if (!date || isNaN(date.getTime())) return ''
|
||||
|
||||
const now = new Date()
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
const hours = date.getHours()
|
||||
const minutes = date.getMinutes()
|
||||
|
||||
const isCurrentYear = year === now.getFullYear()
|
||||
const isToday = isCurrentYear && month === now.getMonth() + 1 && day === now.getDate()
|
||||
const isOnlyYear = month === 1 && day === 1 && hours === 0 && minutes === 0
|
||||
const hasTime = hours !== 0 || minutes !== 0
|
||||
|
||||
// If only year is defined (rest is 01/01 00:00)
|
||||
if (isOnlyYear) {
|
||||
return String(year)
|
||||
}
|
||||
|
||||
// Format month/day depending on locale: MM/DD for US, DD/MM otherwise
|
||||
const mm = String(month).padStart(2, '0')
|
||||
const dd = String(day).padStart(2, '0')
|
||||
const monthDay = isUSLocale() ? `${mm}/${dd}` : `${dd}/${mm}`
|
||||
// Format time if present (12-hour format with AM/PM)
|
||||
let timeStr = ''
|
||||
if (hasTime) {
|
||||
const isPM = hours >= 12
|
||||
const displayHours = hours % 12 || 12
|
||||
const displayMinutes = String(minutes).padStart(2, '0')
|
||||
timeStr = ` ${displayHours}:${displayMinutes} ${isPM ? 'PM' : 'AM'}`
|
||||
}
|
||||
|
||||
// If today and same year, only show time (if present)
|
||||
if (isToday) {
|
||||
return timeStr ? timeStr.trim() : monthDay
|
||||
}
|
||||
|
||||
// If same year, show month/day and time (if present)
|
||||
if (isCurrentYear) {
|
||||
return `${monthDay}${timeStr}`
|
||||
}
|
||||
|
||||
// Otherwise, show full date with year and time (if present)
|
||||
return `${monthDay}/${year}${timeStr}`
|
||||
}
|
||||
|
||||
export function parsePrettyDate(text: string): Date | null {
|
||||
if (!text) return null
|
||||
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
|
||||
// Try parsing as year-only (e.g., "2025")
|
||||
if (/^\d{4}$/.test(text)) {
|
||||
const year = parseInt(text)
|
||||
return new Date(year, 0, 1, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Try parsing time-only (e.g., "11:02 AM") - assumes today
|
||||
const timeOnlyMatch = text.match(/^(\d{1,2}):(\d{2})\s+(AM|PM)$/i)
|
||||
if (timeOnlyMatch) {
|
||||
const [, hourStr, minuteStr, meridiem] = timeOnlyMatch
|
||||
let hours = parseInt(hourStr)
|
||||
const minutes = parseInt(minuteStr)
|
||||
|
||||
if (meridiem.toUpperCase() === 'PM' && hours !== 12) hours += 12
|
||||
if (meridiem.toUpperCase() === 'AM' && hours === 12) hours = 0
|
||||
|
||||
return new Date(currentYear, now.getMonth(), now.getDate(), hours, minutes, 0)
|
||||
}
|
||||
|
||||
const usLocale = isUSLocale()
|
||||
|
||||
// Parse a "first/second" pair as month/day (US) or day/month (non-US)
|
||||
function parseFirstSecond(firstStr: string, secondStr: string): { month: number; day: number } {
|
||||
const first = parseInt(firstStr)
|
||||
const second = parseInt(secondStr)
|
||||
return usLocale ? { month: first - 1, day: second } : { month: second - 1, day: first }
|
||||
}
|
||||
|
||||
// Try parsing NN/NN (e.g., "01/04") - assumes current year, no time
|
||||
// US: MM/DD, non-US: DD/MM
|
||||
const monthDayMatch = text.match(/^(\d{2})\/(\d{2})$/)
|
||||
if (monthDayMatch) {
|
||||
const { month, day } = parseFirstSecond(monthDayMatch[1], monthDayMatch[2])
|
||||
return new Date(currentYear, month, day, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Try parsing NN/NN TIME (e.g., "01/04 11:02 AM") - assumes current year with time
|
||||
// US: MM/DD TIME, non-US: DD/MM TIME
|
||||
const monthDayTimeMatch = text.match(/^(\d{2})\/(\d{2})\s+(\d{1,2}):(\d{2})\s+(AM|PM)$/i)
|
||||
if (monthDayTimeMatch) {
|
||||
const { month, day } = parseFirstSecond(monthDayTimeMatch[1], monthDayTimeMatch[2])
|
||||
let hours = parseInt(monthDayTimeMatch[3])
|
||||
const minutes = parseInt(monthDayTimeMatch[4])
|
||||
const meridiem = monthDayTimeMatch[5]
|
||||
|
||||
if (meridiem.toUpperCase() === 'PM' && hours !== 12) hours += 12
|
||||
if (meridiem.toUpperCase() === 'AM' && hours === 12) hours = 0
|
||||
|
||||
return new Date(currentYear, month, day, hours, minutes, 0)
|
||||
}
|
||||
|
||||
// Try parsing NN/NN/YYYY (e.g., "01/04/2025") - no time
|
||||
// US: MM/DD/YYYY, non-US: DD/MM/YYYY
|
||||
const fullDateMatch = text.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (fullDateMatch) {
|
||||
const { month, day } = parseFirstSecond(fullDateMatch[1], fullDateMatch[2])
|
||||
const year = parseInt(fullDateMatch[3])
|
||||
return new Date(year, month, day, 0, 0, 0)
|
||||
}
|
||||
|
||||
// Try parsing NN/NN/YYYY TIME (e.g., "01/04/2025 3:00 PM")
|
||||
// US: MM/DD/YYYY TIME, non-US: DD/MM/YYYY TIME
|
||||
const fullDateTimeMatch = text.match(/^(\d{2})\/(\d{2})\/(\d{4})\s+(\d{1,2}):(\d{2})\s+(AM|PM)$/i)
|
||||
if (fullDateTimeMatch) {
|
||||
const { month, day } = parseFirstSecond(fullDateTimeMatch[1], fullDateTimeMatch[2])
|
||||
const year = parseInt(fullDateTimeMatch[3])
|
||||
let hours = parseInt(fullDateTimeMatch[4])
|
||||
const minutes = parseInt(fullDateTimeMatch[5])
|
||||
const meridiem = fullDateTimeMatch[6]
|
||||
|
||||
if (meridiem.toUpperCase() === 'PM' && hours !== 12) hours += 12
|
||||
if (meridiem.toUpperCase() === 'AM' && hours === 12) hours = 0
|
||||
|
||||
return new Date(year, month, day, hours, minutes, 0)
|
||||
}
|
||||
|
||||
// Fallback to standard Date parsing (e.g., ISO strings)
|
||||
const date = new Date(text)
|
||||
return isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
@@ -23,28 +23,47 @@
|
||||
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 Label from '$lib/components/Label.svelte'
|
||||
import MultiSelect from '$lib/components/select/MultiSelect.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
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'
|
||||
import { favoriteManager, parseFavoriteAsset } from '$lib/components/sidebar/FavoriteMenu.svelte'
|
||||
import FilterSearchbar, {
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildAssetsFilterSchema } from '$lib/components/assets/assetsFilter'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
interface AssetCursor {
|
||||
created_at?: string
|
||||
id?: number
|
||||
}
|
||||
|
||||
let assetPathFilter: string = $state('')
|
||||
let usagePathFilter: string = $state('')
|
||||
let assetKindsFilter: Array<AssetKind> = $state([])
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allAssetKinds: string[] = $state([
|
||||
's3object',
|
||||
'resource',
|
||||
'variable',
|
||||
'ducklake',
|
||||
'datatable'
|
||||
])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let assetsFilterSchema = $derived(
|
||||
buildAssetsFilterSchema({
|
||||
paths: allPaths,
|
||||
assetKinds: allAssetKinds
|
||||
})
|
||||
)
|
||||
let filterValues = useUrlSyncedFilterInstance(untrack(() => assetsFilterSchema))
|
||||
|
||||
let filters = new Debounced(
|
||||
() => ({
|
||||
assetPath: assetPathFilter || undefined,
|
||||
usagePath: usagePathFilter || undefined,
|
||||
assetKinds: assetKindsFilter.join(',') || undefined
|
||||
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
|
||||
)
|
||||
@@ -252,29 +271,15 @@
|
||||
</div>
|
||||
</Section>
|
||||
<Section label="Latest assets used">
|
||||
<div class="flex gap-2 mb-4 items-end justify-between">
|
||||
<div class="flex gap-2">
|
||||
<Label class="lg:min-w-[16rem] max-w-[30rem]" label="Asset path">
|
||||
<TextInput bind:value={assetPathFilter} />
|
||||
</Label>
|
||||
<Label class="lg:min-w-[16rem] max-w-[30rem]" label="Usage path">
|
||||
<TextInput bind:value={usagePathFilter} />
|
||||
</Label>
|
||||
<Label class="lg:min-w-[8rem] max-w-[30rem]" label="Asset kinds">
|
||||
<MultiSelect
|
||||
hideMainClearBtn
|
||||
bind:value={assetKindsFilter}
|
||||
items={[
|
||||
{ label: 'S3 Object', value: 's3object' },
|
||||
{ label: 'Ducklake', value: 'ducklake' },
|
||||
{ label: 'Data Table', value: 'datatable' },
|
||||
{ label: 'Resource', value: 'resource' }
|
||||
]}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
{#snippet action()}
|
||||
<RefreshButton onClick={() => assetsQuery.reset()} loading={assetsQuery.isLoading} />
|
||||
</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" />
|
||||
|
||||
@@ -9,14 +9,16 @@
|
||||
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import IconedResourceType from '$lib/components/IconedResourceType.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import Required from '$lib/components/Required.svelte'
|
||||
import { resourceTypesStore } from '$lib/components/resourceTypesStore'
|
||||
import SchemaViewer from '$lib/components/SchemaViewer.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import FilterSearchbar, {
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildResourcesFilterSchema } from '$lib/components/resources/resourcesFilter'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
|
||||
@@ -81,9 +83,10 @@
|
||||
let resources: ResourceW[] | undefined = $state()
|
||||
let resourceTypes: ResourceTypeW[] | undefined = $state()
|
||||
|
||||
let filteredItems: (ResourceW & { marked?: string })[] | undefined = $state(undefined) as
|
||||
| (ResourceW & { marked?: string })[]
|
||||
| undefined
|
||||
// Collect unique paths, types, owners for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allResourceTypes: string[] = $state([])
|
||||
let allOwners: string[] = $state([])
|
||||
|
||||
let resourceTypeViewer: Drawer | undefined = $state(undefined)
|
||||
let resourceTypeViewerObj = $state({
|
||||
@@ -120,12 +123,27 @@
|
||||
types: true
|
||||
})
|
||||
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
let typeFilter: string | undefined = $state(undefined)
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let resourcesFilterSchema = $derived(
|
||||
buildResourcesFilterSchema({
|
||||
paths: allPaths,
|
||||
resourceTypes: allResourceTypes,
|
||||
owners: allOwners,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => resourcesFilterSchema))
|
||||
|
||||
async function loadResources(): Promise<void> {
|
||||
resources = await loadResourceInternal(undefined, 'cache,state')
|
||||
@@ -146,19 +164,48 @@
|
||||
resourceType: string | undefined,
|
||||
resourceTypeExclude: string | undefined
|
||||
): Promise<ResourceW[]> {
|
||||
return (
|
||||
await ResourceService.listResource({
|
||||
workspace: $workspaceStore!,
|
||||
resourceTypeExclude,
|
||||
resourceType
|
||||
})
|
||||
).map((x) => {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters
|
||||
const apiParams: any = {
|
||||
workspace: $workspaceStore!,
|
||||
resourceTypeExclude,
|
||||
resourceType: resourceType || (currentFilters.resource_type as string | undefined)
|
||||
}
|
||||
|
||||
if (currentFilters.path) {
|
||||
// path filter can be comma-separated for multiple values
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.value) {
|
||||
apiParams.value = currentFilters.value
|
||||
}
|
||||
if (currentFilters.owner) {
|
||||
apiParams.pathStart = currentFilters.owner
|
||||
}
|
||||
|
||||
const result = (await ResourceService.listResource(apiParams)).map((x) => {
|
||||
return {
|
||||
canWrite:
|
||||
canWrite(x.path, x.extra_perms!, $userStore) && $workspaceStore! == x.workspace_id,
|
||||
...x
|
||||
}
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allResourceTypes = Array.from(new Set(result.map((x) => x.resource_type))).sort()
|
||||
allOwners = Array.from(
|
||||
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
|
||||
).sort()
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function loadResourceTypes(): Promise<void> {
|
||||
@@ -409,59 +456,39 @@
|
||||
return resourceNameToFileExtMap[resourceName]
|
||||
}
|
||||
|
||||
let owners = $derived(
|
||||
Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
let types = $derived(Array.from(new Set(filteredItems?.map((x) => x.resource_type))).sort())
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
// Current resources based on tab
|
||||
let currentResources = $derived(
|
||||
tab == 'cache' ? cacheResources : tab == 'states' ? stateResources : resources
|
||||
)
|
||||
let preFilteredItemsOwners = $derived(
|
||||
ownerFilter == undefined
|
||||
? currentResources
|
||||
: currentResources?.filter((x) => x.path.startsWith(ownerFilter ?? ''))
|
||||
)
|
||||
let preFilteredType = $derived.by(() => {
|
||||
let l =
|
||||
typeFilter == undefined
|
||||
? preFilteredItemsOwners?.filter((x) => {
|
||||
return tab === 'workspace'
|
||||
? x.resource_type !== 'app_theme' &&
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
: tab === 'states'
|
||||
? x.resource_type === 'state'
|
||||
: tab === 'cache'
|
||||
? x.resource_type === 'cache'
|
||||
: tab === 'theme'
|
||||
? x.resource_type === 'app_theme'
|
||||
: true
|
||||
})
|
||||
: preFilteredItemsOwners?.filter((x) => {
|
||||
return (
|
||||
x.resource_type === typeFilter &&
|
||||
(tab === 'workspace'
|
||||
? x.resource_type !== 'app_theme' &&
|
||||
x.resource_type !== 'state' &&
|
||||
x.resource_type !== 'cache'
|
||||
: true)
|
||||
)
|
||||
})
|
||||
if (filterUserFolders) {
|
||||
l = l?.filter((item) => {
|
||||
if (filterUserFoldersType === 'only f/*') return item.path.startsWith('f/')
|
||||
if (filterUserFoldersType === 'u/username and f/*')
|
||||
|
||||
// Filter resources client-side for user folder filtering (admin feature)
|
||||
let filteredItems = $derived.by(() => {
|
||||
let items = currentResources
|
||||
if (filters.val.user_folders_only && items) {
|
||||
items = items.filter((item) => {
|
||||
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
|
||||
if (userFoldersFilterType === 'u/username and f/*')
|
||||
return item.path.startsWith('f/') || item.path.startsWith(`u/${$userStore?.username}/`)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
// Reload resources when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($workspaceStore) {
|
||||
untrack(() => {
|
||||
if (tab === 'workspace' || tab === 'theme') {
|
||||
loadResources()
|
||||
} else if (tab === 'cache') {
|
||||
loadCache()
|
||||
} else if (tab === 'states') {
|
||||
loadState()
|
||||
}
|
||||
})
|
||||
}
|
||||
return l
|
||||
})
|
||||
$effect(() => {
|
||||
if ($workspaceStore && $userStore) {
|
||||
@@ -482,15 +509,6 @@
|
||||
})
|
||||
|
||||
let dbManagerDrawer = $derived(globalDbManagerDrawer.val) as any
|
||||
|
||||
let filterUserFolders = $state(false)
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
@@ -711,13 +729,6 @@
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredType}
|
||||
bind:filteredItems
|
||||
f={(x) => x.path + ' ' + x.resource_type + ' ' + x.description + ' '}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
@@ -820,33 +831,20 @@
|
||||
</div>
|
||||
</div>
|
||||
{#if tab == 'workspace' || tab == 'states' || tab == 'cache' || tab == 'theme'}
|
||||
<div class="pt-2">
|
||||
<input placeholder="Search Resource" bind:value={filter} class="input mt-1" />
|
||||
</div>
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
{#if tab != 'states' && tab != 'cache'}
|
||||
<ListFilters
|
||||
queryName="app_filter"
|
||||
bind:selectedFilter={typeFilter}
|
||||
filters={types}
|
||||
resourceType
|
||||
/>
|
||||
{:else}
|
||||
<div class="h-4"></div>
|
||||
{/if}
|
||||
<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'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<div class="overflow-x-auto pb-40 mt-4"
|
||||
><div class="flex flex-row items-center justify-end gap-4 pb-2">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="overflow-x-auto pb-40 mt-4">
|
||||
{#if loading.resources}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import { userStore, workspaceStore, userWorkspaces, enterpriseLicense } from '$lib/stores'
|
||||
import {
|
||||
Calendar,
|
||||
Circle,
|
||||
Code,
|
||||
Copy,
|
||||
Eye,
|
||||
FileUp,
|
||||
@@ -33,15 +31,14 @@
|
||||
} from 'lucide-svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import SearchItems from '$lib/components/SearchItems.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'
|
||||
import JobPreview from '$lib/components/jobs/JobPreview.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
|
||||
import { setQuery } from '$lib/navigation'
|
||||
import { onMount, untrack } from 'svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable'
|
||||
import { runScheduleNow } from '$lib/components/triggers/scheduled/utils'
|
||||
@@ -66,9 +63,41 @@
|
||||
}
|
||||
getDeployUiSettings()
|
||||
async function loadSchedules(): Promise<void> {
|
||||
schedules = (await ScheduleService.listSchedules({ workspace: $workspaceStore! })).map((x) => {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters
|
||||
const apiParams: any = {
|
||||
workspace: $workspaceStore!
|
||||
}
|
||||
|
||||
if (currentFilters.schedule_path) {
|
||||
apiParams.schedulePath = currentFilters.schedule_path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.path) {
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.summary) {
|
||||
apiParams.summary = currentFilters.summary
|
||||
}
|
||||
if (currentFilters.args) {
|
||||
apiParams.args = currentFilters.args
|
||||
}
|
||||
|
||||
const result = (await ScheduleService.listSchedules(apiParams)).map((x) => {
|
||||
return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x }
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allScriptPaths = Array.from(new Set(result.map((x) => x.script_path))).sort()
|
||||
|
||||
schedules = result
|
||||
loading = false
|
||||
// after the schedule core data has been loaded, load all the job stats
|
||||
// TODO: we could potentially not reload the job stats on every call to loadSchedules, but for now it's
|
||||
@@ -76,6 +105,14 @@
|
||||
loadSchedulesWithJobStats()
|
||||
}
|
||||
|
||||
// Reload schedules when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($workspaceStore) {
|
||||
untrack(() => loadSchedules())
|
||||
}
|
||||
})
|
||||
|
||||
async function loadSchedulesWithJobStats(): Promise<void> {
|
||||
loadingSchedulesWithJobStats = true
|
||||
let schedulesWithJobsByPath = new Map<string, ScheduleW>()
|
||||
@@ -116,35 +153,50 @@
|
||||
})
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state()
|
||||
|
||||
let filteredItems: (ScheduleW & { marked?: any })[] | undefined = $state([])
|
||||
let items: typeof filteredItems | undefined = $state([])
|
||||
let filter = $state('')
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
let nbDisplayed = $state(15)
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allScriptPaths: string[] = $state([])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let schedulesFilterSchema = $derived(
|
||||
buildSchedulesFilterSchema({
|
||||
paths: allPaths,
|
||||
scriptPaths: allScriptPaths,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*'
|
||||
? 'Only f/*'
|
||||
: `Only u/${$userStore?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => schedulesFilterSchema))
|
||||
|
||||
let nbDisplayed = $state(15)
|
||||
let filterEnabledDisabled: 'all' | 'enabled' | 'disabled' = $state('all')
|
||||
|
||||
const SCHEDULE_PATH_KIND_FILTER_SETTING = 'schedulePathKindFilter'
|
||||
const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only'
|
||||
let selectedFilterKind = $state(
|
||||
(getLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING) as 'schedule' | 'script_flow') ?? 'schedule'
|
||||
)
|
||||
let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true')
|
||||
|
||||
$effect(() => {
|
||||
storeLocalSetting(SCHEDULE_PATH_KIND_FILTER_SETTING, selectedFilterKind)
|
||||
})
|
||||
$effect(() => {
|
||||
storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined)
|
||||
})
|
||||
|
||||
function filterItemsPathsBaseOnUserFilters(
|
||||
item: ScheduleW,
|
||||
selectedFilterKind: 'schedule' | 'script_flow',
|
||||
filterUserFolders: boolean
|
||||
userFoldersOnly: boolean
|
||||
) {
|
||||
if ($workspaceStore == 'admins') return true
|
||||
if (filterUserFolders) {
|
||||
if (userFoldersOnly) {
|
||||
if (selectedFilterKind === 'schedule') {
|
||||
return (
|
||||
!item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/')
|
||||
@@ -169,95 +221,21 @@
|
||||
if (filterEnabledDisabled === 'disabled') return !item.enabled
|
||||
}
|
||||
|
||||
let preFilteredItems = $derived.by(() => {
|
||||
return ownerFilter != undefined
|
||||
? selectedFilterKind === 'schedule'
|
||||
? schedules?.filter(
|
||||
(x) =>
|
||||
x.path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
: schedules?.filter(
|
||||
(x) =>
|
||||
x.script_path.startsWith(ownerFilter + '/') &&
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
: schedules?.filter(
|
||||
(x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
// Filter schedules client-side for enabled/disabled and user folders
|
||||
let filteredItems = $derived.by(() => {
|
||||
return schedules?.filter(
|
||||
(x) =>
|
||||
filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, !!filters.val.user_folders_only) &&
|
||||
filterItemsBasedOnEnabledDisabled(x, filterEnabledDisabled)
|
||||
)
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let owners = $derived(
|
||||
selectedFilterKind === 'schedule'
|
||||
? Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
: Array.from(
|
||||
new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
$effect(() => {
|
||||
items = filter !== '' ? filteredItems : preFilteredItems
|
||||
})
|
||||
|
||||
function updateQueryFilters(selectedFilterKind, filterUserFolders, filterEnabledDisabled) {
|
||||
setQuery(new URL(window.location.href), 'filter_kind', selectedFilterKind).then(() => {
|
||||
setQuery(
|
||||
new URL(window.location.href),
|
||||
'user_and_folders_only',
|
||||
String(filterUserFolders)
|
||||
).then(() => {
|
||||
setQuery(new URL(window.location.href), 'status', filterEnabledDisabled)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function loadQueryFilters() {
|
||||
let url = new URL(window.location.href)
|
||||
let queryFilterKind = url.searchParams.get('filter_kind')
|
||||
let queryFilterUserFolders = url.searchParams.get('user_and_folders_only')
|
||||
let queryFilterEnabledDisabled = url.searchParams.get('status')
|
||||
if (queryFilterKind) {
|
||||
selectedFilterKind = queryFilterKind as 'schedule' | 'script_flow'
|
||||
}
|
||||
if (queryFilterUserFolders) {
|
||||
filterUserFolders = queryFilterUserFolders == 'true'
|
||||
}
|
||||
if (queryFilterEnabledDisabled) {
|
||||
filterEnabledDisabled = queryFilterEnabledDisabled as 'all' | 'enabled' | 'disabled'
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadQueryFilters()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
updateQueryFilters(selectedFilterKind, filterUserFolders, filterEnabledDisabled)
|
||||
})
|
||||
let items = $derived(filteredItems)
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
<ScheduleEditor onUpdate={loadSchedules} bind:this={scheduleEditor} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.schedules}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
@@ -283,19 +261,9 @@
|
||||
</PageHeader>
|
||||
<div class="w-full h-full flex flex-col">
|
||||
<div class="w-full pb-4 pt-6">
|
||||
<input type="text" placeholder="Search schedule" bind:value={filter} class="search-item" />
|
||||
<div class="flex flex-row items-center gap-2 mt-2">
|
||||
<div class="text-xs font-semibold text-emphasis shrink-0"> Filter by path of </div>
|
||||
<ToggleButtonGroup bind:selected={selectedFilterKind}>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="schedule" label="Schedule" icon={Calendar} {item} />
|
||||
<ToggleButton value="script_flow" label="Script/Flow" icon={Code} {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<ListFilters syncQuery bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
<FilterSearchbar schema={schedulesFilterSchema} bind:value={filters.val} />
|
||||
|
||||
<div class="flex flex-row items-center justify-end gap-4">
|
||||
<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} />
|
||||
@@ -303,15 +271,6 @@
|
||||
<ToggleButton small value="disabled" label="Disabled" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loading}
|
||||
@@ -322,7 +281,7 @@
|
||||
<div class="text-center text-xs font-semibold text-emphasis mt-2"> No schedules </div>
|
||||
{:else if items?.length}
|
||||
<div class="border rounded-md divide-y">
|
||||
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, marked, jobs, paused_until } (path)}
|
||||
{#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until } (path)}
|
||||
{@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`}
|
||||
{@const avg_s = jobs
|
||||
? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length
|
||||
@@ -343,13 +302,7 @@
|
||||
<div
|
||||
class="text-emphasis flex-wrap text-left text-xs font-semibold mb-1 truncate"
|
||||
>
|
||||
{#if marked}
|
||||
<span class="text-xs">
|
||||
{@html marked}
|
||||
</span>
|
||||
{:else}
|
||||
{summary || script_path}
|
||||
{/if}
|
||||
{summary || script_path}
|
||||
</div>
|
||||
<div class="text-secondary text-xs truncate text-left">
|
||||
schedule: {path}
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
import ContextualVariableEditor from '$lib/components/ContextualVariableEditor.svelte'
|
||||
import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte'
|
||||
import Dropdown from '$lib/components/DropdownV2.svelte'
|
||||
import ListFilters from '$lib/components/home/ListFilters.svelte'
|
||||
import NoDirectDeployAlert from '$lib/components/NoDirectDeployAlert.svelte'
|
||||
import PageHeader from '$lib/components/PageHeader.svelte'
|
||||
import Popover from '$lib/components/Popover.svelte'
|
||||
import SearchItems from '$lib/components/SearchItems.svelte'
|
||||
import FilterSearchbar, {
|
||||
useUrlSyncedFilterInstance
|
||||
} from '$lib/components/FilterSearchbar.svelte'
|
||||
import { buildVariablesFilterSchema } from '$lib/components/variables/variablesFilter'
|
||||
import SharedBadge from '$lib/components/SharedBadge.svelte'
|
||||
import ShareModal from '$lib/components/ShareModal.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
@@ -17,7 +19,6 @@
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import TableSimple from '$lib/components/TableSimple.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import VariableEditor from '$lib/components/VariableEditor.svelte'
|
||||
import type { ContextualVariable, ListableVariable, WorkspaceDeployUISettings } from '$lib/gen'
|
||||
@@ -43,11 +44,31 @@
|
||||
|
||||
type ListableVariableW = ListableVariable & { canWrite: boolean }
|
||||
|
||||
let filter = $state('')
|
||||
let variables = $state(undefined) as ListableVariableW[] | undefined
|
||||
let filteredItems = $state(undefined) as (ListableVariableW & { marked?: string })[] | undefined
|
||||
|
||||
let showCreateButtons = $state(false)
|
||||
|
||||
// Collect unique values for filter autocomplete
|
||||
let allPaths: string[] = $state([])
|
||||
let allOwners: string[] = $state([])
|
||||
|
||||
// FilterSearchbar setup
|
||||
let userFoldersFilterType = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
let variablesFilterSchema = $derived(
|
||||
buildVariablesFilterSchema({
|
||||
paths: allPaths,
|
||||
owners: allOwners,
|
||||
showUserFoldersFilter: userFoldersFilterType !== undefined,
|
||||
userFoldersLabel:
|
||||
userFoldersFilterType === 'only f/*' ? 'Only f/*' : `Only u/${$userStore?.username} and f/*`
|
||||
})
|
||||
)
|
||||
let filters = useUrlSyncedFilterInstance(untrack(() => variablesFilterSchema))
|
||||
let contextualVariables: ContextualVariable[] = $state([])
|
||||
let shareModal: ShareModal | undefined = $state()
|
||||
let variableEditor: VariableEditor | undefined = $state()
|
||||
@@ -59,45 +80,69 @@
|
||||
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
|
||||
let open = $derived(Boolean(deleteConfirmedCallback))
|
||||
|
||||
let owners = $derived(
|
||||
Array.from(
|
||||
new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? [])
|
||||
).sort()
|
||||
)
|
||||
|
||||
let ownerFilter: string | undefined = $state(undefined)
|
||||
|
||||
$effect(() => {
|
||||
if ($workspaceStore) {
|
||||
ownerFilter = undefined
|
||||
}
|
||||
})
|
||||
|
||||
let preFilteredItems = $derived.by(() => {
|
||||
let l =
|
||||
ownerFilter == undefined
|
||||
? variables
|
||||
: variables?.filter((x) => x.path.startsWith(ownerFilter ?? ''))
|
||||
if (filterUserFolders) {
|
||||
l = l?.filter((item) => {
|
||||
if (filterUserFoldersType === 'only f/*') return item.path.startsWith('f/')
|
||||
if (filterUserFoldersType === 'u/username and f/*')
|
||||
// Filter variables client-side for user folder filtering (admin feature)
|
||||
let filteredItems = $derived.by(() => {
|
||||
let items = variables
|
||||
if (filters.val.user_folders_only && items) {
|
||||
items = items.filter((item) => {
|
||||
if (userFoldersFilterType === 'only f/*') return item.path.startsWith('f/')
|
||||
if (userFoldersFilterType === 'u/username and f/*')
|
||||
return item.path.startsWith('f/') || item.path.startsWith(`u/${$userStore?.username}/`)
|
||||
return true
|
||||
})
|
||||
}
|
||||
return l
|
||||
return items
|
||||
})
|
||||
|
||||
// If relative, the dropdown is positioned relative to its button
|
||||
async function loadVariables(): Promise<void> {
|
||||
variables = (await VariableService.listVariable({ workspace: $workspaceStore! })).map((x) => {
|
||||
const currentFilters = filters.val
|
||||
|
||||
// Build API parameters from filters
|
||||
const apiParams: any = {
|
||||
workspace: $workspaceStore!
|
||||
}
|
||||
|
||||
if (currentFilters.path) {
|
||||
apiParams.path = currentFilters.path
|
||||
}
|
||||
if (currentFilters.path_start) {
|
||||
apiParams.pathStart = currentFilters.path_start
|
||||
}
|
||||
if (currentFilters.description) {
|
||||
apiParams.description = currentFilters.description
|
||||
}
|
||||
if (currentFilters.value) {
|
||||
apiParams.value = currentFilters.value
|
||||
}
|
||||
if (currentFilters.owner) {
|
||||
apiParams.pathStart = currentFilters.owner
|
||||
}
|
||||
|
||||
const result = (await VariableService.listVariable(apiParams)).map((x) => {
|
||||
return {
|
||||
canWrite: canWrite(x.path, x.extra_perms!, $userStore) && x.workspace_id == $workspaceStore,
|
||||
...x
|
||||
}
|
||||
})
|
||||
|
||||
// Extract unique values for autocomplete
|
||||
allPaths = Array.from(new Set(result.map((x) => x.path))).sort()
|
||||
allOwners = Array.from(
|
||||
new Set(result.map((x) => x.path.split('/').slice(0, 2).join('/')))
|
||||
).sort()
|
||||
|
||||
variables = result
|
||||
}
|
||||
|
||||
// Reload variables when filters change
|
||||
$effect(() => {
|
||||
filters.val
|
||||
if ($workspaceStore) {
|
||||
untrack(() => loadVariables())
|
||||
}
|
||||
})
|
||||
|
||||
let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined)
|
||||
|
||||
async function getDeployUiSettings() {
|
||||
@@ -154,26 +199,10 @@
|
||||
loadContextualVariables()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
let filterUserFolders = $state(false)
|
||||
let filterUserFoldersType: 'only f/*' | 'u/username and f/*' | undefined = $derived(
|
||||
$userStore?.is_super_admin && $userStore.username.includes('@')
|
||||
? 'only f/*'
|
||||
: $userStore?.is_admin || $userStore?.is_super_admin
|
||||
? 'u/username and f/*'
|
||||
: undefined
|
||||
)
|
||||
</script>
|
||||
|
||||
<DeployWorkspaceDrawer bind:this={deploymentDrawer} />
|
||||
|
||||
<SearchItems
|
||||
{filter}
|
||||
items={preFilteredItems}
|
||||
bind:filteredItems
|
||||
f={(x) => x.path + ' ' + x.description}
|
||||
/>
|
||||
|
||||
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables}
|
||||
<div class="bg-red-100 border-l-4 border-red-600 text-orange-700 p-4 m-4 mt-12" role="alert">
|
||||
<p class="font-bold">Unauthorized</p>
|
||||
@@ -210,7 +239,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</PageHeader>
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v} />
|
||||
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => (showCreateButtons = v)} />
|
||||
|
||||
<VariableEditor bind:this={variableEditor} on:create={loadVariables} />
|
||||
<ContextualVariableEditor
|
||||
@@ -238,24 +267,19 @@
|
||||
</Tab>
|
||||
</Tabs>
|
||||
{#if tab == 'workspace'}
|
||||
<div class="pt-2">
|
||||
<input placeholder="Search Variable" bind:value={filter} class="input mt-1" />
|
||||
</div>
|
||||
<div class="min-h-[56px]">
|
||||
<ListFilters bind:selectedFilter={ownerFilter} filters={owners} />
|
||||
</div>
|
||||
<div class="relative overflow-x-auto pb-40 pr-4">
|
||||
<div class="flex flex-row items-center justify-end gap-4 pb-2">
|
||||
{#if $userStore?.is_super_admin && $userStore.username.includes('@')}
|
||||
<Toggle size="xs" bind:checked={filterUserFolders} options={{ right: 'Only f/*' }} />
|
||||
{:else if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={filterUserFolders}
|
||||
options={{ right: `Only u/${$userStore.username} and f/*` }}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<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">
|
||||
{#if !filteredItems}
|
||||
<Skeleton layout={[0.5, [2], 1]} />
|
||||
{#each new Array(3) as _}
|
||||
@@ -281,7 +305,7 @@
|
||||
</tr>
|
||||
</Head>
|
||||
<tbody class="divide-y">
|
||||
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, marked }}
|
||||
{#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked }}
|
||||
<Row>
|
||||
<Cell class="!px-0 text-center w-12" first>
|
||||
<SharedBadge {canWrite} extraPerms={extra_perms} />
|
||||
@@ -293,11 +317,7 @@
|
||||
onclick={() => variableEditor?.editVariable(path)}
|
||||
href="#{path}"
|
||||
>
|
||||
{#if marked}
|
||||
{@html marked}
|
||||
{:else}
|
||||
{path}
|
||||
{/if}
|
||||
{path}
|
||||
</a>
|
||||
</Cell>
|
||||
<Cell>
|
||||
|
||||
Reference in New Issue
Block a user