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:
Diego Imbert
2026-02-23 17:53:09 +00:00
committed by GitHub
co-authored by claude[bot]
parent 6ba0da3ee5
commit 9b28c85469
51 changed files with 5536 additions and 3040 deletions
+90 -1
View File
@@ -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'`
+34 -4
View File
@@ -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));
}
+2
View File
@@ -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))
}
}
+246 -83
View File
@@ -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", &quoted));
} else {
sqlb.and_where_eq("v2_job_queue.worker", "?".bind(w));
sqlb.and_where_in("v2_job_queue.worker", &quoted);
}
}
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", &quoted));
} else {
sqlb.and_where_in("runnable_path", &quoted);
}
}
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", &quoted);
} else {
sqlb.and_where_in("created_by", &quoted);
}
}
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", &quoted);
} else {
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
sqlb.and_where_in("v2_job.tag", &quoted);
}
}
@@ -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", &quoted);
} else {
sqlb.and_where_in("kind", &quoted);
}
}
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", &quoted));
} else {
sqlb.and_where_in("trigger_kind", &quoted);
}
}
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", &quoted));
} else {
sqlb.and_where_in("trigger", &quoted);
}
}
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", &quoted));
} else {
sqlb.and_where_eq("v2_job_completed.worker", "?".bind(worker));
sqlb.and_where_in("v2_job_completed.worker", &quoted);
}
}
@@ -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", &quoted));
} else {
sqlb.and_where_in("runnable_path", &quoted);
}
}
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", &quoted);
} else {
sqlb.and_where_eq("v2_job.tag", "?".bind(t));
sqlb.and_where_in("v2_job.tag", &quoted);
}
}
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", &quoted);
} else {
sqlb.and_where_in("created_by", &quoted);
}
}
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", &quoted);
} else {
sqlb.and_where_in("kind", &quoted);
}
}
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", &quoted));
} else {
sqlb.and_where_in("trigger_kind", &quoted);
}
}
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", &quoted));
} else {
sqlb.and_where_in("trigger", &quoted);
}
}
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,
+47 -37
View File
@@ -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]
+21 -2
View File
@@ -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)
+66 -13
View File
@@ -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
+19 -3
View File
@@ -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)
+72 -25
View File
@@ -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 -26
View File
@@ -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>
+3 -1
View File
@@ -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>
+20 -25
View File
@@ -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
+332 -134
View File
@@ -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
}
+184
View File
@@ -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
}
}
}
+109 -23
View File
@@ -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>
}
+9 -3
View File
@@ -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
View File
@@ -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, '&amp;')
@@ -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>