Merge branch 'main' into di/assets

This commit is contained in:
Diego Imbert
2025-07-07 15:28:54 +02:00
102 changed files with 2694 additions and 1317 deletions
+34 -15
View File
@@ -1,22 +1,41 @@
name: Auto Comment on PR Ready for Review
name: Claude Auto Review
on:
pull_request:
types: [opened, ready_for_review]
types: [ready_for_review, opened]
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
add-review-comment:
if: github.event.pull_request.draft == false
runs-on: ubicloud-standard-2
auto-review:
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true
permissions:
contents: read
pull-requests: read
id-token: write
steps:
- name: Add review comment
uses: actions/github-script@v7
- name: Checkout repository
uses: actions/checkout@v4
with:
github-token: ${{ secrets.PUBLIC_REPO_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '/ai review this PR'
});
fetch-depth: 1
- name: Automatic PR Review
uses: anthropics/claude-code-action@beta
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
timeout_minutes: "60"
direct_prompt: |
Please review this pull request and provide comprehensive feedback.
Focus on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security implications
Provide constructive feedback with specific suggestions for improvement.
Use inline comments to highlight specific areas of concern.
allowed_tools: "mcp__github__create_pending_pull_request_review,mcp__github__add_pull_request_review_comment_to_pending_review,mcp__github__submit_pending_pull_request_review,mcp__github__get_pull_request_diff"
+1 -1
View File
@@ -1 +1 @@
b7c6fc065a3da98933d50fae697784da61c3fe2d
21aabec96e91c8075dd637d1e32af90e495082fc
@@ -0,0 +1,3 @@
-- Remove email and span columns from audit table
ALTER TABLE audit DROP COLUMN email;
ALTER TABLE audit DROP COLUMN span;
@@ -0,0 +1,3 @@
-- Add email and span columns to audit table
ALTER TABLE audit ADD COLUMN email VARCHAR(255);
ALTER TABLE audit ADD COLUMN span VARCHAR(255);
+1
View File
@@ -1926,6 +1926,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, worker
&job.email,
&job.id,
None,
Some(format!("handle_zombie_jobs")),
)
.await
.expect("could not create job token");
+4 -2
View File
@@ -322,7 +322,7 @@ mod suspend_resume {
let second = completed.next().await.unwrap();
// print_job(second, &db).await;
let token = windmill_common::auth::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil(), None).await.unwrap();
let token = windmill_common::auth::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil(), None, None).await.unwrap();
let secret = reqwest::get(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{second}/0?token={token}&approver=ruben"
))
@@ -427,7 +427,7 @@ mod suspend_resume {
/* ... and send a request resume it. */
let second = completed.next().await.unwrap();
let token = windmill_common::auth::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil(), None).await.unwrap();
let token = windmill_common::auth::create_token_for_owner(&db, "test-workspace", "u/test-user", "", 100, "", &Uuid::nil(), None, None).await.unwrap();
let secret = reqwest::get(format!(
"http://localhost:{port}/api/w/test-workspace/jobs/job_signature/{second}/0?token={token}"
))
@@ -935,6 +935,7 @@ impl RunJob {
/* user */ "test-user",
/* email */ "test@windmill.dev",
/* permissioned_as */ "u/test-user".to_string(),
/* token_prefix */ None,
/* scheduled_for_o */ None,
/* schedule_path */ None,
/* parent_job */ None,
@@ -4215,6 +4216,7 @@ async fn test_result_format(db: Pool<Postgres>) {
"",
&Uuid::nil(),
None,
None,
)
.await
.unwrap();
+2
View File
@@ -14402,6 +14402,8 @@ components:
type: string
parameters:
type: object
span:
type: string
required:
- id
- timestamp
+4
View File
@@ -4,6 +4,7 @@ use uuid::Uuid;
use std::str::FromStr;
use regex::Regex;
use serde_json::Value;
use crate::auth::OptTokened;
use crate::db::{ApiAuthed, DB};
use crate::jobs::{cancel_suspended_job, resume_suspended_job, QueryApprover, QueryOrBody, ResumeUrls, get_resume_urls_internal};
use axum::{extract::{Path, Query}, Extension};
@@ -101,6 +102,7 @@ pub fn extract_w_id_from_resume_url(resume_url: &str) -> Result<&str, Error> {
pub async fn handle_resume_action(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
resume_url: &str,
form_data: Value,
@@ -135,6 +137,7 @@ pub async fn handle_resume_action(
let res = if action == "resume" {
resume_suspended_job(
authed,
opt_tokened,
Extension(db.clone()),
Path((
w_id.to_string(),
@@ -149,6 +152,7 @@ pub async fn handle_resume_action(
} else {
cancel_suspended_job(
authed,
opt_tokened,
Extension(db.clone()),
Path((
w_id.to_string(),
+9
View File
@@ -9,6 +9,7 @@ use std::{collections::HashMap, sync::Arc};
*/
use crate::{
auth::OptTokened,
db::{ApiAuthed, DB},
resources::get_resource_value_interpolated_internal,
users::{require_owner_of_path, OptAuthed},
@@ -52,6 +53,7 @@ use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
apps::{AppScriptId, ListAppQuery},
auth::TOKEN_PREFIX_LEN,
cache::{self, future::FutureCachedExt},
db::UserDB,
error::{to_anyhow, Error, JsonResult, Result},
@@ -1040,6 +1042,7 @@ async fn create_app_internal<'a>(
&authed.username,
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
@@ -1411,6 +1414,7 @@ async fn update_app_internal<'a>(
&authed.username,
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
@@ -1517,6 +1521,7 @@ fn empty_triggerables(mut policy: Policy) -> Policy {
async fn execute_component(
OptAuthed(opt_authed): OptAuthed,
tokened: OptTokened,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, path)): Path<(String, StripPath)>,
@@ -1719,6 +1724,10 @@ async fn execute_component(
&username,
email,
permissioned_as,
opt_authed
.and_then(|a| a.token_prefix)
.or_else(|| tokened.token.map(|t| t[0..TOKEN_PREFIX_LEN].to_string()))
.as_deref(),
None,
None,
None,
+9 -1
View File
@@ -21,7 +21,7 @@ use std::sync::{
use tokio::sync::RwLock;
use windmill_common::{
auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims},
auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims, TOKEN_PREFIX_LEN},
jwt,
users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL},
};
@@ -134,6 +134,7 @@ impl AuthCache {
folders: claims.folders,
scopes: None,
username_override,
token_prefix: claims.audit_span,
};
AUTH_CACHE.insert(
@@ -217,6 +218,7 @@ impl AuthCache {
folders,
scopes: None,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
} else {
let groups = vec![name.to_string()];
@@ -238,6 +240,7 @@ impl AuthCache {
folders,
scopes: None,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
}
} else {
@@ -252,6 +255,7 @@ impl AuthCache {
folders,
scopes: None,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
}
}
@@ -299,6 +303,7 @@ impl AuthCache {
folders,
scopes,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
}
None if super_admin => Some(ApiAuthed {
@@ -310,6 +315,7 @@ impl AuthCache {
folders: vec![],
scopes,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
}),
None => None,
}
@@ -323,6 +329,7 @@ impl AuthCache {
folders: Vec::new(),
scopes,
username_override,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
}
}
@@ -355,6 +362,7 @@ impl AuthCache {
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
})
} else {
None
+6
View File
@@ -836,6 +836,7 @@ pub struct ApiAuthed {
pub folders: Vec<(String, bool, bool)>,
pub scopes: Option<Vec<String>>,
pub username_override: Option<String>,
pub token_prefix: Option<String>,
}
impl From<ApiAuthed> for Authed {
@@ -848,6 +849,7 @@ impl From<ApiAuthed> for Authed {
groups: value.groups,
folders: value.folders,
scopes: value.scopes,
token_prefix: value.token_prefix,
}
}
}
@@ -858,6 +860,7 @@ impl From<&ApiAuthed> for AuditAuthor {
email: value.email.clone(),
username: value.username.clone(),
username_override: value.username_override.clone(),
token_prefix: value.token_prefix.clone(),
}
}
}
@@ -878,6 +881,9 @@ impl AuditAuthorable for ApiAuthed {
fn username_override(&self) -> Option<&str> {
self.username_override.as_deref()
}
fn token_prefix(&self) -> Option<&str> {
self.token_prefix.as_deref()
}
}
impl Authable for ApiAuthed {
+2
View File
@@ -494,6 +494,7 @@ async fn create_flow(
&authed.username,
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
@@ -935,6 +936,7 @@ async fn update_flow(
&authed.username,
&authed.email,
windmill_common::users::username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
+67 -16
View File
@@ -22,7 +22,7 @@ use std::ops::{Deref, DerefMut};
use std::str::FromStr;
use tokio::io::AsyncReadExt;
use tower::ServiceBuilder;
use windmill_common::auth::is_super_admin_email;
use windmill_common::auth::{is_super_admin_email, TOKEN_PREFIX_LEN};
use windmill_common::error::JsonResult;
use windmill_common::flow_status::{JobResult, RestartedFrom};
use windmill_common::jobs::{format_completed_job_result, format_result, ENTRYPOINT_OVERRIDE};
@@ -33,6 +33,7 @@ use windmill_common::scripts::PREVIEW_IS_CODEBASE_HASH;
use windmill_common::variables::get_workspace_key;
use crate::add_webhook_allowed_origin;
use crate::auth::{OptTokened, Tokened};
use crate::concurrency_groups::join_concurrency_key;
use crate::db::ApiAuthed;
@@ -293,6 +294,7 @@ struct JsonPath {
}
async fn get_result_by_id(
authed: ApiAuthed,
tokened: Tokened,
Extension(db): Extension<DB>,
Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
@@ -301,7 +303,7 @@ async fn get_result_by_id(
windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path)
.await?;
log_job_view(&db, Some(&authed), &w_id, &flow_id).await?;
log_job_view(&db, Some(&authed), Some(&tokened.token), &w_id, &flow_id).await?;
Ok(Json(res))
}
@@ -337,6 +339,7 @@ async fn get_db_clock(Extension(db): Extension<DB>) -> windmill_common::error::J
async fn cancel_job_api(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
@@ -349,6 +352,7 @@ async fn cancel_job_api(
username: "anonymous".to_string(),
username_override: None,
email: "anonymous".to_string(),
token_prefix: opt_tokened.token.map(|s| s[0..TOKEN_PREFIX_LEN].to_string()),
},
};
let (mut tx, job_option) = tokio::time::timeout(
@@ -447,6 +451,7 @@ async fn cancel_persistent_script_api(
async fn force_cancel(
OptAuthed(opt_authed): OptAuthed,
tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Json(CancelJob { reason }): Json<CancelJob>,
@@ -459,6 +464,7 @@ async fn force_cancel(
username: "anonymous".to_string(),
username_override: None,
email: "anonymous".to_string(),
token_prefix: tokened.token.map(|t| t[0..TOKEN_PREFIX_LEN].to_string()),
},
};
@@ -510,6 +516,7 @@ async fn force_cancel(
async fn get_flow_job_debug_info(
OptAuthed(opt_authed): OptAuthed,
tokened_o: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
@@ -561,7 +568,7 @@ async fn get_flow_job_debug_info(
}
}
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), tokened_o.token.as_deref(), &w_id, &id).await?;
Ok(Json(jobs).into_response())
} else {
@@ -621,6 +628,7 @@ struct GetJobQuery {
async fn get_job(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetJobQuery { no_logs }): Query<GetJobQuery>,
@@ -640,7 +648,7 @@ async fn get_job(
let mut job = get.fetch(&db, id, &w_id).await?;
job.fetch_outstanding_wait_time(&db).await?;
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
Ok(Json(job).into_response())
}
@@ -1088,6 +1096,7 @@ async fn get_logs_from_disk(
async fn get_job_logs(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
@@ -1125,7 +1134,7 @@ async fn get_job_logs(
}
let logs = record.logs.unwrap_or_default();
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(r) = get_logs_from_store(record.log_offset, &logs, &record.log_file_index).await
@@ -1162,7 +1171,7 @@ async fn get_job_logs(
}
let logs = text.logs.unwrap_or_default();
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(r) =
@@ -1186,6 +1195,7 @@ async fn get_job_logs(
async fn get_args(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> JsonResult<Box<RawValue>> {
@@ -1211,7 +1221,7 @@ async fn get_args(
));
}
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
Ok(Json(record.args.map(|x| x.0).unwrap_or_default()))
} else {
@@ -1232,7 +1242,7 @@ async fn get_args(
));
}
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
Ok(Json(record.args.map(|x| x.0).unwrap_or_default()))
}
@@ -1993,13 +2003,23 @@ pub async fn resume_suspended_flow_as_owner(
pub async fn resume_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
resume_suspended_job_internal(
value, db, w_id, job_id, resume_id, approver, secret, authed, true,
value,
db,
w_id,
job_id,
resume_id,
approver,
secret,
authed,
opt_tokened,
true,
)
.await
}
@@ -2013,6 +2033,7 @@ async fn resume_suspended_job_internal(
approver: QueryApprover,
secret: String,
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
approved: bool,
) -> Result<StatusCode, Error> {
let value = value.unwrap_or(serde_json::Value::Null);
@@ -2091,6 +2112,7 @@ async fn resume_suspended_job_internal(
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: opt_tokened.token.map(|s| s[0..TOKEN_PREFIX_LEN].to_string()),
},
};
audit_log(
@@ -2260,13 +2282,14 @@ async fn get_suspended_flow_info<'c>(
pub async fn cancel_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job_id, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
QueryOrBody(value): QueryOrBody<serde_json::Value>,
) -> error::Result<StatusCode> {
resume_suspended_job_internal(
value, db, w_id, job_id, resume_id, approver, secret, authed, false,
value, db, w_id, job_id, resume_id, approver, secret, authed, opt_tokened, false,
)
.await
}
@@ -2284,6 +2307,7 @@ pub struct QueryApprover {
pub async fn get_suspended_job_flow(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job, resume_id, secret)): Path<(String, Uuid, u32, String)>,
Query(approver): Query<QueryApprover>,
@@ -2350,7 +2374,7 @@ pub async fn get_suspended_job_flow(
approvers_from_status
};
log_job_view(&db, authed.as_ref(), &w_id, &job).await?;
log_job_view(&db, authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &job).await?;
Ok(Json(SuspendedJobFlow { job: flow, approvers }).into_response())
}
@@ -3507,6 +3531,7 @@ pub async fn run_flow_by_path_inner(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
@@ -3600,6 +3625,7 @@ pub async fn restart_flow(
&authed.username,
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
@@ -3692,6 +3718,7 @@ pub async fn run_script_by_path_inner(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
@@ -3837,6 +3864,7 @@ pub async fn run_workflow_as_code(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
Some(job_id),
@@ -4252,6 +4280,7 @@ impl JobViewCache {
async fn log_job_view(
db: &DB,
opt_authed: Option<&ApiAuthed>,
opt_token: Option<&str>,
w_id: &str,
job_id: &Uuid,
) -> error::Result<()> {
@@ -4262,6 +4291,7 @@ async fn log_job_view(
username: "anonymous".to_string(),
username_override: None,
email: "anonymous".to_string(),
token_prefix: opt_token.map(|t| t[0..TOKEN_PREFIX_LEN].to_string())
},
};
if JOB_VIEW_CACHE
@@ -4360,6 +4390,7 @@ pub async fn run_wait_result_job_by_path_get(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
None,
None,
run_query.parent_job,
@@ -4500,6 +4531,7 @@ pub async fn run_wait_result_script_by_path_internal(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
None,
None,
run_query.parent_job,
@@ -4613,6 +4645,7 @@ pub async fn run_wait_result_script_by_hash(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
None,
None,
run_query.parent_job,
@@ -4727,6 +4760,7 @@ pub async fn run_wait_result_flow_by_path_internal(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
@@ -4797,6 +4831,7 @@ async fn run_preview_script(
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
scheduled_for,
None,
None,
@@ -4885,6 +4920,7 @@ async fn run_bundle_preview_script(
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
scheduled_for,
None,
None,
@@ -5052,6 +5088,7 @@ async fn run_dependencies_job(
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
@@ -5109,6 +5146,7 @@ async fn run_flow_dependencies_job(
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
None,
None,
None,
@@ -5449,6 +5487,7 @@ async fn run_preview_flow_job(
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
authed.token_prefix.as_deref(),
scheduled_for,
None,
None,
@@ -5568,6 +5607,7 @@ pub async fn run_job_by_hash_inner(
authed.display_username(),
email,
permissioned_as,
authed.token_prefix.as_deref(),
scheduled_for,
None,
run_query.parent_job,
@@ -5660,6 +5700,7 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R
async fn get_job_update(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(JobUpdateQuery { log_offset, get_progress, running }): Query<JobUpdateQuery>,
@@ -5716,7 +5757,7 @@ async fn get_job_update(
"As a non logged in user, you can only see jobs ran by anonymous users".to_string(),
));
}
log_job_view(&db, opt_authed.as_ref(), &w_id, &job_id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &job_id).await?;
Ok(Json(JobUpdate {
running: record.running,
completed: record.completed,
@@ -6004,6 +6045,7 @@ async fn list_completed_jobs(
async fn get_completed_job<'a>(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
) -> error::Result<Response> {
@@ -6029,7 +6071,7 @@ async fn get_completed_job<'a>(
// .fetch_optional(db)
// .await.ok().flatten().flatten();
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
Ok(response)
}
@@ -6043,6 +6085,7 @@ pub struct RawResult {
async fn get_completed_job_result(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query<JsonPath>,
@@ -6131,7 +6174,7 @@ async fn get_completed_job_result(
raw_result.result.as_mut(),
);
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
Ok(Json(raw_result.result).into_response())
}
@@ -6189,6 +6232,7 @@ struct GetCompletedJobQuery {
async fn get_completed_job_result_maybe(
OptAuthed(opt_authed): OptAuthed,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
Query(GetCompletedJobQuery { get_started }): Query<GetCompletedJobQuery>,
@@ -6221,7 +6265,7 @@ async fn get_completed_job_result_maybe(
));
}
log_job_view(&db, opt_authed.as_ref(), &w_id, &id).await?;
log_job_view(&db, opt_authed.as_ref(), opt_tokened.token.as_deref(), &w_id, &id).await?;
Ok(Json(CompletedJobResult {
started: Some(true),
@@ -6259,6 +6303,7 @@ async fn get_completed_job_result_maybe(
async fn delete_completed_job<'a>(
authed: ApiAuthed,
Tokened { token }: Tokened,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, Uuid)>,
@@ -6308,5 +6353,11 @@ async fn delete_completed_job<'a>(
.await?;
tx.commit().await?;
return get_completed_job(OptAuthed(Some(authed)), Extension(db), Path((w_id, id))).await;
return get_completed_job(
OptAuthed(Some(authed)),
OptTokened { token: Some(token) },
Extension(db),
Path((w_id, id)),
)
.await;
}
+1
View File
@@ -508,6 +508,7 @@ pub async fn transform_json_value<'c>(
email: "backend".to_string(),
username: "backend".to_string(),
username_override: None,
token_prefix: None,
}),
)
.await?;
+2 -9
View File
@@ -7,10 +7,7 @@
*/
use crate::{
db::{ApiAuthed, DB},
settings::{delete_global_setting, set_global_setting_internal},
users::maybe_refresh_folders,
utils::require_super_admin,
db::{ApiAuthed, DB}, settings::{delete_global_setting, set_global_setting_internal}, users::maybe_refresh_folders, utils::require_super_admin
};
use axum::{
extract::{Extension, Path, Query},
@@ -25,11 +22,7 @@ use std::str::FromStr;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
schedule::Schedule,
utils::{not_found_if_none, paginate, Pagination, ScheduleType, StripPath},
worker::to_raw_value,
db::UserDB, error::{Error, JsonResult, Result}, schedule::Schedule, utils::{not_found_if_none, paginate, Pagination, ScheduleType, StripPath}, worker::to_raw_value
};
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
use windmill_queue::schedule::push_scheduled_job;
+1
View File
@@ -958,6 +958,7 @@ async fn create_script_internal<'c>(
&authed.username,
&authed.email,
permissioned_as,
authed.token_prefix.as_deref(),
None,
None,
None,
+7 -5
View File
@@ -11,11 +11,11 @@ use std::collections::HashMap;
use windmill_common::error::Error;
use windmill_common::variables::get_secret_value_as_admin;
use crate::approvals::{
use crate::{approvals::{
extract_w_id_from_resume_url, handle_resume_action, ApprovalFormDetails, FieldType,
MessageFormat, QueryDefaultArgsJson, QueryDynamicEnumJson, QueryFlowStepId, QueryMessage,
ResumeFormField, ResumeSchema,
};
}, auth::OptTokened};
use crate::db::{ApiAuthed, DB};
use crate::jobs::{QueryApprover, ResumeUrls};
@@ -116,6 +116,7 @@ struct PrivateMetadata {
pub async fn slack_app_callback_handler(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
Extension(db): Extension<DB>,
Form(form_data): Form<SlackFormData>,
) -> Result<StatusCode, Error> {
@@ -124,8 +125,8 @@ pub async fn slack_app_callback_handler(
tracing::debug!("Payload: {:#?}", payload);
match payload.r#type {
PayloadType::ViewSubmission => handle_submission(authed, db, &payload, "resume").await?,
PayloadType::ViewClosed => handle_submission(authed, db, &payload, "cancel").await?,
PayloadType::ViewSubmission => handle_submission(authed, opt_tokened, db, &payload, "resume").await?,
PayloadType::ViewClosed => handle_submission(authed, opt_tokened, db, &payload, "cancel").await?,
_ => {
if let Some(actions) = &payload.actions {
if let Some(action) = actions.first() {
@@ -256,6 +257,7 @@ pub async fn request_slack_approval(
async fn handle_submission(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
payload: &Payload,
action: &str,
@@ -294,7 +296,7 @@ async fn handle_submission(
}
// Use the common handler to process the resume/cancel action
handle_resume_action(authed, db.clone(), &resume_url, state_json, action).await?;
handle_resume_action(authed, opt_tokened, db.clone(), &resume_url, state_json, action).await?;
let w_id = extract_w_id_from_resume_url(&resume_url)?;
let slack_token = get_slack_token(&db, &resource_path, w_id).await?;
+28 -6
View File
@@ -44,7 +44,7 @@ use tower_cookies::{Cookie, Cookies};
use tracing::Instrument;
use windmill_audit::audit_oss::{audit_log, AuditAuthor};
use windmill_audit::ActionKind;
use windmill_common::auth::fetch_authed_from_permissioned_as;
use windmill_common::auth::{fetch_authed_from_permissioned_as, TOKEN_PREFIX_LEN};
use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING;
use windmill_common::oauth2::InstanceEvent;
use windmill_common::users::COOKIE_NAME;
@@ -243,13 +243,14 @@ pub async fn fetch_api_authed_from_permissioned_as(
let api_authed = ApiAuthed {
username: authed.username,
email: email,
email,
is_admin: authed.is_admin,
is_operator: authed.is_operator,
groups: authed.groups,
folders: authed.folders,
scopes: authed.scopes,
username_override: None,
token_prefix: authed.token_prefix,
};
API_AUTHED_CACHE.insert(
@@ -690,7 +691,12 @@ async fn logout(
};
audit_log(
&mut *tx,
&AuditAuthor { email: email.clone(), username: email, username_override: None },
&AuditAuthor {
email: email.clone(),
username: email,
username_override: None,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
},
audit_message,
ActionKind::Delete,
"global",
@@ -1274,7 +1280,10 @@ async fn join_workspace<'c>(
Ok((tx, username))
}
async fn leave_instance(Extension(db): Extension<DB>, authed: ApiAuthed) -> Result<String> {
async fn leave_instance(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> Result<String> {
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM password WHERE email = $1", &authed.email)
.execute(&mut *tx)
@@ -1639,8 +1648,12 @@ async fn login(
) -> Result<String> {
let mut tx = db.begin().await?;
let email = email.to_lowercase();
let audit_author =
AuditAuthor { email: email.clone(), username: email.clone(), username_override: None };
let audit_author = AuditAuthor {
email: email.clone(),
username: email.clone(),
username_override: None,
token_prefix: None,
};
let email_w_h: Option<(String, String, bool, bool)> = sqlx::query_as(
"SELECT email, password_hash, super_admin, first_time_user FROM password WHERE email = $1 AND login_type = \
'password'",
@@ -1689,6 +1702,13 @@ async fn login(
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
let audit_author = AuditAuthor {
email: email.clone(),
username: email.clone(),
username_override: None,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
};
audit_log(
&mut *tx,
&audit_author,
@@ -1758,6 +1778,7 @@ async fn refresh_token(
email: authed.email.to_string(),
username: authed.email.to_string(),
username_override: None,
token_prefix: authed.token_prefix,
},
"users.token.refresh",
ActionKind::Create,
@@ -1798,6 +1819,7 @@ pub async fn create_session_token<'c>(
email: email.to_string(),
username: email.to_string(),
username_override: None,
token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()),
},
"users.token.invalidate_old_sessions",
ActionKind::Delete,
+2 -7
View File
@@ -7,9 +7,7 @@
*/
use crate::{
db::{ApiAuthed, DB},
users::{maybe_refresh_folders, require_owner_of_path},
webhook_util::{WebhookMessage, WebhookShared},
db::{ApiAuthed, DB}, users::{maybe_refresh_folders, require_owner_of_path}, webhook_util::{WebhookMessage, WebhookShared}
};
use axum::{
@@ -23,10 +21,7 @@ use serde_json::Value;
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult, Result},
utils::{not_found_if_none, paginate, Pagination, StripPath},
variables::{
db::UserDB, error::{Error, JsonResult, Result}, utils::{not_found_if_none, paginate, Pagination, StripPath}, variables::{
build_crypt, get_reserved_variables, ContextualVariable, CreateVariable, ListableVariable,
},
worker::CLOUD_HOSTED,
+16 -8
View File
@@ -20,14 +20,6 @@ use {
},
};
#[derive(Clone)]
#[cfg(not(feature = "private"))]
pub struct AuditAuthor {
pub username: String,
pub email: String,
pub username_override: Option<String>,
}
#[cfg(not(feature = "private"))]
impl AuditAuthorable for AuditAuthor {
fn email(&self) -> &str {
@@ -41,6 +33,10 @@ impl AuditAuthorable for AuditAuthor {
fn username_override(&self) -> Option<&str> {
self.username_override.as_deref()
}
fn token_prefix(&self) -> Option<&str> {
self.token_prefix.as_deref()
}
}
#[cfg(not(feature = "private"))]
@@ -48,6 +44,18 @@ pub trait AuditAuthorable {
fn username(&self) -> &str;
fn email(&self) -> &str;
fn username_override(&self) -> Option<&str>;
fn token_prefix(&self) -> Option<&str> {
None
}
}
#[derive(Clone)]
#[cfg(not(feature = "private"))]
pub struct AuditAuthor {
pub username: String,
pub email: String,
pub username_override: Option<String>,
pub token_prefix: Option<String>,
}
#[cfg(not(feature = "private"))]
+1
View File
@@ -24,6 +24,7 @@ pub struct AuditLog {
pub action_kind: ActionKind,
pub resource: Option<String>,
pub parameters: Option<serde_json::Value>,
pub span: Option<String>,
}
#[derive(Deserialize)]
+1 -1
View File
@@ -84,7 +84,7 @@ strum_macros.workspace = true
url.workspace = true
semver.workspace = true
croner = "2.0.6"
croner = "2.2.0"
quick_cache.workspace = true
pin-project-lite.workspace = true
futures.workspace = true
+12 -3
View File
@@ -17,6 +17,8 @@ pub struct IdToken {
expiration: DateTime<Utc>,
}
pub const TOKEN_PREFIX_LEN: usize = 10;
pub fn has_expired(expiration_time: DateTime<Utc>, take: Option<Duration>) -> bool {
let now = Utc::now();
@@ -66,6 +68,7 @@ pub struct JWTAuthClaims {
pub exp: usize,
pub job_id: Option<String>,
pub scopes: Option<Vec<String>>,
pub audit_span: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -92,6 +95,7 @@ impl From<JobPerms> for Authed {
.filter_map(|x| serde_json::from_value::<(String, bool, bool)>(x).ok())
.collect(),
scopes: None,
token_prefix: None,
}
}
}
@@ -171,38 +175,41 @@ pub async fn fetch_authed_from_permissioned_as(
let folders = get_folders_for_user(w_id, &name, &groups, db).await?;
Ok(Authed {
email: email,
email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
token_prefix: None,
})
} else {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(&w_id, "", &groups, db).await?;
Ok(Authed {
email: email,
email,
username: format!("group-{name}"),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
token_prefix: None,
})
}
} else {
let groups = vec![];
let folders = vec![];
Ok(Authed {
email: email,
email,
username: permissioned_as,
is_admin: super_admin,
is_operator: true,
groups,
folders,
scopes: None,
token_prefix: None,
})
}
}
@@ -262,6 +269,7 @@ pub async fn create_token_for_owner(
email: &str,
job_id: &Uuid,
perms: Option<JobPerms>,
audit_span: Option<String>,
) -> crate::error::Result<String> {
let job_perms = if perms.is_some() {
Ok(perms)
@@ -302,6 +310,7 @@ pub async fn create_token_for_owner(
as usize,
job_id: Some(job_id.to_string()),
scopes: None,
audit_span,
};
let token = jwt::encode_with_internal_secret(&payload)
+1
View File
@@ -12,6 +12,7 @@ pub struct Authed {
// (folder name, can write, is owner)
pub folders: Vec<(String, bool, bool)>,
pub scopes: Option<Vec<String>>,
pub token_prefix: Option<String>,
}
#[derive(Clone)]
+10 -1
View File
@@ -428,6 +428,7 @@ pub async fn push_init_job<'c>(
worker_name,
"worker@windmill.dev",
SUPERADMIN_SECRET_EMAIL.to_string(),
Some("worker_init_job"),
None,
None,
None,
@@ -1212,6 +1213,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&queued_job.created_by,
&queued_job.permissioned_as_email,
queued_job.permissioned_as.clone(),
Some(&format!("add.completed.job{}", queued_job.id)),
scheduled_for,
queued_job.schedule_path(),
None,
@@ -1735,6 +1737,7 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>(
},
email,
permissioned_as,
Some(&format!("error.handler.{job_id}")),
None,
None,
Some(job_id),
@@ -1842,6 +1845,7 @@ async fn handle_recovered_schedule<'a, 'c, T: Serialize + Send + Sync>(
SCHEDULE_RECOVERY_HANDLER_USERNAME,
email,
permissioned_as,
Some(&format!("recovered.schedule.{job_id}")),
None,
None,
Some(job_id),
@@ -1930,6 +1934,7 @@ async fn handle_successful_schedule<'a, 'c, T: Serialize + Send + Sync>(
SCHEDULE_RECOVERY_HANDLER_USERNAME,
email,
permissioned_as,
Some(&format!("successful.schedule.recovery{job_id}")),
None,
None,
Some(job_id),
@@ -2220,6 +2225,7 @@ pub async fn create_token(db: &DB, job: &MiniPulledJob, perms: Option<JobPerms>)
&job.permissioned_as_email,
&job.id,
perms,
Some(format!("job-span-{}", job.flow_innermost_root_job.unwrap_or(job.id))),
)
.warn_after_seconds(5)
.await
@@ -3257,6 +3263,7 @@ pub async fn push<'c, 'd>(
user: &str,
mut email: &str,
mut permissioned_as: String,
token_prefix: Option<&str>,
scheduled_for_o: Option<chrono::DateTime<chrono::Utc>>,
schedule_path: Option<String>,
parent_job: Option<Uuid>,
@@ -4372,12 +4379,14 @@ pub async fn push<'c, 'd>(
email: email.to_string(),
username: permissioned_as.trim_start_matches("u/").to_string(),
username_override: Some(user.to_string()),
token_prefix: token_prefix.map(|s| s.to_string()),
}
} else {
AuditAuthor {
email: email.to_string(),
username: user.to_string(),
username_override: None,
token_prefix: token_prefix.map(|s| s.to_string()),
}
};
@@ -4700,4 +4709,4 @@ pub async fn get_same_worker_job(
same_worker_job.job_id, e
))
})
}
}
+1
View File
@@ -271,6 +271,7 @@ pub async fn push_scheduled_job<'c>(
&schedule_to_user(&schedule.path),
email,
permissioned_as,
Some(&schedule.path),
Some(next),
Some(schedule.path.clone()),
None,
@@ -17,12 +17,7 @@ use windmill_common::otel_oss::FutureExt;
use uuid::Uuid;
use windmill_common::{
add_time,
error::{self, Error},
jobs::JobKind,
utils::WarnAfterExt,
worker::{to_raw_value, Connection, WORKER_GROUP},
KillpillSender, DB,
add_time, error::{self, Error}, jobs::JobKind, utils::WarnAfterExt, worker::{to_raw_value, Connection, WORKER_GROUP}, KillpillSender, DB
};
#[cfg(feature = "benchmark")]
@@ -17,6 +17,7 @@ use crate::worker_utils::get_tag_and_concurrency;
use crate::{
JobCompletedSender, PreviousResult, SameWorkerSender, SendResult, UpdateFlow, KEEP_JOB_DIR,
};
use anyhow::Context;
use futures::TryFutureExt;
use mappable_rc::Marc;
@@ -2030,6 +2031,7 @@ async fn push_next_flow_job(
.to_string(),
email: flow_job.permissioned_as_email.clone(),
username_override: None,
token_prefix: Some(format!("psh.nxt.flowjob-{}", client.token.to_string())),
};
if can_be_resumed || disapproved_or_timeout_but_continue {
@@ -2772,6 +2774,7 @@ async fn push_next_flow_job(
&flow_job.created_by,
email,
permissioned_as,
Some(&format!("job-span-{}", flow_job.flow_innermost_root_job.unwrap_or(flow_job.id))),
scheduled_for_o,
flow_job.schedule_path(),
Some(flow_job.id),
@@ -611,6 +611,7 @@ async fn trigger_dependents_to_recompute_dependencies(
&created_by,
email,
permissioned_as.to_string(),
Some("trigger.dependents.to.recompute.dependencies"),
None,
None,
None,
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.502.2",
"version": "1.502.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.502.2",
"version": "1.502.5",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -151,7 +151,7 @@
"fsevents": "^2.3.3"
},
"peerDependencies": {
"svelte": "^4.0.0"
"svelte": "^5.0.0"
}
},
"node_modules/@alloc/quick-lru": {
+42 -27
View File
@@ -5,7 +5,7 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"postinstall": "node scripts/untar_ui_builder.js && node scripts/patch_files.js",
"postinstall": "if [ -f ./scripts/untar_ui_builder.js ]; then node ./scripts/untar_ui_builder.js && node ./scripts/patch_files.js; fi",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --threshold warning",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .",
@@ -161,7 +161,7 @@
"zod-to-json-schema": "^3.24.5"
},
"peerDependencies": {
"svelte": "^4.0.0"
"svelte": "^5.0.0"
},
"exports": {
"./package.json": "./package.json",
@@ -169,7 +169,16 @@
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
},
"./scripts/untar_ui_builder.js": "./scripts/untar_ui_builder.js",
"./assets/app.css": "./package/assets/app.css",
"./components/custom_ui": {
"types": "./package/components/custom_ui.d.ts",
"default": "./package/components/custom_ui.js"
},
"./components/scriptBuilder": {
"types": "./package/components/scriptBuilder.d.ts",
"default": "./package/components/scriptBuilder.js"
},
"./components/TestJobLoader.svelte": {
"types": "./package/components/TestJobLoader.svelte.d.ts",
"svelte": "./package/components/TestJobLoader.svelte",
@@ -255,20 +264,20 @@
"svelte": "./package/components/FlowStatusViewer.svelte",
"default": "./package/components/FlowStatusViewer.svelte"
},
"./components/FlowBuilder.svelte": {
"types": "./package/components/FlowBuilder.svelte.d.ts",
"svelte": "./package/components/FlowBuilder.svelte",
"default": "./package/components/FlowBuilder.svelte"
"./components/FlowWrapper.svelte": {
"types": "./package/components/FlowWrapper.svelte.d.ts",
"svelte": "./package/components/FlowWrapper.svelte",
"default": "./package/components/FlowWrapper.svelte"
},
"./components/AppEditor.svelte": {
"types": "./package/components/apps/editor/AppEditor.svelte.d.ts",
"svelte": "./package/components/apps/editor/AppEditor.svelte",
"default": "./package/components/apps/editor/AppEditor.svelte"
"./components/AppWrapper.svelte": {
"types": "./package/components/AppWrapper.svelte.d.ts",
"svelte": "./package/components/AppWrapper.svelte",
"default": "./package/components/AppWrapper.svelte"
},
"./components/ScriptBuilder.svelte": {
"types": "./package/components/ScriptBuilder.svelte.d.ts",
"svelte": "./package/components/ScriptBuilder.svelte",
"default": "./package/components/ScriptBuilder.svelte"
"./components/ScriptWrapper.svelte": {
"types": "./package/components/ScriptWrapper.svelte.d.ts",
"svelte": "./package/components/ScriptWrapper.svelte",
"default": "./package/components/ScriptWrapper.svelte"
},
"./components/FlowEditor.svelte": {
"types": "./package/components/flows/FlowEditor.svelte.d.ts",
@@ -285,10 +294,10 @@
"svelte": "./package/components/SchemaForm.svelte",
"default": "./package/components/SchemaForm.svelte"
},
"./components/EditableSchemaWrapper.svelte": {
"types": "./package/components/schema/EditableSchemaWrapper.svelte.d.ts",
"svelte": "./package/components/schema/EditableSchemaWrapper.svelte",
"default": "./package/components/schema/EditableSchemaWrapper.svelte"
"./components/EditableSchemaSdkWrapper.svelte": {
"types": "./package/components/schema/EditableSchemaSdkWrapper.svelte.d.ts",
"svelte": "./package/components/schema/EditableSchemaSdkWrapper.svelte",
"default": "./package/components/schema/EditableSchemaSdkWrapper.svelte"
},
"./components/ResourceEditor.svelte": {
"types": "./package/components/ResourceEditor.svelte.d.ts",
@@ -451,14 +460,14 @@
"components/FlowBuilder.svelte": [
"./package/components/FlowBuilder.svelte.d.ts"
],
"components/AppEditor.svelte": [
"./package/components/apps/editor/AppEditor.svelte.d.ts"
"components/AppWrapper.svelte": [
"./package/components/AppWrapper.svelte.d.ts"
],
"components/ScriptBuilder.svelte": [
"./package/components/ScriptBuilder.svelte.d.ts"
"components/ScriptWrapper.svelte": [
"./package/components/ScriptWrapper.svelte.d.ts"
],
"components/FlowEditor.svelte": [
"./package/components/flows/FlowEditor.svelte.d.ts"
"components/FlowWrapper.svelte": [
"./package/components/FlowWrapper.svelte.d.ts"
],
"components/SchemaViewer.svelte": [
"./package/components/SchemaViewer.svelte.d.ts"
@@ -466,8 +475,8 @@
"components/SchemaEditor.svelte": [
"./package/components/SchemaEditor.svelte.d.ts"
],
"components/EditableSchemaWrapper.svelte": [
"./package/components/schema/EditableSchemaWrapper.svelte.d.ts"
"components/EditableSchemaSdkWrapper.svelte": [
"./package/components/schema/EditableSchemaSdkWrapper.svelte.d.ts"
],
"components/flows/FlowHistoryInner.svelte": [
"./package/components/flows/FlowHistoryInner.svelte.d.ts"
@@ -519,6 +528,12 @@
],
"tailwindUtils": [
"./package/components/apps/editor/componentsPanel/tailwindUtils.d.ts"
],
"components/scriptBuilder": [
"./package/components/scriptBuilder.d.ts"
],
"components/custom_ui": [
"./package/components/custom_ui.d.ts"
]
}
},
@@ -526,4 +541,4 @@
"@rollup/rollup-linux-x64-gnu": "^4.35.0",
"fsevents": "^2.3.3"
}
}
}
+17 -10
View File
@@ -1,6 +1,23 @@
import path from 'path'
import fs from 'fs'
// Check if we're in node_modules (installed as dependency)
if (process.cwd().includes('node_modules')) {
console.log('Skipping postinstall - running as dependency');
process.exit(0);
}
// Check if we're in the root project
if (process.env.INIT_CWD && process.env.INIT_CWD !== process.cwd()) {
console.log('Skipping postinstall - not root project');
process.exit(0);
}
// Your actual postinstall logic here
console.log('Running postinstall for root project');
import { x } from 'tar'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-d44b577.tar.gz'
@@ -18,16 +35,6 @@ const response = await fetch(tarUrl)
const buffer = await response.arrayBuffer()
await fs.promises.writeFile(outputTarPath, Buffer.from(buffer))
// Check if this script is being run from the package root
const isRootInstall = process.cwd() + '/scripts' === __dirname
if (isRootInstall) {
console.log('Running postinstall: direct install')
// Your postinstall logic here
} else {
console.log('Skipping postinstall: installed as dependency')
process.exit(0)
}
// Create extract directory if it doesn't exist
try {
@@ -0,0 +1,10 @@
<script lang="ts">
import AppEditor from './apps/editor/AppEditor.svelte'
import type { AppEditorProps } from './apps/types'
let { app: oldApp, ...props }: AppEditorProps = $props()
let app = $state(oldApp)
</script>
<AppEditor {app} {...props} />
@@ -1,9 +1,8 @@
<script lang="ts">
import { page } from '$app/stores'
import { workspaceStore } from '$lib/stores'
import ClipboardPanel from './details/ClipboardPanel.svelte'
$: url = `${$page.url.protocol}//${$page.url.hostname}/`
let url = $derived(`${window.location.protocol}//${window.location.hostname}/`)
</script>
<div>
+4 -5
View File
@@ -524,22 +524,21 @@
let workspace = $derived($page.url.searchParams.get('workspace') ?? undefined)
let themeDarkRaw = $derived($page.url.searchParams.get('activeColorTheme'))
let themeDark = $derived(themeDarkRaw == '2' || themeDarkRaw == '4')
$effect(() => {
$effect.pre(() => {
if (token) {
OpenAPI.WITH_CREDENTIALS = true
OpenAPI.TOKEN = token
untrack(() => loadUser())
}
})
$effect(() => {
$effect.pre(() => {
if (workspace) {
$workspaceStore = workspace
untrack(() => setupCopilotInfo())
}
})
$effect(() => {
$effect.pre(() => {
if (workspace && token) {
untrack(() => loadUser())
untrack(() => setupCopilotInfo())
}
})
$effect(() => {
@@ -1,6 +1,6 @@
<script lang="ts">
import { createEventDispatcher, onDestroy, getContext, untrack } from 'svelte'
import { getFirstStepSchema } from '$lib/components/flows/flowStore.svelte'
import { getFirstStepSchema } from '$lib/components/flows/flowStore'
import type { FlowEditorContext } from '$lib/components/flows/types'
import { twMerge } from 'tailwind-merge'
import { Alert } from '$lib/components/common'
+31 -66
View File
@@ -1,6 +1,4 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import {
FlowService,
type Flow,
@@ -26,7 +24,6 @@
orderedJsonStringify,
readFieldsRecursively,
replaceFalseWithUndefined,
type StateStore,
type Value
} from '$lib/utils'
import { sendUserToast } from '$lib/toast'
@@ -35,12 +32,11 @@
import AIChangesWarningModal from '$lib/components/copilot/chat/flow/AIChangesWarningModal.svelte'
import { onMount, setContext, untrack, type ComponentType } from 'svelte'
import { writable, type Writable } from 'svelte/store'
import { writable } from 'svelte/store'
import CenteredPage from './CenteredPage.svelte'
import { Badge, Button, UndoRedo } from './common'
import FlowEditor from './flows/FlowEditor.svelte'
import ScriptEditorDrawer from './flows/content/ScriptEditorDrawer.svelte'
import type { FlowState } from './flows/flowState'
import { dfs as dfsApply } from './flows/dfs'
import FlowImportExportMenu from './flows/header/FlowImportExportMenu.svelte'
import FlowPreviewButtons from './flows/header/FlowPreviewButtons.svelte'
@@ -56,7 +52,6 @@
type Icon,
Settings
} from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import Awareness from './Awareness.svelte'
import { getAllModules } from './flows/flowExplorer'
import { type FlowCopilotContext } from './copilot/flow'
@@ -65,7 +60,6 @@
import Dropdown from '$lib/components/DropdownV2.svelte'
import FlowTutorials from './FlowTutorials.svelte'
import { ignoredTutorials } from './tutorials/ignoredTutorials'
import type DiffDrawer from './DiffDrawer.svelte'
import FlowHistory from './flows/FlowHistory.svelte'
import Summary from './Summary.svelte'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
@@ -88,43 +82,7 @@
StepHistoryLoader,
type stepState
} from './stepHistoryLoader.svelte'
interface Props {
initialPath?: string
pathStoreInit?: string | undefined
newFlow: boolean
selectedId: string | undefined
initialArgs?: Record<string, any>
loading?: boolean
flowStore: StateStore<OpenFlow>
flowStateStore: Writable<FlowState>
savedFlow?: FlowWithDraftAndDraftTriggers | undefined
diffDrawer?: DiffDrawer | undefined
customUi?: FlowBuilderWhitelabelCustomUi
disableAi?: boolean
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
setSavedraftCb?: ((cb: () => void) => void) | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
loadedFromHistoryFromUrl?: {
flowJobInitial: boolean | undefined
stepsState: Record<string, stepState>
}
noInitial?: boolean
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
onSaveDraft?: ({
path,
savedAtNewPath,
newFlow
}: {
path: string
savedAtNewPath: boolean
newFlow: boolean
}) => void
}
import type { FlowBuilderProps } from './flow_builder'
let {
initialPath = $bindable(''),
@@ -147,8 +105,16 @@
selectedTriggerIndexFromUrl = undefined,
children,
loadedFromHistoryFromUrl,
noInitial = false
}: Props = $props()
noInitial = false,
onSaveInitial,
onSaveDraft,
onDeploy,
onDeployError,
onDetails,
onSaveDraftError,
onSaveDraftOnlyAtNewPath,
onHistoryRestore
}: FlowBuilderProps = $props()
let initialPathStore = writable(initialPath)
@@ -157,7 +123,7 @@
'u/' +
($userStore?.username?.includes('@')
? $userStore!.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: $userStore!.username!) +
: $userStore?.username) +
'/' +
generateRandomString(12)
@@ -229,8 +195,6 @@
}
}
const dispatch = createEventDispatcher()
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
const triggersCount = writable<TriggersCount | undefined>(undefined)
const simplifiedPoll = writable(false)
@@ -361,18 +325,18 @@
let savedAtNewPath = false
if (newFlow) {
dispatch('saveInitial', $pathStore)
onSaveInitial?.({ path: $pathStore, id: getSelectedId() })
} else if (savedFlow?.draft_only && $pathStore !== initialPath) {
savedAtNewPath = true
initialPath = $pathStore
onSaveDraftOnlyAtNewPath?.({ path: $pathStore, selectedId: getSelectedId() })
// this is so we can use the flow builder outside of sveltekit
dispatch('saveDraftOnlyAtNewPath', { path: $pathStore, selectedId: getSelectedId() })
}
dispatch('saveDraft', { path: $pathStore, savedAtNewPath, newFlow })
onSaveDraft?.({ path: $pathStore, savedAtNewPath, newFlow })
sendUserToast('Saved as draft')
} catch (error) {
sendUserToast(`Error while saving the flow as a draft: ${error.body || error.message}`, true)
dispatch('saveDraftError', error)
onSaveDraftError?.({ error })
}
loadingDraft = false
}
@@ -542,9 +506,10 @@
} as Flow
setDraftTriggers([])
loadingSave = false
dispatch('deploy', $pathStore)
onDeploy?.({ path: $pathStore })
} catch (err) {
dispatch('deployError', err)
onDeployError?.({ error: err })
// this is so we can use the flow builder outside of sveltekit
sendUserToast(`The flow could not be saved: ${err.body ?? err}`, true)
loadingSave = false
}
@@ -738,7 +703,7 @@
if (savedFlow?.draft_only === false || savedFlow?.draft_only === undefined) {
dropdownItems.push({
label: 'Exit & see details',
onClick: () => dispatch('details', $pathStore)
onClick: () => onDetails?.({ path: $pathStore })
})
}
@@ -853,28 +818,28 @@
let forceTestTab: Record<string, boolean> = $state({})
let highlightArg: Record<string, string | undefined> = $state({})
run(() => {
$effect.pre(() => {
initialPathStore.set(initialPath)
})
run(() => {
$effect.pre(() => {
setContext('customUi', customUi)
})
run(() => {
$effect.pre(() => {
if (flowStore.val || $selectedIdStore) {
readFieldsRecursively(flowStore.val)
untrack(() => saveSessionDraft())
}
})
run(() => {
$effect.pre(() => {
initialPath && ($pathStore = initialPath)
})
run(() => {
$effect.pre(() => {
selectedId && untrack(() => select(selectedId))
})
run(() => {
$effect.pre(() => {
initialPath && initialPath != '' && $workspaceStore && untrack(() => loadTriggers())
})
run(() => {
$effect.pre(() => {
const hasAiDiff = aiChatManager.flowAiChatHelpers?.hasDiff() ?? false
customUi && untrack(() => onCustomUiChange(customUi, hasAiDiff))
})
@@ -919,8 +884,8 @@
{@render children?.()}
<DeployOverrideConfirmationModal
bind:deployedBy
bind:confirmCallback
{deployedBy}
{confirmCallback}
bind:open
{diffDrawer}
bind:deployedValue
@@ -942,7 +907,7 @@
{#key renderCount}
{#if !$userStore?.operator}
{#if $pathStore}
<FlowHistory bind:this={flowHistory} path={$pathStore} on:historyRestore />
<FlowHistory bind:this={flowHistory} path={$pathStore} {onHistoryRestore} />
{/if}
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
<FlowImportExportMenu bind:drawer={jsonViewerDrawer} />
@@ -0,0 +1,13 @@
<script lang="ts">
import AiChatLayout from './copilot/chat/AiChatLayout.svelte'
import type { FlowBuilderProps } from './flow_builder'
import FlowBuilder from './FlowBuilder.svelte'
let { flowStore: oldFlowStore, disableAi, ...props }: FlowBuilderProps = $props()
let flowStore = $state(oldFlowStore)
</script>
<AiChatLayout noPadding={true} {disableAi}>
<FlowBuilder {flowStore} {disableAi} {...props} />
</AiChatLayout>
@@ -20,7 +20,6 @@
import autosize from '$lib/autosize'
import GfmMarkdown from './GfmMarkdown.svelte'
import TestTriggerConnection from './triggers/TestTriggerConnection.svelte'
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
import GitHubAppIntegration from './GitHubAppIntegration.svelte'
interface Props {
@@ -29,7 +28,7 @@
path?: string
newResource?: boolean
hidePath?: boolean
watchChanges?: boolean
onChange?: (args: { path: string; args: Record<string, any>; description: string }) => void
defaultValues?: Record<string, any> | undefined
}
@@ -39,7 +38,7 @@
path = $bindable(''),
newResource = false,
hidePath = false,
watchChanges = false,
onChange,
defaultValues = undefined
}: Props = $props()
@@ -62,7 +61,6 @@
let viewJsonSchema = $state(false)
const dispatch = createEventDispatcher()
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
let rawCode: string | undefined = $state(undefined)
@@ -181,8 +179,8 @@
run(() => {
canSave = can_write && isValid && jsonError == ''
})
run(() => {
watchChanges && dispatchIfMounted('change', { path, args, description })
$effect(() => {
onChange && onChange({ path, args, description })
})
run(() => {
rawCode && untrack(() => parseJson())
+108 -93
View File
@@ -11,7 +11,8 @@
type TriggersCount,
PostgresTriggerService,
CaptureService,
type ScriptLang
type ScriptLang,
WorkerService
} from '$lib/gen'
import { inferArgs } from '$lib/infer'
import { initialCode } from '$lib/script_helpers'
@@ -21,6 +22,7 @@
enterpriseLicense,
usedTriggerKinds,
userStore,
workerTags,
workspaceStore
} from '$lib/stores'
import {
@@ -65,16 +67,15 @@
import ScriptSchema from './ScriptSchema.svelte'
import Section from './Section.svelte'
import Label from './Label.svelte'
import type DiffDrawer from './DiffDrawer.svelte'
import type Editor from './Editor.svelte'
import WorkerTagPicker from './WorkerTagPicker.svelte'
import MetadataGen from './copilot/MetadataGen.svelte'
import { writable } from 'svelte/store'
import { defaultScriptLanguages, processLangs } from '$lib/scripts'
import DefaultScripts from './DefaultScripts.svelte'
import { createEventDispatcher, onMount, setContext, untrack } from 'svelte'
import { onMount, setContext, untrack } from 'svelte'
import Summary from './Summary.svelte'
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
import TriggersEditor from './triggers/TriggersEditor.svelte'
import type { ScheduleTrigger, TriggerContext } from './triggers'
@@ -86,7 +87,6 @@
} from '$lib/script_helpers'
import CaptureTable from './triggers/CaptureTable.svelte'
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
import DeployButton from './DeployButton.svelte'
import {
type NewScriptWithDraftAndDraftTriggers,
@@ -97,33 +97,12 @@
} from './triggers/utils'
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
import { Triggers } from './triggers/triggers.svelte'
import type { AssetWithAccessType } from './assets/lib'
interface Props {
script: NewScript & {
draft_triggers?: Trigger[]
fallback_access_types?: AssetWithAccessType[]
}
fullyLoaded?: boolean
initialPath?: string
template?: 'docker' | 'bunnative' | 'script'
initialArgs?: Record<string, any>
lockedLanguage?: boolean
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawer | undefined
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
replaceStateFn?: (url: string) => void
customUi?: ScriptBuilderWhitelabelCustomUi
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
children?: import('svelte').Snippet
}
import type { ScriptBuilderProps } from './script_builder'
import type { DiffDrawerI } from './diff_drawer'
import WorkerTagSelect from './WorkerTagSelect.svelte'
let {
script = $bindable(),
script,
fullyLoaded = true,
initialPath = $bindable(''),
template = $bindable('script'),
@@ -139,8 +118,15 @@
customUi = {},
savedPrimarySchedule = undefined,
functionExports = undefined,
children
}: Props = $props()
children,
onDeploy,
onDeployError,
onSaveInitial,
onSeeDetails,
onSaveDraftError,
onSaveDraft,
disableAi
}: ScriptBuilderProps = $props()
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
return {
@@ -157,7 +143,7 @@
'u/' +
($userStore?.username?.includes('@')
? $userStore?.username.split('@')[0].replace(/[^a-zA-Z0-9_]/g, '')
: $userStore?.username!) +
: $userStore?.username) +
'/' +
generateRandomString(12)
@@ -214,8 +200,6 @@
loadTriggers()
}
const dispatch = createEventDispatcher()
onMount(() => {
if (functionExports) {
console.log('functionExports set')
@@ -578,10 +562,10 @@
script.parent_hash = newHash
sendUserToast('Deployed')
} else {
dispatch('deploy', newHash)
onDeploy?.({ path: script.path, hash: newHash })
}
} catch (error) {
dispatch('deployError', error)
onDeployError?.({ path: script.path, error })
sendUserToast(`Error while saving the script: ${error.body || error.message}`, true)
}
loadingSave = false
@@ -634,7 +618,7 @@
} catch (error) {
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
}
let newHash = ''
if (initialPath == '' || savedScript?.draft_only) {
if (savedScript?.draft_only) {
await ScriptService.deleteScriptByPath({
@@ -654,7 +638,7 @@
runnableKind: 'script'
})
}
await ScriptService.createScript({
newHash = await ScriptService.createScript({
workspace: $workspaceStore!,
requestBody: {
path: script.path,
@@ -714,9 +698,9 @@
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
savedAtNewPath = true
initialPath = script.path
dispatch('saveInitial', script.path)
onSaveInitial?.({ path: script.path, hash: newHash })
}
dispatch('saveDraft', { path: script.path, savedAtNewPath, script })
onSaveDraft?.({ path: script.path, savedAtNewPath, script })
sendUserToast('Saved as draft')
} catch (error) {
@@ -724,7 +708,7 @@
`Error while saving the script as a draft: ${error.body || error.message}`,
true
)
dispatch('saveDraftError', error)
onSaveDraftError?.({ path: script.path, error })
}
loadingDraft = false
}
@@ -732,7 +716,7 @@
function computeDropdownItems(
initialPath: string,
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
diffDrawer: DiffDrawer | undefined
diffDrawer: DiffDrawerI | undefined
) {
let dropdownItems: { label: string; onClick: () => void }[] =
initialPath != '' && customUi?.topBar?.extraDeployOptions != false
@@ -782,7 +766,7 @@
{
label: 'Exit & See details',
onClick: () => {
dispatch('seeDetails', initialPath)
onSeeDetails?.({ path: initialPath })
}
}
]
@@ -950,14 +934,21 @@
$effect(() => {
!disableHistoryChange && untrack(() => encodeScriptState(script))
})
loadWorkerTags()
async function loadWorkerTags() {
if (!$workerTags) {
$workerTags = await WorkerService.getCustomTags({ workspace: $workspaceStore })
}
}
</script>
<svelte:window onkeydown={onKeyDown} />
{@render children?.()}
<DeployOverrideConfirmationModal
bind:deployedBy
bind:confirmCallback
{deployedBy}
{confirmCallback}
bind:open
{diffDrawer}
bind:deployedValue
@@ -1086,7 +1077,7 @@
}}
/>
</Label>
{#if script.schema}
{#if script.schema && !disableAi && !customUi?.settingsPanel?.metadata?.disableAiFilling}
<div class="mt-3">
<AIFormSettings
bind:prompt={script.schema.prompt_for_ai as string | undefined}
@@ -1096,52 +1087,52 @@
{/if}
</div>
</Section>
<Section label="Language">
{#snippet action()}
<DefaultScripts />
{/snippet}
{#if lockedLanguage}
<div class="text-sm text-tertiary italic mb-2">
As a forked script, the language '{script.language}' cannot be modified.
</div>
{/if}
<div class=" grid grid-cols-3 gap-2">
{#each langs as [label, lang] (lang)}
{@const isPicked =
(lang == script.language && template == 'script') ||
(template == 'bunnative' && lang == 'bunnative') ||
(template == 'docker' && lang == 'docker')}
<Popover
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
>
<Button
aiId={`create-script-language-button-${lang}`}
aiDescription={`Choose ${lang} as the language of the script`}
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
: 'm-[1px]'}
on:click={() => onScriptLanguageTrigger(lang)}
disabled={lockedLanguage ||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
{#if !customUi?.settingsPanel?.metadata?.languages || customUi?.settingsPanel?.metadata?.languages?.length > 1}
<Section label="Language">
{#snippet action()}
<DefaultScripts />
{/snippet}
{#if lockedLanguage}
<div class="text-sm text-tertiary italic mb-2">
As a forked script, the language '{script.language}' cannot be modified.
</div>
{/if}
<div class=" grid grid-cols-3 gap-2">
{#each langs as [label, lang] (lang)}
{@const isPicked =
(lang == script.language && template == 'script') ||
(template == 'bunnative' && lang == 'bunnative') ||
(template == 'docker' && lang == 'docker')}
<Popover
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
>
<LanguageIcon {lang} />
<span class="ml-2 py-2 truncate">{label}</span>
{#if lang === 'nu'}
<span class="text-tertiary !text-xs"> BETA </span>
{/if}
</Button>
{#snippet text()}
{label} is only available with an enterprise license
{/snippet}
</Popover>
{/each}
</div>
</Section>
<Button
aiId={`create-script-language-button-${lang}`}
aiDescription={`Choose ${lang} as the language of the script`}
size="sm"
variant="border"
color={isPicked ? 'blue' : 'light'}
btnClasses={isPicked
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
: 'm-[1px]'}
on:click={() => onScriptLanguageTrigger(lang)}
disabled={lockedLanguage ||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
>
<LanguageIcon {lang} />
<span class="ml-2 py-2 truncate">{label}</span>
{#if lang === 'nu'}
<span class="text-tertiary !text-xs"> BETA </span>
{/if}
</Button>
{#snippet text()}
{label} is only available with an enterprise license
{/snippet}
</Popover>
{/each}
</div>
</Section>
{/if}
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true}
<Section label="Script kind">
{#snippet header()}
@@ -1177,6 +1168,14 @@
</ToggleButtonGroup>
</Section>
{/if}
{#if customUi?.settingsPanel?.disableRuntime}
<Section label="Worker group tag (queue)">
<WorkerTagPicker
bind:tag={script.tag}
placeholder={customUi?.tagSelectPlaceholder}
/>
</Section>
{/if}
</div>
</TabContent>
<TabContent value="runtime">
@@ -1244,7 +1243,10 @@
group tag (queue). For instance, you could setup an "highmem", or "gpu" tag.
</Tooltip>
{/snippet}
<WorkerTagPicker bind:tag={script.tag} />
<WorkerTagPicker
bind:tag={script.tag}
placeholder={customUi?.tagSelectPlaceholder}
/>
</Section>
<Section label="Cache">
{#snippet header()}
@@ -1698,6 +1700,18 @@
{/if}
<div class="flex flex-row gap-x-1 lg:gap-x-2">
{#if $workerTags}
{#if $workerTags?.length ?? 0 > 0}
<div class="max-w-[200px] pr-8">
<WorkerTagSelect
inputClass="text-sm text-secondary !placeholder-secondary"
nullTag={script.language}
placeholder={customUi?.tagSelectPlaceholder}
bind:tag={script.tag}
/>
</div>
{/if}
{/if}
{#if customUi?.topBar?.settings != false}
<Button
aiId="script-builder-settings"
@@ -1738,6 +1752,7 @@
</div>
<ScriptEditor
{disableAi}
bind:selectedTab={selectedInputTab}
{customUi}
collabMode
@@ -85,6 +85,7 @@
stablePathForCaptures?: string
lastSavedCode?: string | undefined
lastDeployedCode?: string | undefined
disableAi?: boolean
editor_bar_right?: import('svelte').Snippet
fallbackAccessTypes?: AssetWithAccessType[]
}
@@ -115,6 +116,7 @@
stablePathForCaptures = '',
lastSavedCode = undefined,
lastDeployedCode = undefined,
disableAi = false,
editor_bar_right,
fallbackAccessTypes = $bindable()
}: Props = $props()
@@ -559,7 +561,7 @@
color="marine"
/>
{/if}
{#if !aiChatManager.open}
{#if !aiChatManager.open && !disableAi}
{#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')}
<HideButton
hidden={true}
@@ -0,0 +1,13 @@
<script lang="ts">
import ScriptBuilder from '$lib/components/ScriptBuilder.svelte'
import AiChatLayout from './copilot/chat/AiChatLayout.svelte'
import type { ScriptBuilderProps } from './script_builder'
let { script: oldScript, disableAi, ...props }: ScriptBuilderProps = $props()
let script = $state(oldScript)
</script>
<AiChatLayout noPadding {disableAi}>
<ScriptBuilder {script} {disableAi} {...props} />
</AiChatLayout>
@@ -6,12 +6,22 @@
import { WorkerService } from '$lib/gen'
import WorkerTagSelect from './WorkerTagSelect.svelte'
export let tag: string | undefined
export let popupPlacement: 'bottom-end' | 'top-end' = 'bottom-end'
export let disabled = false
interface Props {
tag: string | undefined
popupPlacement?: 'bottom-end' | 'top-end'
disabled?: boolean
placeholder?: string
}
loadWorkerGroups()
async function loadWorkerGroups() {
let {
tag = $bindable(),
popupPlacement = 'bottom-end',
disabled = false,
placeholder
}: Props = $props()
loadWorkerTags()
async function loadWorkerTags() {
if (!$workerTags) {
$workerTags = await WorkerService.getCustomTags({ workspace: $workspaceStore })
}
@@ -22,7 +32,7 @@
<div class="max-w-sm grow">
{#if $workerTags}
{#if $workerTags?.length ?? 0 > 0}
<WorkerTagSelect noLabel bind:tag {disabled} />
<WorkerTagSelect {placeholder} noLabel bind:tag {disabled} />
{:else}
<div class="text-sm text-secondary flex flex-row gap-2">
No custom worker group tag defined on this instance in "Workers {'->'} Custom tags"
@@ -48,7 +58,7 @@
color="light"
on:click={() => {
$workerTags = undefined
loadWorkerGroups()
loadWorkerTags()
}}
startIcon={{ icon: RotateCw }}
{disabled}
@@ -10,12 +10,18 @@
tag = $bindable(),
noLabel = false,
nullTag = undefined,
disabled = false
disabled = false,
placeholder,
inputClass
}: {
tag: string | undefined
noLabel?: boolean
nullTag?: string | undefined
disabled?: boolean
placeholder?: string
language?: string
class?: string
inputClass?: string
} = $props()
loadWorkerGroups()
@@ -35,13 +41,14 @@
<div class="flex gap-1 items-center">
{#if !noLabel}
<div class="text-tertiary text-2xs">tag</div>
<div class="text-tertiary text-2xs">{placeholder ?? 'tag'}</div>
{/if}
<Select
clearable
class="w-full"
{inputClass}
{disabled}
placeholder={nullTag ? `default: ${nullTag}` : 'lang default'}
placeholder={nullTag ? nullTag : (placeholder ?? 'lang default')}
items={safeSelectItems(items)}
bind:value={() => tag, (value) => ((tag = value), dispatch('change', value))}
/>
@@ -12,6 +12,7 @@
import type {
App,
AppEditorContext,
AppEditorProps,
AppViewerContext,
ConnectingInput,
ContextPanelContext,
@@ -33,7 +34,7 @@
import ItemPicker from '$lib/components/ItemPicker.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import { VariableService, type Policy } from '$lib/gen'
import { VariableService } from '$lib/gen'
import { initHistory } from '$lib/history'
import { Component, Minus, Paintbrush, Plus, Smartphone, Scan, Hand, Grab } from 'lucide-svelte'
import { animateTo, findGridItem, findGridItemParentGrid } from './appUtils'
@@ -51,37 +52,10 @@
import DarkModeObserver from '$lib/components/DarkModeObserver.svelte'
import { getTheme } from './componentsPanel/themeUtils'
import StylePanel from './settingsPanel/StylePanel.svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import HideButton from './settingsPanel/HideButton.svelte'
import AppEditorBottomPanel from './AppEditorBottomPanel.svelte'
import panzoom from 'panzoom'
interface Props {
app: App
path: string
policy: Policy
summary: string
fromHub?: boolean
diffDrawer?: DiffDrawer | undefined
savedApp?:
| {
value: App
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
version?: number | undefined
newApp?: boolean
newPath?: string | undefined
replaceStateFn?: (path: string) => void
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
}
let {
app,
path,
@@ -95,8 +69,9 @@
newPath = undefined,
replaceStateFn = (path: string) => window.history.replaceState(null, '', path),
gotoFn = (path: string, opt?: Record<string, any>) => window.history.pushState(null, '', path),
unsavedConfirmationModal
}: Props = $props()
unsavedConfirmationModal,
onSavedNewAppPath
}: AppEditorProps = $props()
migrateApp(app)
@@ -872,13 +847,13 @@
leftPanelHidden={leftPanelSize === 0}
rightPanelHidden={rightPanelSize === 0}
bottomPanelHidden={runnablePanelSize === 0}
on:savedNewAppPath
on:showLeftPanel={() => showLeftPanel()}
on:showRightPanel={() => showRightPanel()}
on:hideLeftPanel={() => hideLeftPanel()}
on:hideRightPanel={() => hideRightPanel()}
on:hideBottomPanel={() => hideBottomPanel()}
on:showBottomPanel={() => showBottomPanel()}
{onSavedNewAppPath}
onShowLeftPanel={() => showLeftPanel()}
onShowRightPanel={() => showRightPanel()}
onShowBottomPanel={() => showBottomPanel()}
onHideLeftPanel={() => hideLeftPanel()}
onHideRightPanel={() => hideRightPanel()}
onHideBottomPanel={() => hideBottomPanel()}
>
{#snippet unsavedConfirmationModal({
diffDrawer,
@@ -32,7 +32,7 @@
Globe,
AlertTriangle
} from 'lucide-svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { getContext, untrack } from 'svelte'
import {
cleanValueProperties,
orderedJsonStringify,
@@ -57,7 +57,6 @@
import { secondaryMenuLeftStore, secondaryMenuRightStore } from './settingsPanel/secondaryMenu'
import Dropdown from '$lib/components/DropdownV2.svelte'
import AppEditorTutorial from './AppEditorTutorial.svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import AppReportsDrawer from './AppReportsDrawer.svelte'
import { type ColumnDef, getPrimaryKeys } from '../components/display/dbtable/utils'
import DebugPanel from './contextPanel/DebugPanel.svelte'
@@ -82,6 +81,7 @@
import { collectStaticFields, type TriggerableV2 } from './commonAppUtils'
import LazyModePanel from './contextPanel/LazyModePanel.svelte'
import { Sha256 } from '@aws-crypto/sha256-js'
import type { DiffDrawerI } from '$lib/components/diff_drawer'
async function hash(message) {
try {
@@ -103,7 +103,7 @@
interface Props {
policy: Policy
fromHub?: boolean
diffDrawer?: DiffDrawer | undefined
diffDrawer?: DiffDrawerI | undefined
savedApp?:
| {
value: App
@@ -122,6 +122,13 @@
newApp: boolean
newPath?: string
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
onSavedNewAppPath?: (path: string) => void
onShowRightPanel?: () => void
onShowLeftPanel?: () => void
onShowBottomPanel?: () => void
onHideRightPanel?: () => void
onHideLeftPanel?: () => void
onHideBottomPanel?: () => void
}
let {
@@ -135,7 +142,14 @@
bottomPanelHidden = false,
newApp,
newPath = '',
unsavedConfirmationModal
unsavedConfirmationModal,
onSavedNewAppPath,
onShowLeftPanel,
onShowRightPanel,
onShowBottomPanel,
onHideLeftPanel,
onHideRightPanel,
onHideBottomPanel
}: Props = $props()
let newEditedPath = $state('')
@@ -406,7 +420,7 @@
} catch (e) {
console.error('error interacting with local storage', e)
}
dispatch('savedNewAppPath', path)
onSavedNewAppPath?.(path)
} catch (e) {
sendUserToast('Error creating app', e)
}
@@ -509,7 +523,7 @@
} catch (e) {
console.error('error interacting with local storage', e)
}
dispatch('savedNewAppPath', npath)
onSavedNewAppPath?.(npath)
}
}
@@ -589,7 +603,7 @@
}
draftDrawerOpen = false
dispatch('savedNewAppPath', newEditedPath)
onSavedNewAppPath?.(newEditedPath)
} catch (e) {
sendUserToast('Error saving initial draft', e)
}
@@ -686,7 +700,7 @@
}
loading.saveDraft = false
if (newApp || savedApp.draft_only) {
dispatch('savedNewAppPath', newEditedPath || path)
onSavedNewAppPath?.(newEditedPath || path)
}
} catch (e) {
loading.saveDraft = false
@@ -891,8 +905,6 @@
debugAppDrawerOpen = true
}
const dispatch = createEventDispatcher()
function setTheme(newDarkMode: boolean | undefined) {
let globalDarkMode = window.localStorage.getItem('dark-mode')
? window.localStorage.getItem('dark-mode') === 'dark'
@@ -981,8 +993,8 @@
})}
{/if}
<DeployOverrideConfirmationModal
bind:deployedBy
bind:confirmCallback
{deployedBy}
{confirmCallback}
bind:open
{diffDrawer}
bind:deployedValue
@@ -1442,9 +1454,9 @@
hidden={leftPanelHidden}
on:click={() => {
if (leftPanelHidden) {
dispatch('showLeftPanel')
onShowLeftPanel?.()
} else {
dispatch('hideLeftPanel')
onHideLeftPanel?.()
}
}}
/>
@@ -1453,9 +1465,9 @@
direction="bottom"
on:click={() => {
if (bottomPanelHidden) {
dispatch('showBottomPanel')
onShowBottomPanel?.()
} else {
dispatch('hideBottomPanel')
onHideBottomPanel?.()
}
}}
/>
@@ -1464,9 +1476,9 @@
direction="right"
on:click={() => {
if (rightPanelHidden) {
dispatch('showRightPanel')
onShowRightPanel?.()
} else {
dispatch('hideRightPanel')
onHideRightPanel?.()
}
}}
/>
+35 -6
View File
@@ -141,13 +141,42 @@ export type HiddenRunnable = {
export type AppTheme =
| {
type: 'path'
path: string
}
type: 'path'
path: string
}
| {
type: 'inlined'
css: string
}
type: 'inlined'
css: string
}
import type { DiffDrawerI } from "$lib/components/diff_drawer"
export interface AppEditorProps {
app: App
path: string
policy: Policy
summary: string
fromHub?: boolean
diffDrawer?: DiffDrawerI | undefined
savedApp?:
| {
value: App
draft?: any
path: string
summary: string
policy: any
draft_only?: boolean
custom_path?: string
}
| undefined
version?: number | undefined
newApp?: boolean
newPath?: string | undefined
replaceStateFn?: (path: string) => void
gotoFn?: (path: string, opt?: Record<string, any> | undefined) => void
unsavedConfirmationModal?: import('svelte').Snippet<[any]>
onSavedNewAppPath?: (path: string) => void
}
export type App = {
grid: GridItem[]
@@ -97,6 +97,7 @@
actionKind: ActionKind | undefined | 'all',
scope: undefined | 'all_workspaces' | 'instance'
): Promise<void> {
console.log("loading logs")
loading = true
if (username == 'all') {
@@ -130,6 +131,7 @@
hasMore = logs.length > 0 && logs.length === perPage
loading = false
console.log("loadede logs")
}
async function loadUsers() {
@@ -353,24 +355,32 @@
<span class="text-xs absolute -top-4">After</span>
<input type="text" value={after ?? 'After'} disabled />
<CalendarPicker
clearable
date={after}
placement="bottom-end"
label="After"
on:change={async ({ detail }) => {
on:change={({ detail }) => {
after = new Date(detail).toISOString()
}}
on:clear={() => {
after = undefined
}}
/>
</div>
<div class="flex gap-1 relative w-full">
<span class="text-xs absolute -top-4">Before</span>
<input type="text" value={before ?? 'Before'} disabled />
<CalendarPicker
clearable
bind:date={before}
label="Before"
placement="bottom-end"
on:change={async ({ detail }) => {
on:change={({ detail }) => {
before = new Date(detail).toISOString()
}}
on:clear={() => {
before = undefined
}}
/>
</div>
@@ -1,24 +1,38 @@
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import type { AuditLog } from '$lib/gen'
import { displayDate } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { onMount, tick } from 'svelte'
import Button from '../common/button/Button.svelte'
import { ListFilter } from 'lucide-svelte'
import { ListFilter, ChevronLeft, ChevronRight } from 'lucide-svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
import { twMerge } from 'tailwind-merge'
export let logs: AuditLog[] = []
export let pageIndex: number | undefined = 1
export let perPage: number | undefined = 100
export let hasMore: boolean = true
export let actionKind: string | undefined = undefined
export let operation: string | undefined = undefined
export let selectedId: number | undefined = undefined
export let usernameFilter: string | undefined = undefined
export let resourceFilter: string | undefined = undefined
interface Props {
logs?: AuditLog[]
pageIndex?: number | undefined
perPage?: number | undefined
hasMore?: boolean
actionKind?: string | undefined
operation?: string | undefined
selectedId?: number | undefined
usernameFilter?: string | undefined
resourceFilter?: string | undefined
onselect?: (id: number) => void
}
let {
logs = [],
pageIndex = $bindable(1),
perPage = $bindable(100),
hasMore = $bindable(true),
actionKind = $bindable(),
operation = $bindable(),
selectedId = undefined,
usernameFilter = $bindable(),
resourceFilter = $bindable(),
onselect
}: Props = $props()
function groupLogsByDay(logs: AuditLog[]): Record<string, AuditLog[]> {
const groupedLogs = {}
@@ -42,8 +56,55 @@
return groupedLogs
}
const dispatch = createEventDispatcher()
$: groupedLogs = groupLogsByDay(logs)
type FlatLogs =
| {
type: 'date'
date: string
}
| {
type: 'log'
log: AuditLog
}
function flattenLogs(groupedLogs: Record<string, AuditLog[]>): Array<FlatLogs> {
const flatLogs: Array<FlatLogs> = []
for (const [date, logsByDay] of Object.entries(groupedLogs)) {
flatLogs.push({ type: 'date', date })
for (const log of logsByDay) {
flatLogs.push({ type: 'log', log })
}
}
return flatLogs
}
let tableHeight: number = $state(0)
let headerHeight: number = $state(0)
let footerHeight: number = $state(48)
function computeHeight() {
tableHeight =
document.querySelector('#audit-logs-table-wrapper')!.parentElement?.clientHeight ?? 0
}
onMount(() => {
tick().then(computeHeight)
})
let groupedLogs = $derived(groupLogsByDay(logs))
let flatLogs = $derived(groupedLogs ? flattenLogs(groupedLogs) : undefined)
let stickyIndices = $derived.by(() => {
const nstickyIndices: number[] = []
let index = 0
for (const entry of flatLogs ?? []) {
if (entry.type === 'date') {
nstickyIndices.push(index)
}
index++
}
return nstickyIndices
})
function kindToBadgeColor(kind: string) {
if (kind == 'Execute') {
@@ -59,118 +120,178 @@
}
</script>
<DataTable
on:next={() => {
pageIndex = (pageIndex ?? 1) + 1
}}
on:previous={() => {
pageIndex = (pageIndex ?? 1) - 1
}}
currentPage={pageIndex}
paginated
rounded={false}
size="sm"
{hasMore}
bind:perPage
<svelte:window onresize={() => computeHeight()} />
<div
class="divide-y min-w-[640px] h-full"
id="audit-logs-table-wrapper"
>
<Head>
<Cell first head>ID</Cell>
<Cell head>Timestamp</Cell>
<Cell head>Username</Cell>
<Cell head>Operation</Cell>
<Cell head last>Resource</Cell>
</Head>
{#if logs?.length > 0}
<tbody class="divide-y">
{#each Object.entries(groupedLogs) as [date, logsByDay]}
<tr class="border-t">
<Cell
first
colspan="6"
scope="colgroup"
class="bg-surface-secondary/30 py-2 border-b font-semibold"
>
{date}
</Cell>
</tr>
{#each logsByDay as { id, timestamp, username, operation: op, action_kind, resource, parameters }}
<Row
hoverable
selected={id === selectedId}
on:click={() => {
dispatch('select', id)
}}
>
<Cell first>
{id}
</Cell>
<Cell>
{displayDate(timestamp)}
</Cell>
<Cell>
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-52">
{username}
{#if parameters && 'end_user' in parameters}
<span> ({parameters.end_user})</span>
{/if}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
usernameFilter = username
}}
/>
</div>
</Cell>
<Cell>
<div class="flex flex-row gap-1">
<Badge
on:click={() => {
actionKind = action_kind.toLocaleLowerCase()
}}
color={kindToBadgeColor(action_kind)}>{action_kind}</Badge
>
<Badge
on:click={() => {
operation = op
}}
>
{op}
</Badge>
</div>
</Cell>
<Cell last>
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar w-48">
{resource}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
resourceFilter = resource
}}
/>
</div>
</Cell>
</Row>
{/each}
{/each}
</tbody>
<div bind:clientHeight={headerHeight}>
<div
class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4 text-xs font-semibold"
>
<div class="w-1/12">ID</div>
<div class="w-3/12">Timestamp</div>
<div class="w-3/12">Username</div>
<div class="w-3/12">Operation</div>
<div class="w-2/12">Resource</div>
</div>
</div>
{#if logs?.length == 0}
<div class="text-xs text-secondary p-8"> No logs found for the selected filters. </div>
{:else}
<tr>
<td colspan="4" class="text-center py-8">
<div class="text-xs text-secondary"> No logs found for the selected filters. </div>
</td>
</tr>
<VirtualList
width="100%"
height={tableHeight - headerHeight - footerHeight}
itemCount={flatLogs?.length ?? 0}
itemSize={42}
overscanCount={20}
{stickyIndices}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet children({ index, style })}
<div {style} class="w-full">
{#if flatLogs}
{@const logOrDate = flatLogs[index]}
{#if logOrDate}
{#if logOrDate?.type === 'date'}
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
{logOrDate.date}
</div>
{:else}
<!-- 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 px-2 py-1 hover:bg-surface-hover cursor-pointer',
logOrDate.log.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/50' : ''
)}
role="button"
tabindex="0"
onclick={() => {
onselect?.(logOrDate.log.id)
}}
>
<div class="w-1/12 text-xs truncate">
{logOrDate.log.id}
</div>
<div class="w-3/12 text-xs">
{displayDate(logOrDate.log.timestamp)}
</div>
<div class="w-3/12 text-xs">
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-60">
{logOrDate.log.username}
{#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters}
<span> ({logOrDate.log.parameters.end_user})</span>
{/if}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
usernameFilter = logOrDate.log.username
}}
/>
</div>
</div>
<div class="w-3/12 text-xs">
<div class="flex flex-row gap-1">
<Badge
on:click={() => {
actionKind = logOrDate.log.action_kind.toLocaleLowerCase()
}}
color={kindToBadgeColor(logOrDate.log.action_kind)}
>
{logOrDate.log.action_kind}
</Badge>
<Badge
on:click={() => {
operation = logOrDate.log.operation
}}
>
{logOrDate.log.operation}
</Badge>
</div>
</div>
<div class="w-2/12 text-xs">
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-60">
{logOrDate.log.resource}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
resourceFilter = logOrDate.log.resource
}}
/>
</div>
</div>
</div>
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full px-2">
<div class="text-xs text-secondary">Loading...</div>
</div>
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full px-2">
<div class="text-xs text-secondary">Loading...</div>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}{/snippet}
</VirtualList>
{/if}
</DataTable>
<!-- Pagination footer - always visible -->
<div class="flex flex-row justify-between items-center p-2 bg-surface-primary border-t">
<div class="flex flex-row gap-2 items-center">
<Button
color="light"
size="xs2"
startIcon={{ icon: ChevronLeft }}
on:click={() => {
pageIndex = (pageIndex ?? 1) - 1
}}
disabled={pageIndex <= 1}
>
Previous
</Button>
<span class="text-xs text-secondary px-2">Page {pageIndex}</span>
<Button
color="light"
size="xs2"
endIcon={{ icon: ChevronRight }}
on:click={() => {
pageIndex = (pageIndex ?? 1) + 1
}}
disabled={!hasMore}
>
Next
</Button>
</div>
<div class="flex flex-row gap-2 items-center">
<span class="text-xs text-secondary">Per page:</span>
<select
bind:value={perPage}
class="text-xs bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1"
>
<option value={25}>25</option>
<option value={100}>100</option>
<option value={1000}>1000</option>
</select>
</div>
</div>
</div>
<style lang="postcss">
/* Hide scrollbar for Chrome, Safari and Opera */
@@ -183,4 +304,10 @@
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* VirtualList scrollbar styling */
:global(.virtual-list-wrapper:hover::-webkit-scrollbar) {
width: 8px !important;
height: 8px !important;
}
</style>
@@ -0,0 +1,564 @@
<script lang="ts">
import 'chartjs-adapter-date-fns'
import zoomPlugin from 'chartjs-plugin-zoom'
import {
Chart as ChartJS,
Title,
Tooltip,
Legend,
LineElement,
CategoryScale,
LinearScale,
PointElement,
TimeScale,
type ChartData,
type ChartOptions
} from 'chart.js'
import type { AuditLog } from '$lib/gen'
import { Scatter } from '../chartjs-wrappers/chartJs'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { sleep } from '$lib/utils'
import { usePromise } from '$lib/svelte5Utils.svelte'
interface Props {
logs: AuditLog[]
minTimeSet: string | undefined
maxTimeSet: string | undefined
onMissingJobSpan?: (jobId: string, jobLogs: AuditLog[]) => Promise<AuditLog[]>
onZoom?: (range: { min: Date; max: Date }) => void
onLogSelected?: (log: any) => void
}
let {
logs = [],
minTimeSet,
maxTimeSet,
onMissingJobSpan,
onZoom,
onLogSelected
}: Props = $props()
// Register ChartJS components
ChartJS.register(
Title,
Tooltip,
Legend,
zoomPlugin,
LineElement,
CategoryScale,
LinearScale,
PointElement,
TimeScale
)
function addSeconds(date: Date, seconds: number): Date {
date.setTime(date.getTime() + seconds * 1000)
return date
}
const zoomOptions = {
pan: {
mode: 'x' as 'x',
enabled: true,
modifierKey: 'ctrl' as 'ctrl',
onPanComplete: ({ chart }) => {
chartInstance = chart
onZoom?.({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
},
zoom: {
drag: {
enabled: true
},
mode: 'x' as 'x',
scaleMode: 'y' as 'y',
onZoom: ({ chart }) => {
chartInstance = chart
onZoom?.({
min: addSeconds(new Date(chart.scales.x.min), -1),
max: addSeconds(new Date(chart.scales.x.max), 1)
})
}
}
}
// Color mapping for different action kinds
const actionColors = {
Execute: '#3b82f6', // blue
Delete: '#ef4444', // red
Update: '#eab308', // yellow
Create: '#22c55e', // green
default: '#6b7280' // gray
}
function getActionColor(actionKind: string): string {
return actionColors[actionKind as keyof typeof actionColors] || actionColors.default
}
async function groupLogsBySpan(
logs: AuditLog[],
onMissingJobSpan?: (jobId: string, jobLogs: AuditLog[]) => Promise<AuditLog[]>
): Promise<{
grouped: Record<string, AuditLog[]>
jobGrouped: Map<string, AuditLog[]>
}> {
const grouped: Record<string, AuditLog[]> = {}
const jobGrouped: Map<string, AuditLog[]> = new Map()
for (const log of logs) {
const spanId = log.span || 'untraced'
if (spanId.startsWith('job-span-')) {
const jobid = spanId.slice('job-span-'.length)
if (!jobGrouped.has(jobid)) {
jobGrouped.set(jobid, [])
}
jobGrouped.get(jobid)?.push(log)
continue
}
if (!grouped[spanId]) {
grouped[spanId] = []
}
grouped[spanId].push(log)
}
for (const jobid of jobGrouped.keys()) {
const j = Object.values(grouped)
.flat()
.find((log) => log.parameters?.uuid === jobid)
if (j?.span != undefined) {
grouped[j.span].push(...jobGrouped.get(jobid)!)
jobGrouped.get(jobid)?.push(j)
} else {
// Try to fetch missing job execution audit log
if (onMissingJobSpan) {
try {
const jobLogs = jobGrouped.get(jobid)!
const additionalLogs = await onMissingJobSpan(jobid, jobLogs)
// Look for the job execution audit log in the new results
const jobExecutionLog = additionalLogs.find((log) => log.parameters?.uuid === jobid)
if (jobExecutionLog?.span) {
if (!grouped[jobExecutionLog.span]) {
grouped[jobExecutionLog.span] = []
}
grouped[jobExecutionLog.span].push(jobExecutionLog, ...jobLogs)
jobGrouped.get(jobid)?.push(jobExecutionLog)
continue
}
} catch (error) {
console.warn(`Failed to fetch missing job audit span for job ${jobid}:`, error)
}
}
if (!grouped[jobid]) {
grouped[jobid] = []
}
grouped[jobid].push(...jobGrouped.get(jobid)!)
}
}
// Sort logs within each span by timestamp
Object.values(grouped).forEach((spanLogs) => {
spanLogs.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
})
return { grouped, jobGrouped }
}
function isDark(): boolean {
return document.documentElement.classList.contains('dark')
}
// Function to apply zoom-aware jittering to overlapping points
function applyJittering(dataPoints: any[], baseY: number, chartInstance?: any): any[] {
if (dataPoints.length <= 1) return dataPoints
// Sort by timestamp
const sorted = [...dataPoints].sort((a, b) => new Date(a.x).getTime() - new Date(b.x).getTime())
// Calculate visual overlap based on chart scale
const pointRadius = 0.8 // Current point radius
const overlapThreshold = pointRadius * 2 // Points overlap if closer than this in pixels
// Group points that visually overlap
const groups: any[][] = []
let currentGroup: any[] = [sorted[0]]
for (let i = 1; i < sorted.length; i++) {
const prevTime = new Date(sorted[i - 1].x).getTime()
const currTime = new Date(sorted[i].x).getTime()
// Calculate pixel distance between points
let pixelDistance = overlapThreshold + 1 // Default to no overlap
if (chartInstance && chartInstance.scales && chartInstance.scales.x) {
const prevPixel = chartInstance.scales.x.getPixelForValue(prevTime)
const currPixel = chartInstance.scales.x.getPixelForValue(currTime)
pixelDistance = Math.abs(currPixel - prevPixel)
} else {
pixelDistance = 20000
}
if (pixelDistance < overlapThreshold) {
currentGroup.push(sorted[i])
} else {
groups.push(currentGroup)
currentGroup = [sorted[i]]
}
}
groups.push(currentGroup)
const jitteredPoints: any[] = []
groups.forEach((group) => {
if (group.length === 1) {
jitteredPoints.push({
...group[0],
y: baseY,
isCluster: false,
clusterSize: 1
})
} else {
const jitterRange = 0.4
group.forEach((point, index) => {
let jitterOffset =
(1 - Math.exp(-group.length / 50)) * jitterRange * (Math.random() - 0.5)
jitteredPoints.push({
...point,
y: baseY + jitterOffset,
originalY: baseY,
isCluster: false,
clusterSize: group.length,
clusterIndex: index
})
})
}
})
return jitteredPoints
}
// Set chart defaults based on theme
ChartJS.defaults.color = isDark() ? '#ccc' : '#666'
ChartJS.defaults.borderColor = isDark() ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
async function getGroupedData(): Promise<{
grouped: Record<string, AuditLog[]>
jobGrouped: Map<string, AuditLog[]>
}> {
if (logs.length === 0) {
await sleep(1)
return { grouped: {}, jobGrouped: new Map() }
}
try {
return await groupLogsBySpan(logs, onMissingJobSpan)
} catch (error) {
console.error('Error grouping logs:', error)
return await groupLogsBySpan(logs)
}
}
let groupedData = usePromise(getGroupedData)
// let isGrouping = $state(false)
let chartInstance: any = $state(null)
let { minTime, maxTime } = $derived(computeMinMaxTime(logs, minTimeSet, maxTimeSet))
function computeMinMaxTime(
logs: AuditLog[] | undefined,
minTimeSet: string | undefined,
maxTimeSet: string | undefined
) {
let minTime = addSeconds(new Date(), -300)
let maxTime = new Date()
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
if (minTimeSetDate && maxTimeSetDate) {
minTime = minTimeSetDate
maxTime = maxTimeSetDate
return { minTime, maxTime }
}
if (logs == undefined || logs?.length == 0) {
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
maxTime = maxTimeSetDate ?? new Date()
return { minTime, maxTime }
}
const maxLogsTime = new Date(
logs.reduce((max, current) =>
new Date(current.timestamp) > new Date(max.timestamp) ? current : max
).timestamp
)
const maxJob = maxTimeSetDate === undefined ? new Date() : maxLogsTime
const minJob = new Date(
logs.reduce((max, current) =>
new Date(current.timestamp) < new Date(max.timestamp) ? current : max
).timestamp
)
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
if (maxTimeSetDate) {
maxTime = maxTimeSetDate ?? maxJob
} else {
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
}
return { minTime, maxTime }
}
$effect(() => {
logs && untrack(() => groupedData.refresh())
})
const groupedLogs = $derived(groupedData.value?.grouped ?? {})
const jobGrouped = $derived(groupedData.value?.jobGrouped ?? new Map())
const spanIds = $derived(Object.keys(groupedLogs).sort())
const spanAuthors = $derived(
spanIds.map((span) => {
if (span == 'untraced') {
return 'untraced'
}
const endUser = groupedLogs[span][0]?.parameters?.end_user
const endUserText = endUser ? ` (${endUser})` : ''
return groupedLogs[span]?.length > 0 ? `${groupedLogs[span][0].username}${endUserText}` : ''
})
)
// Transform data for ChartJS scatter plot
const chartData = $derived((): ChartData<'scatter'> => {
if (untrack(() => logs.length) === 0) {
return { datasets: [] }
}
const datasets: any[] = []
// Create datasets for regular span groups (points only)
spanIds.forEach((spanId, index) => {
const spanLogs = groupedLogs[spanId]
// Create initial data points
const dataPoints = spanLogs.map((log) => ({
x: log.timestamp as any,
y: index, // Each span gets its own y-axis position
log: log // Store full log data for tooltips
}))
// Apply zoom-aware jittering to spread out overlapping points
const jitteredPoints = applyJittering(dataPoints, index, chartInstance)
// const jitteredPoints = dataPoints
datasets.push({
label: spanId === 'untraced' ? 'Untraced' : spanId,
data: jitteredPoints,
backgroundColor: jitteredPoints.map((point) => {
const baseColor = getActionColor(point.log.action_kind)
// Make clustered points slightly more opaque
return point.isCluster ? baseColor + 'E0' : baseColor
}),
borderColor: jitteredPoints.map((point) => {
const baseColor = getActionColor(point.log.action_kind)
// Add white border to clustered points for better visibility
return point.isCluster ? '#ffffff' : baseColor
}),
borderWidth: jitteredPoints.map((point) => (point.isCluster ? 1 : 1)),
pointRadius: jitteredPoints.map((point) => (point.isCluster ? 3 : 3)),
pointHoverRadius: jitteredPoints.map((point) => (point.isCluster ? 5 : 5)),
showLine: false
})
})
// Create datasets for job-connected lines
jobGrouped.forEach((jobLogs: AuditLog[], jobId: string) => {
if (jobLogs.length > 1) {
// Only create lines if there are multiple points
// Sort job logs by timestamp to ensure proper line connection
const sortedJobLogs = [...jobLogs].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
)
// Find the y-position for each log based on its span and jittered position
const lineData = sortedJobLogs.map((log) => {
const spanId = log.span || 'untraced'
let baseYPosition = spanIds.indexOf(spanId)
if (baseYPosition === -1) {
// Handle job-span logs that might not be in regular spans
const jobSpanId = spanId.startsWith('job-span-')
? spanId.slice('job-span-'.length)
: spanId
baseYPosition = spanIds.findIndex((id) => id === jobSpanId)
if (baseYPosition === -1) {
// If still not found, assign to the span where this job's audit logs are grouped
const auditSpan = Object.entries(groupedLogs).find(([, spanLogs]) =>
spanLogs.some((l) => l.parameters?.uuid === jobId)
)?.[0]
baseYPosition = auditSpan ? spanIds.indexOf(auditSpan) : 0
}
}
// Find the jittered position for this specific log
let jitteredY = baseYPosition
if (baseYPosition >= 0 && baseYPosition < datasets.length) {
const spanDataset = datasets[baseYPosition]
if (spanDataset && spanDataset.data) {
const matchingPoint = spanDataset.data.find(
(point: any) => point.log && point.log.id === log.id
)
if (matchingPoint) {
jitteredY = matchingPoint.y
}
}
}
return {
x: log.timestamp as any,
y: jitteredY,
log: log
}
})
datasets.push({
label: `Job ${jobId} Connection`,
data: lineData,
backgroundColor: 'transparent',
borderColor: '#8b5cf6', // Purple color for job connections
borderWidth: 2,
pointRadius: 0, // Hide points for connection lines
pointHoverRadius: 0,
showLine: true,
tension: 0, // Straight lines
fill: false
})
}
})
return { datasets }
})
const chartOptions = $derived(
(): ChartOptions<'scatter'> => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
zoom: zoomOptions,
legend: {
display: false // We'll create our own legend
},
tooltip: {
callbacks: {
title: function (context: any) {
const log = context[0].raw.log
let title = `${log.operation} - ${log.action_kind}`
return title
},
label: function (context: any) {
const log = context.raw.log
const labels = [
`User: ${log.username}`,
`Resource: ${log.resource}`,
`Time: ${new Date(log.timestamp).toLocaleString()}`
]
return labels
}
}
}
},
scales: {
x: {
type: 'time',
time: {
displayFormats: {
millisecond: 'HH:mm:ss.SSS',
second: 'HH:mm:ss',
minute: 'HH:mm',
hour: 'MMM dd HH:mm',
day: 'MMM dd',
week: 'MMM dd',
month: 'MMM yyyy',
quarter: 'MMM yyyy',
year: 'yyyy'
}
},
title: {
display: true,
text: 'Time'
},
grid: {
display: false
},
ticks: {
maxTicksLimit: 15
},
min: minTime.getTime(),
max: maxTime.getTime()
},
y: {
type: 'linear',
min: -1,
max: spanIds.length,
grid: {
display: true,
color: isDark() ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
},
ticks: {
autoSkip: false,
maxTicksLimit: 22,
stepSize: 1,
callback: function (value: any) {
if (spanIds.length > 20) {
return ''
}
const index = Math.round(value)
if (index >= 0 && index < spanIds.length) {
// const spanId = `${spanAuthors[index]} - ${index}`
const spanId = spanAuthors[index]
return spanId === 'untraced' ? 'Untraced' : spanId.slice(0, 30)
}
return ''
}
}
}
},
onClick: (event: any, elements: any, chart: any) => {
// Capture chart instance for jittering calculations
if (!chartInstance) {
chartInstance = chart
}
if (elements.length > 0) {
const element = elements[0]
const log = (chartData().datasets[element.datasetIndex].data[element.index] as any).log
onLogSelected?.(log)
}
},
onHover: (event: any, elements: any, chart: any) => {
// Capture chart instance for jittering calculations
if (!chartInstance) {
chartInstance = chart
}
},
animation: {
duration: 300
}
})
)
</script>
<div class="p-4 bg-surface mb-4 h-full">
{#if logs.length === 0}
<div class="text-center py-8 text-secondary"> No audit logs to display </div>
{:else if !groupedData || groupedData.status === 'loading'}
<div class="text-center py-8 text-secondary">
<Loader2 size={24} class="animate-spin mx-auto mb-2" />
Processing audit logs...
</div>
{:else}
<Scatter data={chartData()} options={chartOptions()} />
{/if}
</div>
@@ -1,15 +1,26 @@
<script lang="ts">
import ConfirmationModal from './ConfirmationModal.svelte'
import Button from '../button/Button.svelte'
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
import { type Value } from '$lib/utils'
import type { DiffDrawerI } from '$lib/components/diff_drawer'
export let deployedValue: Value | undefined = undefined
export let currentValue: Value | undefined = undefined
export let diffDrawer: DiffDrawer | undefined = undefined
export let confirmCallback: () => void
export let deployedBy : string | undefined = undefined
export let open = false
interface Props {
deployedValue?: Value | undefined;
currentValue?: Value | undefined;
diffDrawer?: DiffDrawerI | undefined;
confirmCallback: () => void;
deployedBy?: string | undefined;
open?: boolean;
}
let {
deployedValue = $bindable(),
currentValue = undefined,
diffDrawer = undefined,
confirmCallback,
deployedBy = undefined,
open = $bindable(false)
}: Props = $props();
</script>
<ConfirmationModal
@@ -1,31 +1,17 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import AssistantMessage from './AssistantMessage.svelte'
import AIChatMessage from './AIChatMessage.svelte'
import { type Snippet } from 'svelte'
import {
CheckIcon,
HistoryIcon,
Loader2,
Plus,
RefreshCwIcon,
StopCircleIcon,
Undo2Icon,
X,
XIcon
} from 'lucide-svelte'
import autosize from '$lib/autosize'
import { CheckIcon, HistoryIcon, Loader2, Plus, StopCircleIcon, X, XIcon } from 'lucide-svelte'
import Button from '$lib/components/common/button/Button.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { type DisplayMessage } from './shared'
import type { ContextElement } from './context'
import ContextElementBadge from './ContextElementBadge.svelte'
import ContextTextarea from './ContextTextarea.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import ChatQuickActions from './ChatQuickActions.svelte'
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ChatMode from './ChatMode.svelte'
import Markdown from 'svelte-exmarkdown'
import { aiChatManager, AIMode } from './AIChatManager.svelte'
import AIChatInput from './AIChatInput.svelte'
let {
messages,
@@ -63,16 +49,8 @@
suggestions?: string[]
} = $props()
let contextTextareaComponent: ContextTextarea | undefined = $state()
let instructionsTextarea: HTMLTextAreaElement | undefined = $state()
export function focusInput() {
if (aiChatManager.mode === 'script') {
contextTextareaComponent?.focus()
} else {
instructionsTextarea?.focus()
}
}
let aiChatInput: AIChatInput | undefined = $state()
let editingMessageIndex = $state<number | null>(null)
let scrollEl: HTMLDivElement | undefined = $state()
async function scrollDown() {
@@ -87,38 +65,12 @@
aiChatManager.automaticScroll && height && scrollDown()
})
function addContextToSelection(contextElement: ContextElement) {
if (
selectedContext &&
availableContext &&
!selectedContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
) &&
availableContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
)
) {
selectedContext = [...selectedContext, contextElement]
}
}
function submitSuggestion(suggestion: string) {
aiChatManager.instructions = suggestion
aiChatManager.sendRequest()
aiChatManager.sendRequest({ instructions: suggestion })
}
function isLastUserMessage(messageIndex: number): boolean {
// Find the last user message index
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
return i === messageIndex
}
}
return false
}
function restartGeneration(messageIndex: number) {
aiChatManager.restartLastGeneration(messageIndex)
export function focusInput() {
aiChatInput?.focusInput()
}
</script>
@@ -208,68 +160,13 @@
>
<div class="flex flex-col" bind:clientHeight={height}>
{#each messages as message, messageIndex}
<div class={twMerge(message.role === 'user' && messageIndex > 0 && 'mt-6', 'mb-2')}>
{#if message.role === 'user' && message.contextElements}
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
{#each message.contextElements as element}
<ContextElementBadge contextElement={element} />
{/each}
</div>
{/if}
<div
class={twMerge(
'text-sm py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg relative group',
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
message.role === 'tool' && 'text-tertiary'
)}
>
{#if message.role === 'assistant'}
<AssistantMessage {message} />
{:else}
{message.content}
{/if}
{#if message.role === 'user' && isLastUserMessage(messageIndex) && !aiChatManager.loading}
<div
class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Button
size="xs2"
variant="border"
color="light"
iconOnly
title="Restart generation"
startIcon={{ icon: RefreshCwIcon }}
btnClasses="!p-1 !h-6 !w-6"
on:click={() => restartGeneration(messageIndex)}
/>
</div>
{/if}
</div>
{#if message.role === 'user' && message.snapshot}
<div
class="mx-2 text-sm text-tertiary flex flex-row items-center justify-between gap-2 mt-2"
>
Saved a flow snapshot
<Button
size="xs2"
variant="border"
color="light"
on:click={() => {
if (message.snapshot) {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot)
}
}}
title="Revert to snapshot"
startIcon={{ icon: Undo2Icon }}
>
Revert
</Button>
</div>
{/if}
</div>
<AIChatMessage
{message}
{messageIndex}
{availableContext}
bind:selectedContext
bind:editingMessageIndex
/>
{/each}
{#if aiChatManager.loading && !aiChatManager.currentReply}
<div class="mb-6 py-1 px-2">
@@ -323,71 +220,14 @@
</Button>
</div>
{/if}
{#if aiChatManager.mode === 'script'}
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 px-2 no-scrollbar">
<Popover>
<svelte:fragment slot="trigger">
<div
class="border rounded-md px-1 py-0.5 font-normal text-tertiary text-xs hover:bg-surface-hover"
>@</div
>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
</svelte:fragment>
</Popover>
{#each selectedContext as element}
<ContextElementBadge
contextElement={element}
deletable
on:delete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
<ContextTextarea
bind:this={contextTextareaComponent}
{availableContext}
{selectedContext}
isFirstMessage={messages.length === 0}
onAddContext={(contextElement) => addContextToSelection(contextElement)}
onSendRequest={() => {
if (!aiChatManager.loading) {
aiChatManager.sendRequest()
}
}}
onUpdateInstructions={(value) => (aiChatManager.instructions = value)}
{disabled}
/>
{:else}
<div class="relative w-full px-2 scroll-pb-2 pt-2">
<textarea
bind:this={instructionsTextarea}
bind:value={aiChatManager.instructions}
use:autosize
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && !aiChatManager.loading) {
e.preventDefault()
aiChatManager.sendRequest()
}
}}
rows={3}
placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'}
class="resize-none"
{disabled}
></textarea>
</div>
{/if}
<AIChatInput
bind:this={aiChatInput}
bind:selectedContext
{availableContext}
{disabled}
isFirstMessage={messages.length === 0}
placeholder={messages.length === 0 ? 'Ask anything' : 'Ask followup'}
/>
<div
class={`flex flex-row ${
aiChatManager.mode === 'script' && hasDiff ? 'justify-between' : 'justify-end'
@@ -0,0 +1,162 @@
<script lang="ts">
import Popover from '$lib/components/meltComponents/Popover.svelte'
import AvailableContextList from './AvailableContextList.svelte'
import ContextElementBadge from './ContextElementBadge.svelte'
import ContextTextarea from './ContextTextarea.svelte'
import autosize from '$lib/autosize'
import type { ContextElement } from './context'
import { aiChatManager } from './AIChatManager.svelte'
interface Props {
availableContext: ContextElement[]
selectedContext: ContextElement[]
isFirstMessage?: boolean
disabled?: boolean
placeholder?: string
initialInstructions?: string
editingMessageIndex?: number | null
onEditEnd?: () => void
}
let {
availableContext,
selectedContext = $bindable([]),
disabled = false,
isFirstMessage = false,
placeholder = 'Ask anything',
initialInstructions = '',
editingMessageIndex = null,
onEditEnd = () => {}
}: Props = $props()
let contextTextareaComponent: ContextTextarea | undefined = $state()
let instructionsTextareaComponent: HTMLTextAreaElement | undefined = $state()
let instructions = $state(initialInstructions)
export function focusInput() {
if (aiChatManager.mode === 'script') {
contextTextareaComponent?.focus()
} else {
instructionsTextareaComponent?.focus()
}
}
function clickOutside(node: HTMLElement) {
function handleClick(event: MouseEvent) {
if (node && !node.contains(event.target as Node) && editingMessageIndex !== null) {
onEditEnd()
}
}
document.addEventListener('click', handleClick, true)
return {
destroy() {
document.removeEventListener('click', handleClick, true)
}
}
}
function addContextToSelection(contextElement: ContextElement) {
if (
selectedContext &&
availableContext &&
!selectedContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
) &&
availableContext.find(
(c) => c.type === contextElement.type && c.title === contextElement.title
)
) {
selectedContext = [...selectedContext, contextElement]
}
}
function sendRequest() {
if (aiChatManager.loading) {
return
}
if (editingMessageIndex !== null) {
aiChatManager.restartGeneration(editingMessageIndex, instructions)
onEditEnd()
} else {
aiChatManager.sendRequest({ instructions })
instructions = ''
}
}
$effect(() => {
if (editingMessageIndex !== null) {
focusInput()
}
})
</script>
<div use:clickOutside>
{#if aiChatManager.mode === 'script'}
<div class="flex flex-row gap-1 mb-1 overflow-scroll pt-2 px-2 no-scrollbar">
<Popover>
<svelte:fragment slot="trigger">
<div
class="border rounded-md px-1 py-0.5 font-normal text-tertiary text-xs hover:bg-surface-hover"
>@</div
>
</svelte:fragment>
<svelte:fragment slot="content" let:close>
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
addContextToSelection(element)
close()
}}
/>
</svelte:fragment>
</Popover>
{#each selectedContext as element}
<ContextElementBadge
contextElement={element}
deletable
on:delete={() => {
selectedContext = selectedContext?.filter(
(c) => c.type !== element.type || c.title !== element.title
)
}}
/>
{/each}
</div>
<ContextTextarea
bind:this={contextTextareaComponent}
bind:value={instructions}
{availableContext}
{selectedContext}
{isFirstMessage}
{placeholder}
onAddContext={(contextElement) => addContextToSelection(contextElement)}
onSendRequest={() => {
sendRequest()
}}
{disabled}
onEscape={onEditEnd}
/>
{:else}
<div class="relative w-full px-2 scroll-pb-2 pt-2">
<textarea
bind:this={instructionsTextareaComponent}
bind:value={instructions}
use:autosize
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendRequest()
} else if (e.key === 'Escape') {
onEditEnd()
}
}}
rows={3}
{placeholder}
class="resize-none"
{disabled}
></textarea>
</div>
{/if}
</div>
@@ -32,7 +32,6 @@ import type { DBSchemas } from '$lib/stores'
import { askTools, prepareAskSystemMessage } from './ask/core'
import { chatState, DEFAULT_SIZE, triggerablesByAi } from './sharedChatState.svelte'
export enum AIMode {
SCRIPT = 'script',
FLOW = 'flow',
@@ -217,6 +216,32 @@ class AIChatManager {
}
}
retryRequest = (messageIndex: number) => {
const message = this.displayMessages[messageIndex]
if (message && message.role === 'user') {
this.restartGeneration(messageIndex)
message.error = false
} else {
throw new Error('No user message found at the specified index')
}
}
private getLastUserMessage = () => {
for (let i = this.displayMessages.length - 1; i >= 0; i--) {
const message = this.displayMessages[i]
if (message.role === 'user') {
return message
}
}
}
private flagLastMessageAsError = () => {
const lastUserMessage = this.getLastUserMessage()
if (lastUserMessage) {
lastUserMessage.error = true
}
}
private chatRequest = async ({
messages,
abortController,
@@ -403,7 +428,8 @@ class AIChatManager {
role: 'user',
content: this.instructions,
contextElements: this.mode === AIMode.SCRIPT ? oldSelectedContext : undefined,
snapshot
snapshot,
index: this.messages.length // matching with actual messages index. not -1 because it's not yet added to the messages array
}
]
const oldInstructions = this.instructions
@@ -423,8 +449,8 @@ class AIChatManager {
: this.mode === AIMode.NAVIGATOR
? prepareNavigatorUserMessage(oldInstructions)
: await prepareScriptUserMessage(oldInstructions, lang, oldSelectedContext, {
isPreprocessor
})
isPreprocessor
})
this.messages.push(userMessage)
await this.historyManager.saveChat(this.displayMessages, this.messages)
@@ -478,6 +504,7 @@ class AIChatManager {
await this.historyManager.saveChat(this.displayMessages, this.messages)
} catch (err) {
console.error(err)
this.flagLastMessageAsError()
if (err instanceof Error) {
sendUserToast('Failed to send request: ' + err.message, true)
} else {
@@ -492,7 +519,7 @@ class AIChatManager {
this.abortController?.abort()
}
restartLastGeneration = (displayMessageIndex: number) => {
restartGeneration = (displayMessageIndex: number, newContent?: string) => {
const userMessage = this.displayMessages[displayMessageIndex]
if (!userMessage || userMessage.role !== 'user') {
@@ -502,23 +529,17 @@ class AIChatManager {
// Remove all messages including and after the specified user message
this.displayMessages = this.displayMessages.slice(0, displayMessageIndex)
// Find the last user message in actual messages and remove it and everything after it
let lastActualUserMessageIndex = -1
for (let i = this.messages.length - 1; i >= 0; i--) {
if (this.messages[i].role === 'user') {
lastActualUserMessageIndex = i
break
}
}
// Find corresponding message in actual messages and remove it and everything after it
let actualMessageIndex = this.messages.findIndex((_, i) => i === userMessage.index)
if (lastActualUserMessageIndex === -1) {
if (actualMessageIndex === -1) {
throw new Error('No actual user message found to restart from')
}
this.messages = this.messages.slice(0, lastActualUserMessageIndex)
this.messages = this.messages.slice(0, actualMessageIndex)
// Resend the request with the same instructions
this.instructions = userMessage.content
this.instructions = newContent ?? userMessage.content
this.sendRequest()
}
@@ -648,15 +669,15 @@ class AIChatManager {
const editorRelated =
currentEditor && currentEditor.type === 'script' && currentEditor.stepId === module.id
? {
diffMode: currentEditor.diffMode,
lastDeployedCode: currentEditor.lastDeployedCode,
lastSavedCode: undefined
}
diffMode: currentEditor.diffMode,
lastDeployedCode: currentEditor.lastDeployedCode,
lastSavedCode: undefined
}
: {
diffMode: false,
lastDeployedCode: undefined,
lastSavedCode: undefined
}
diffMode: false,
lastDeployedCode: undefined,
lastSavedCode: undefined
}
return {
args: moduleState?.previewArgs ?? {},
@@ -0,0 +1,112 @@
<script lang="ts">
import { twMerge } from 'tailwind-merge'
import type { DisplayMessage } from './shared'
import ContextElementBadge from './ContextElementBadge.svelte'
import AssistantMessage from './AssistantMessage.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import { Button } from '$lib/components/common'
import { RefreshCwIcon, Undo2Icon } from 'lucide-svelte'
import AIChatInput from './AIChatInput.svelte'
import type { ContextElement } from './context'
interface Props {
availableContext: ContextElement[]
selectedContext: ContextElement[]
message: DisplayMessage
messageIndex: number
editingMessageIndex: number | null
}
let {
message,
messageIndex,
availableContext,
selectedContext = $bindable(),
editingMessageIndex = $bindable(null)
}: Props = $props()
function editMessage() {
if (message.role !== 'user' || editingMessageIndex !== null || aiChatManager.loading) {
return
}
editingMessageIndex = messageIndex
}
</script>
<div
class={twMerge(
message.role === 'user' && messageIndex > 0 && 'mt-6',
'mb-2',
message.role !== 'user' ? 'cursor-default' : 'cursor-pointer'
)}
role="button"
tabindex="0"
onclick={() => editMessage()}
onkeydown={() => {}}
>
{#if message.role === 'user' && message.contextElements && editingMessageIndex !== messageIndex}
<div class="flex flex-row gap-1 mb-1 overflow-scroll no-scrollbar px-2">
{#each message.contextElements as element}
<ContextElementBadge contextElement={element} />
{/each}
</div>
{/if}
{#if message.role === 'user' && editingMessageIndex === messageIndex}
<AIChatInput
{availableContext}
bind:selectedContext
initialInstructions={message.content}
{editingMessageIndex}
onEditEnd={() => (editingMessageIndex = null)}
/>
{:else}
<div
class={twMerge(
'text-sm py-1 mx-2',
message.role === 'user' &&
'px-2 border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-900 rounded-lg relative group',
(message.role === 'assistant' || message.role === 'tool') && 'px-[1px]',
message.role === 'tool' && 'text-tertiary'
)}
>
{#if message.role === 'assistant'}
<AssistantMessage {message} />
{:else}
{message.content}
{/if}
</div>
{/if}
{#if message.role === 'user' && message.snapshot}
<div class="mx-2 text-sm text-tertiary flex flex-row items-center justify-between gap-2 mt-2">
Saved a flow snapshot
<Button
size="xs2"
variant="border"
color="light"
on:click={() => {
if (message.snapshot) {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot)
}
}}
title="Revert to snapshot"
startIcon={{ icon: Undo2Icon }}
>
Revert
</Button>
</div>
{/if}
</div>
{#if message.role === 'user' && message.error}
<div class="flex justify-end px-2 -mt-1">
<Button
size="xs2"
variant="border"
title="Retry generation"
color="light"
startIcon={{ icon: RefreshCwIcon }}
onclick={() => aiChatManager.retryRequest(messageIndex)}
>
Retry
</Button>
</div>
{/if}
@@ -0,0 +1,98 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AiChat from './AIChat.svelte'
import { zIndexes } from '$lib/zIndexes'
import { loadCopilot, userStore, workspaceStore } from '$lib/stores'
import { chatState } from './sharedChatState.svelte'
interface Props {
noPadding?: boolean
isCollapsed?: boolean
children: any
onMenuOpen?: () => void
disableAi?: boolean
}
let {
noPadding: noBorder = false,
isCollapsed = false,
children,
onMenuOpen,
disableAi
}: Props = $props()
$effect(() => {
if (disableAi) {
chatState.size = 0
}
})
$effect(() => {
if ($workspaceStore && !disableAi) {
loadCopilot($workspaceStore)
}
})
</script>
{#if !disableAi}
<Splitpanes horizontal={false} class="flex-1 min-h-0">
<Pane size={99.8 - chatState.size} minSize={50} class="flex flex-col min-h-0">
<div
id="content"
class={classNames(
'w-full flex-1 flex flex-col overflow-y-auto',
noBorder || $userStore?.operator ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40',
'transition-all ease-in-out duration-200'
)}
>
<main class="flex-1 flex flex-col">
<div class="relative w-full flex-1 flex flex-col">
<div
class={classNames(
'pt-2 px-4 sm:px-4 flex flex-row justify-between items-center shadow-sm max-w-7xl md:hidden',
noBorder || $userStore?.operator ? 'hidden' : ''
)}
>
<button
aria-label="Menu"
type="button"
onclick={() => {
onMenuOpen?.()
}}
class="h-8 w-8 inline-flex items-center justify-center rounded-md text-tertiary hover:text-primary focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
<div class="flex-1">
{@render children?.()}
</div>
</div>
</main>
</div>
</Pane>
<Pane
bind:size={chatState.size}
minSize={15}
class={`flex flex-col min-h-0 z-[${zIndexes.aiChat}]`}
>
<AiChat />
</Pane>
</Splitpanes>
{:else}
{@render children?.()}
{/if}
@@ -3,28 +3,31 @@
import { tick } from 'svelte'
import type { ContextElement } from './context'
import AvailableContextList from './AvailableContextList.svelte'
import { aiChatManager } from './AIChatManager.svelte'
import Portal from '$lib/components/Portal.svelte'
import { zIndexes } from '$lib/zIndexes'
interface Props {
value: string
availableContext: ContextElement[]
selectedContext: ContextElement[]
isFirstMessage: boolean
placeholder: string
disabled: boolean
onUpdateInstructions: (value: string) => void
onSendRequest: () => void
onAddContext: (contextElement: ContextElement) => void
onEscape: () => void
}
const {
let {
value = $bindable(''),
availableContext,
selectedContext,
isFirstMessage,
placeholder,
disabled,
onUpdateInstructions,
onSendRequest,
onAddContext
onAddContext,
onEscape
}: Props = $props()
let showContextTooltip = $state(false)
@@ -163,11 +166,10 @@
}
function updateInstructionsWithContext(contextElement: ContextElement) {
const index = aiChatManager.instructions.lastIndexOf('@')
const index = value.lastIndexOf('@')
if (index !== -1) {
const newInstructions =
aiChatManager.instructions.substring(0, index) + `@${contextElement.title}`
onUpdateInstructions(newInstructions)
const newInstructions = value.substring(0, index) + `@${contextElement.title}`
value = newInstructions
}
}
@@ -252,7 +254,7 @@
function handleInput(e: Event) {
textarea = e.target as HTMLTextAreaElement
const words = aiChatManager.instructions.split(/\s+/)
const words = value.split(/\s+/)
const lastWord = words[words.length - 1]
if (
@@ -267,7 +269,6 @@
contextTooltipWord = ''
selectedSuggestionIndex = 0
}
onUpdateInstructions(aiChatManager.instructions)
}
function handleKeyPress(e: KeyboardEvent) {
@@ -283,10 +284,7 @@
(c) => c.title === contextElement.title && c.type === contextElement.type
)
// If the context element is already in the selected context and the last word in the instructions is the same as the context element title, send request
if (
isInSelectedContext &&
aiChatManager.instructions.split(' ').pop() === '@' + contextElement.title
) {
if (isInSelectedContext && value.split(' ').pop() === '@' + contextElement.title) {
onSendRequest()
return
}
@@ -301,6 +299,10 @@
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onEscape()
}
if (!showContextTooltip) return
const filteredContext = availableContext.filter(
@@ -337,14 +339,14 @@
<div class="relative w-full px-2 scroll-pb-2">
<div class="textarea-input absolute top-0 left-0 pointer-events-none">
<span class="break-words">
{@html getHighlightedText(aiChatManager.instructions)}
{@html getHighlightedText(value)}
</span>
</div>
<textarea
bind:this={textarea}
onkeypress={handleKeyPress}
onkeydown={handleKeyDown}
bind:value={aiChatManager.instructions}
bind:value
use:autosize
rows={3}
oninput={handleInput}
@@ -353,11 +355,9 @@
showContextTooltip = false
}, 200)
}}
placeholder={isFirstMessage ? 'Ask anything' : 'Ask followup'}
{placeholder}
class="textarea-input resize-none bg-transparent caret-black dark:caret-white"
style={aiChatManager.instructions.length > 0
? 'color: transparent; -webkit-text-fill-color: transparent;'
: ''}
style={value.length > 0 ? 'color: transparent; -webkit-text-fill-color: transparent;' : ''}
{disabled}
></textarea>
</div>
@@ -115,6 +115,19 @@
if (snapshot) {
flowStore.val = snapshot
refreshStateStore(flowStore)
if ($currentEditor) {
const module = getModule($currentEditor.stepId, snapshot)
if (module) {
if ($currentEditor.type === 'script' && module.value.type === 'rawscript') {
$currentEditor.editor.setCode(module.value.content)
} else if ($currentEditor.type === 'iterator' && module.value.type === 'forloopflow') {
$currentEditor.editor.setCode(
module.value.iterator.type === 'javascript' ? module.value.iterator.expr : ''
)
}
}
}
}
},
showModuleDiff(id: string) {
@@ -1,4 +1,3 @@
import { page } from '$app/state'
import type {
ChatCompletionSystemMessageParam,
ChatCompletionTool,
@@ -189,7 +188,7 @@ function getTriggerableComponents(): string {
// Function to get the current page name
function getCurrentPageName(): string {
try {
const currentPage = page.url.pathname
const currentPage = window.location.pathname
switch (currentPage) {
case '/':
return 'Home Page'
@@ -8,18 +8,29 @@ import type { ContextElement } from './context'
import { workspaceStore } from '$lib/stores'
import type { ExtendedOpenFlow } from '$lib/components/flows/types'
export type DisplayMessage =
| {
role: 'user' | 'assistant'
content: string
contextElements?: ContextElement[]
snapshot?: ExtendedOpenFlow
}
| {
role: 'tool'
tool_call_id: string
content: string
}
type BaseDisplayMessage = {
content: string
contextElements?: ContextElement[]
snapshot?: ExtendedOpenFlow
}
export type UserDisplayMessage = BaseDisplayMessage & {
role: 'user'
index: number // Used to match index with actual chat messages
error?: boolean
}
export type ToolDisplayMessage = {
role: 'tool'
tool_call_id: string
content: string
}
export type AssistantDisplayMessage = BaseDisplayMessage & {
role: 'assistant'
}
export type DisplayMessage = UserDisplayMessage | ToolDisplayMessage | AssistantDisplayMessage
async function callTool<T>({
tools,
+4 -1
View File
@@ -35,6 +35,8 @@ export type FlowBuilderWhitelabelCustomUi = {
tagEdit?: boolean
editorBar?: EditorBarUi
downloadLogs?: boolean
tagSelectPlaceholder?: string
tagSelectNoLabel?: boolean
}
export type DisplayResultUi = {
@@ -79,6 +81,7 @@ export type SettingsPanelMetadataUi = {
disableScriptKind?: boolean
editableSchemaForm?: EditableSchemaFormUi
disableMute?: boolean
disableAiFilling?: boolean
}
export type SettingsPanelUi = {
@@ -106,7 +109,7 @@ export type ScriptBuilderWhitelabelCustomUi = {
}
settingsPanel?: SettingsPanelUi
disableTooltips?: boolean
editorBar?: EditorBarUi
previewPanel?: PreviewPanelUi
tagSelectPlaceholder?: string
}
@@ -0,0 +1,24 @@
import type { Value } from "$lib/utils"
export type DiffDrawerDiff =
| {
mode: 'normal'
deployed: Value
draft: Value | undefined
current: Value
defaultDiffType?: 'deployed' | 'draft'
button?: { text: string; onClick: () => void }
}
| {
mode: 'simple'
original: Value
current: Value
title: string
button?: { text: string; onClick: () => void }
}
export interface DiffDrawerI {
openDrawer: () => void
closeDrawer: () => void
setDiff: (diff: DiffDrawerDiff) => void
}
@@ -0,0 +1,52 @@
import type { OpenFlow } from '$lib/gen'
import type { StateStore } from '$lib/utils'
import type { Writable } from 'svelte/store'
import type { FlowState } from './flows/flowState'
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
import type { DiffDrawerI } from './diff_drawer'
import type { FlowBuilderWhitelabelCustomUi } from './custom_ui'
import type { ScheduleTrigger } from './triggers'
import type { stepState } from './stepHistoryLoader.svelte'
export type FlowBuilderProps = {
initialPath?: string
pathStoreInit?: string | undefined
newFlow: boolean
selectedId: string | undefined
initialArgs?: Record<string, any>
loading?: boolean
flowStore: StateStore<OpenFlow>
flowStateStore: Writable<FlowState>
savedFlow?: FlowWithDraftAndDraftTriggers | undefined
diffDrawer?: DiffDrawerI | undefined
customUi?: FlowBuilderWhitelabelCustomUi
disableAi?: boolean
disabledFlowInputs?: boolean
savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore
version?: number | undefined
setSavedraftCb?: ((cb: () => void) => void) | undefined
draftTriggersFromUrl?: Trigger[] | undefined
selectedTriggerIndexFromUrl?: number | undefined
children?: import('svelte').Snippet
loadedFromHistoryFromUrl?: {
flowJobInitial: boolean | undefined
stepsState: Record<string, stepState>
}
noInitial?: boolean
onSaveInitial?: ({ path, id }: { path: string; id: string }) => void
onSaveDraft?: ({
path,
savedAtNewPath,
newFlow
}: {
path: string
savedAtNewPath: boolean
newFlow: boolean
}) => void
onSaveDraftError?: ({ error }: { error: any }) => void
onSaveDraftOnlyAtNewPath?: ({ path, selectedId }: { path: string; selectedId: string }) => void
onDeploy?: ({ path }: { path: string }) => void
onDeployError?: ({ error }: { error: any }) => void
onDetails?: ({ path }: { path: string }) => void
onHistoryRestore?: () => void
}
@@ -6,7 +6,7 @@
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
import { importFlowStore } from '$lib/components/flows/flowStore'
import { Loader2, Plus } from 'lucide-svelte'
import YAML from 'yaml'
@@ -1,18 +1,20 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import Drawer from '../common/drawer/Drawer.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import FlowHistoryInner from './FlowHistoryInner.svelte'
export let path: string
let drawer: Drawer
export function open() {
drawer.openDrawer()
interface Props {
path: string
onHistoryRestore?: () => void
}
const dispatch = createEventDispatcher()
let { path, onHistoryRestore }: Props = $props()
let drawer: Drawer | undefined = $state()
export function open() {
drawer?.openDrawer()
}
</script>
<Drawer bind:this={drawer} size="1200px">
@@ -25,9 +27,9 @@
>
<FlowHistoryInner
allowFork
on:historyRestore={() => {
drawer.closeDrawer()
dispatch('historyRestore')
onHistoryRestore={() => {
drawer?.closeDrawer()
onHistoryRestore?.()
}}
{path}
/>
@@ -1,4 +1,7 @@
<script lang="ts">
import { run, stopPropagation, createBubbler } from 'svelte/legacy'
const bubble = createBubbler()
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { classNames, displayDate, emptyString, sendUserToast } from '$lib/utils'
import { type Flow, FlowService, type FlowVersion } from '$lib/gen'
@@ -6,18 +9,22 @@
import { Skeleton } from '$lib/components/common'
import Button from '../common/button/Button.svelte'
import { ArrowRight, Loader2, Pencil, X } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
export let path: string
export let allowFork: boolean = false
let loading: boolean = false
interface Props {
path: string
allowFork?: boolean
onHistoryRestore?: () => void
}
let versions: FlowVersion[] = []
let { path, allowFork = false, onHistoryRestore }: Props = $props()
let loading: boolean = $state(false)
let selectedVersion: FlowVersion | undefined = undefined
let selected: Flow | undefined = undefined
let deploymentMsgUpdateMode = false
let deploymentMsgUpdate: string | undefined = undefined
let versions: FlowVersion[] = $state([])
let selectedVersion: FlowVersion | undefined = $state(undefined)
let selected: Flow | undefined = $state(undefined)
let deploymentMsgUpdateMode = $state(false)
let deploymentMsgUpdate: string | undefined = $state(undefined)
async function loadFlow(version: number) {
selected = await FlowService.getFlowVersion({
@@ -57,8 +64,6 @@
loadVersions()
}
const dispatch = createEventDispatcher()
async function restoreVersion(flow: Flow | undefined) {
if (!flow) return
await FlowService.updateFlow({
@@ -69,13 +74,15 @@
},
path
})
dispatch('historyRestore')
onHistoryRestore?.()
sendUserToast('Flow restored from previous deployment')
}
loadVersions()
$: selectedVersion !== undefined && loadFlow(selectedVersion.id)
run(() => {
selectedVersion !== undefined && loadFlow(selectedVersion.id)
})
</script>
<Splitpanes class="!overflow-visible">
@@ -85,7 +92,7 @@
{#if versions.length > 0}
<div class="flex gap-2 flex-col">
{#each versions ?? [] as version}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class={classNames(
'border flex gap-1 truncate justify-between flex-row w-full items-center p-2 rounded-md cursor-pointer hover:bg-surface-hover hover:text-primary',
@@ -93,7 +100,7 @@
)}
role="button"
tabindex="0"
on:click={() => {
onclick={() => {
selectedVersion = version
}}
>
@@ -124,10 +131,11 @@
type="text"
bind:value={deploymentMsgUpdate}
class="!w-auto grow"
on:click|stopPropagation={() => {}}
on:keydown|stopPropagation
on:keypress|stopPropagation={({ key }) => {
if (key === 'Enter') updateDeploymentMsg(selectedVersion?.id)
onclick={stopPropagation(() => {})}
onkeydown={stopPropagation(bubble('keydown'))}
onkeypress={(e) => {
e.stopPropagation()
if (e.key === 'Enter') updateDeploymentMsg(selectedVersion?.id)
}}
/>
<Button
@@ -163,7 +171,7 @@
Deployed {displayDate(selected.edited_at)} by {selected.edited_by}
{/if}
<button
on:click={() => {
onclick={() => {
deploymentMsgUpdate = selectedVersion?.deployment_msg
deploymentMsgUpdateMode = true
}}
@@ -1,3 +1,12 @@
<script lang="ts" module>
let cachedValues: Record<
string,
{
latestHash: string | undefined
}
> = {}
</script>
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import Button from '$lib/components/common/button/Button.svelte'
@@ -7,28 +16,55 @@
import { ScriptService, type FlowModuleValue, type PathScript } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { Lock, RefreshCw, Unlock } from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import { createEventDispatcher, untrack } from 'svelte'
export let flowModuleValue: FlowModuleValue | undefined = undefined
export let title: string | undefined = undefined
export let summary: string | undefined = undefined
interface Props {
flowModuleValue?: FlowModuleValue | undefined
title?: string | undefined
summary?: string | undefined
children?: import('svelte').Snippet
}
let {
flowModuleValue = undefined,
title = undefined,
summary = $bindable(undefined),
children
}: Props = $props()
let latestHash: string | undefined = $state(undefined)
function getCachedKey(path: string) {
return `${$workspaceStore}-${path}`
}
function getCachedValues(path: string) {
const key = getCachedKey(path)
latestHash = cachedValues[key]?.latestHash
}
if (flowModuleValue?.type === 'script' && flowModuleValue.path) {
getCachedValues(flowModuleValue.path)
}
let latestHash: string | undefined = undefined
async function loadLatestHash(value: PathScript) {
let script = await ScriptService.getScriptByPath({
workspace: $workspaceStore!,
path: value.path
})
const key = getCachedKey(value.path)
cachedValues[key] = {
latestHash: script.hash
}
latestHash = script.hash
}
const dispatch = createEventDispatcher()
$: $workspaceStore &&
flowModuleValue?.type === 'script' &&
flowModuleValue.path &&
!flowModuleValue.path.startsWith('hub/') &&
loadLatestHash(flowModuleValue)
$effect.pre(() => {
$workspaceStore &&
flowModuleValue?.type === 'script' &&
flowModuleValue.path &&
!flowModuleValue.path.startsWith('hub/') &&
untrack(() => loadLatestHash(flowModuleValue))
})
</script>
<div
@@ -114,5 +150,5 @@
{#if title}
<div class="text-sm font-bold text-primary pr-2">{title}</div>
{/if}
<slot />
{@render children?.()}
</div>
@@ -73,7 +73,7 @@
</script>
{#if $selectedId?.startsWith('settings')}
<FlowSettings {noEditor} />
<FlowSettings {enableAi} {noEditor} />
{:else if $selectedId === 'Input'}
<FlowInput
{noEditor}
@@ -11,7 +11,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import { createScriptFromInlineScript, fork } from '$lib/components/flows/flowStateUtils.svelte'
import type { FlowModule, RawScript } from '$lib/gen'
import type { FlowModule, RawScript, ScriptLang } from '$lib/gen'
import FlowCard from '../common/FlowCard.svelte'
import FlowModuleHeader from './FlowModuleHeader.svelte'
import { getLatestHashForScript, scriptLangToEditorLang } from '$lib/scripts'
@@ -99,7 +99,8 @@
highlightArg = undefined
}: Props = $props()
let tag: string | undefined = $state(undefined)
let workspaceScriptTag: string | undefined = $state(undefined)
let workspaceScriptLang: ScriptLang | undefined = $state(undefined)
let diffMode = $state(false)
let editor: Editor | undefined = $state()
@@ -247,7 +248,7 @@
)
)
$effect(() => {
$effect.pre(() => {
$selectedId && untrack(() => onSelectedIdChange())
})
$effect(() => {
@@ -311,6 +312,10 @@
;[flowModule.value.content, flowModule.value.language]
untrack(() => assets.refresh())
})
let rawScriptLang = $derived(
flowModule.value.type == 'rawscript' ? flowModule.value.language : undefined
)
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -333,7 +338,7 @@
>
{#snippet header()}
<FlowModuleHeader
{tag}
tag={workspaceScriptTag ?? rawScriptLang ?? workspaceScriptLang}
module={flowModule}
on:tagChange={(e) => {
console.log('tagChange', e.detail)
@@ -491,7 +496,8 @@
<div class="border-t">
{#key forceReload}
<FlowModuleScript
bind:tag
bind:tag={workspaceScriptTag}
bind:language={workspaceScriptLang}
showAllCode={false}
path={flowModule.value.path}
hash={flowModule.value.hash}
@@ -147,6 +147,8 @@
{/if}
{#if customUi?.tagEdit != false}
<FlowModuleWorkerTagSelect
placeholder={customUi?.tagSelectPlaceholder}
noLabel={customUi?.tagSelectNoLabel}
nullTag={tag}
tag={module.value.tag_override}
on:change={(e) => dispatch('tagChange', e.detail)}
@@ -192,6 +194,9 @@
<div class="px-0.5"></div>
{#if module.value.type === 'rawscript'}
<FlowModuleWorkerTagSelect
placeholder={customUi?.tagSelectPlaceholder}
noLabel={customUi?.tagSelectNoLabel}
nullTag={tag}
tag={module.value.tag}
on:change={(e) => dispatch('tagChange', e.detail)}
/>
@@ -1,25 +1,92 @@
<script lang="ts" module>
let cachedValues: Record<
string,
{
code: string | undefined
previousCode: string | undefined
language: ScriptLang | undefined
lock: string | undefined
date: string | undefined
notFound: boolean
tag: string | undefined
}
> = {}
</script>
<script lang="ts">
import type { SupportedLanguage } from '$lib/common'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import TimeAgo from '$lib/components/TimeAgo.svelte'
import { ScriptService } from '$lib/gen'
import { ScriptService, type ScriptLang } from '$lib/gen'
import { getScriptByPath, scriptLangToEditorLang } from '$lib/scripts'
import { workspaceStore } from '$lib/stores'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
export let path: string
export let hash: string | undefined = undefined
export let previousHash: string | undefined = undefined
export let showDate = false
export let showAllCode: boolean = true
export let tag: string | undefined = undefined
interface Props {
path: string
hash?: string | undefined
previousHash?: string | undefined
showDate?: boolean
showAllCode?: boolean
tag?: string | undefined
language?: ScriptLang | undefined
showDiff?: boolean
}
let code: string
let previousCode: string
let language: SupportedLanguage
let lock: string | undefined = undefined
let date: string | undefined = undefined
let notFound = false
let {
path,
hash = undefined,
previousHash = undefined,
showDate = false,
showAllCode = $bindable(true),
tag = $bindable(undefined),
showDiff = false,
language = $bindable(undefined)
}: Props = $props()
let code: string | undefined = $state()
let previousCode: string | undefined = $state()
let lock: string | undefined = $state(undefined)
let date: string | undefined = $state(undefined)
let notFound = $state(false)
function getCachedKey(path: string, hash: string | undefined) {
return `${$workspaceStore}-${path}-${hash ?? ''}`
}
function getCachedValues(path: string, hash: string | undefined) {
const key = getCachedKey(path, hash)
code = cachedValues[key]?.code
language = cachedValues[key]?.language
lock = cachedValues[key]?.lock
date = cachedValues[key]?.date
previousCode = cachedValues[key]?.previousCode
tag = cachedValues[key]?.tag
notFound = cachedValues[key]?.notFound ?? false
console.log('cachedValues', cachedValues, code)
}
getCachedValues(path, hash)
async function loadPreviousCode(previousHash: string) {
try {
const previousScript = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
hash: previousHash
})
previousCode = previousScript.content
const key = getCachedKey(path, previousHash)
cachedValues[key] = {
...(cachedValues[key] ?? {}),
previousCode: previousScript.content
}
} catch (e) {
console.error(e)
}
}
function toggleShowAll() {
showAllCode = !showAllCode
}
async function loadCode(path: string, hash: string | undefined) {
try {
@@ -33,32 +100,28 @@
lock = script.lock
date = script.created_at
tag = script.tag
const key = getCachedKey(path, hash)
cachedValues[key] = {
...(cachedValues[key] ?? {}),
code: script.content,
language: script.language,
lock: script.lock,
date: script.created_at,
tag: script.tag
}
} catch (e) {
notFound = true
console.error(e)
}
}
async function loadPreviousCode(previousHash: string) {
try {
const previousScript = await ScriptService.getScriptByHash({
workspace: $workspaceStore!,
hash: previousHash
})
previousCode = previousScript.content
} catch (e) {
console.error(e)
}
}
$: path && loadCode(path, hash)
$: path && previousHash && loadPreviousCode(previousHash)
function toggleShowAll() {
showAllCode = !showAllCode
}
export let showDiff: boolean = false
$effect.pre(() => {
hash
path && untrack(() => loadCode(path, hash))
})
$effect.pre(() => {
path && previousHash && untrack(() => loadPreviousCode(previousHash))
})
</script>
<div class="flex flex-col flex-1 h-full overflow-auto p-2">
@@ -72,7 +135,7 @@
<div class="text-red-400">script not found at {path} in workspace {$workspaceStore}</div>
{:else if showAllCode}
{#if showDiff}
{#key previousCode + code}
{#key (previousCode ?? '') + (code ?? '')}
{#await import('$lib/components/DiffEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
@@ -94,7 +157,7 @@
<div class="code-container h-full">
<HighlightCode {language} code={code?.split('\n').slice(0, 10).join('\n')} />
</div>
<button on:click={toggleShowAll}>Show all</button>
<button onclick={toggleShowAll}>Show all</button>
{/if}
{#if lock}
@@ -5,9 +5,16 @@
import { WorkerService } from '$lib/gen'
import WorkerTagSelect from '$lib/components/WorkerTagSelect.svelte'
let { tag = $bindable(), nullTag = $bindable() }: {
let {
tag = $bindable(),
nullTag,
placeholder,
noLabel
}: {
tag: string | undefined
nullTag?: string | undefined
placeholder?: string
noLabel?: boolean
} = $props()
const { flowStore, selectedId } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -26,7 +33,13 @@
{#if $workerTags?.length > 0}
<div class="w-40">
{#if flowStore.val.tag == undefined}
<WorkerTagSelect {nullTag} bind:tag on:change={(e) => dispatch('change', e.detail)} />
<WorkerTagSelect
{noLabel}
{placeholder}
{nullTag}
bind:tag
on:change={(e) => dispatch('change', e.detail)}
/>
{:else}
<button
title="Worker Group is defined at the flow level"
@@ -25,9 +25,10 @@
interface Props {
noEditor: boolean
enableAi?: boolean
}
let { noEditor }: Props = $props()
let { noEditor, enableAi }: Props = $props()
const { flowStore, initialPathStore, previewArgs, pathStore, customUi } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -125,7 +126,7 @@
/>
</Label>
{#if flowStore.val.schema}
{#if flowStore.val.schema && enableAi}
<AIFormSettings
bind:prompt={flowStore.val.schema.prompt_for_ai as string | undefined}
type="flow"
@@ -1,87 +0,0 @@
import type { Flow, OpenFlow } from '$lib/gen'
import { writable, type Writable } from 'svelte/store'
import { initFlowState, type FlowState } from './flowState'
import { sendUserToast } from '$lib/toast'
import type { StateStore } from '$lib/utils'
export type FlowMode = 'push' | 'pull'
export const importFlowStore = writable<Flow | undefined>(undefined)
export async function initFlow(
flow: Flow,
flowStore: StateStore<Flow>,
flowStateStore: Writable<FlowState>
) {
await initFlowState(flow, flowStateStore)
flowStore.val = flow
}
export async function copyFirstStepSchema(
flowState: FlowState,
flowStore: StateStore<OpenFlow>
): Promise<void> {
const firstModuleId = flowStore.val.value.modules[0]?.id
if (flowState[firstModuleId] && firstModuleId) {
flowStore.val.schema = structuredClone($state.snapshot(flowState[firstModuleId].schema))
const v = flowStore.val.value.modules[0].value
if (v.type == 'rawscript' || v.type == 'script') {
Object.keys(v.input_transforms ?? {}).forEach((key) => {
v.input_transforms[key] = {
type: 'javascript',
expr: `flow_input.${key}`
}
})
return
}
return sendUserToast('Only scripts can be used as a input schema', true)
}
return sendUserToast('No first step found', true)
}
export async function getFirstStepSchema(flowState: FlowState, flow: OpenFlow) {
const firstModuleId = flow.value.modules[0]?.id
if (!firstModuleId || !flowState[firstModuleId]) {
throw new Error('no first step found')
}
const schema = structuredClone(flowState[firstModuleId].schema)
const v = flow.value.modules[0].value
if (v.type !== 'rawscript' && v.type !== 'script') {
throw new Error('only scripts can be used as a input schema')
}
const simplifiedModule = {
id: flow.value.modules[0].id,
summary: flow.value.modules[0].summary,
value: {
type: flow.value.modules[0].value.type,
...('path' in flow.value.modules[0].value ? { path: flow.value.modules[0].value.path } : {}),
...('language' in flow.value.modules[0].value
? { language: flow.value.modules[0].value.language }
: {})
}
}
return {
schema,
mod: simplifiedModule,
connectFirstNode: () => {
Object.keys(v.input_transforms ?? {}).forEach((key) => {
v.input_transforms[key] = {
type: 'javascript',
expr: `flow_input.${key}`
}
})
}
}
}
export function replaceId(expr: string, id: string, newId: string): string {
return expr
.replaceAll(`results.${id}`, `results.${newId}`)
.replaceAll(`results?.${id}`, `results?.${newId}`)
}
+21 -24
View File
@@ -3,7 +3,6 @@ import { writable, type Writable } from 'svelte/store'
import { initFlowState, type FlowState } from './flowState'
import { sendUserToast } from '$lib/toast'
import type { StateStore } from '$lib/utils'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
export type FlowMode = 'push' | 'pull'
@@ -18,39 +17,37 @@ export async function initFlow(
flowStore.val = flow
}
export async function copyFirstStepSchema(flowState: FlowState, flowStore: Writable<OpenFlow>) {
flowStore.update((flow) => {
const firstModuleId = flow.value.modules[0]?.id
export async function copyFirstStepSchema(
flowState: FlowState,
flowStore: StateStore<OpenFlow>
): Promise<void> {
const firstModuleId = flowStore.val.value.modules[0]?.id
if (flowState[firstModuleId] && firstModuleId) {
flow.schema = structuredClone(stateSnapshot(flowState[firstModuleId].schema))
const v = flow.value.modules[0].value
if (v.type == 'rawscript' || v.type == 'script') {
Object.keys(v.input_transforms ?? {}).forEach((key) => {
v.input_transforms[key] = {
type: 'javascript',
expr: `flow_input.${key}`
}
})
return flow
}
sendUserToast('Only scripts can be used as a input schema', true)
return flow
if (flowState[firstModuleId] && firstModuleId) {
flowStore.val.schema = structuredClone($state.snapshot(flowState[firstModuleId].schema))
const v = flowStore.val.value.modules[0].value
if (v.type == 'rawscript' || v.type == 'script') {
Object.keys(v.input_transforms ?? {}).forEach((key) => {
v.input_transforms[key] = {
type: 'javascript',
expr: `flow_input.${key}`
}
})
return
}
sendUserToast('No first step found', true)
return flow
})
return sendUserToast('Only scripts can be used as a input schema', true)
}
return sendUserToast('No first step found', true)
}
export async function getFirstStepSchema(flowState: FlowState, flowStore: StateStore<OpenFlow>) {
const flow = flowStore.val
export async function getFirstStepSchema(flowState: FlowState, flow: OpenFlow) {
const firstModuleId = flow.value.modules[0]?.id
if (!firstModuleId || !flowState[firstModuleId]) {
throw new Error('no first step found')
}
const schema = structuredClone(stateSnapshot(flowState[firstModuleId].schema))
const schema = structuredClone(flowState[firstModuleId].schema)
const v = flow.value.modules[0].value
if (v.type !== 'rawscript' && v.type !== 'script') {
@@ -28,7 +28,7 @@
import { Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { getDependeeAndDependentComponents } from '../flowExplorer'
import { replaceId } from '../flowStore.svelte'
import { replaceId } from '../flowStore'
import FlowModuleSchemaItemViewer from './FlowModuleSchemaItemViewer.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import OutputPicker from '$lib/components/flows/propPicker/OutputPicker.svelte'
@@ -28,7 +28,7 @@
import { ignoredTutorials } from '$lib/components/tutorials/ignoredTutorials'
import { tutorialInProgress } from '$lib/tutorialUtils'
import FlowGraphV2 from '$lib/components/graph/FlowGraphV2.svelte'
import { replaceId } from '../flowStore.svelte'
import { replaceId } from '../flowStore'
import { setScheduledPollSchedule, type TriggerContext } from '$lib/components/triggers'
import type { PropPickerContext } from '$lib/components/prop_picker'
import { JobService } from '$lib/gen'
@@ -388,8 +388,7 @@
let moduleCounter = $state(0)
function onModulesChange2(modules) {
if (!deepEqual(modules, lastModules)) {
console.log('modules changed', modules)
lastModules = structuredClone($state.snapshot(modules))
lastModules = $state.snapshot(modules)
moduleCounter++
}
}
@@ -416,6 +415,7 @@
return
}
let newGraph = graph
newGraph.nodes.sort((a, b) => b.id.localeCompare(a.id))
;[nodes, edges] = computeAssetNodes(layoutNodes(newGraph.nodes), newGraph.edges, assetsMap, {
moving,
eventHandlers: eventHandler,
@@ -659,8 +659,8 @@
<UnsavedConfirmationModal {diffDrawer} {getInitialAndModifiedValues} />
<DeployOverrideConfirmationModal
bind:deployedBy
bind:confirmCallback
{deployedBy}
{confirmCallback}
bind:open
{diffDrawer}
bind:deployedValue
@@ -0,0 +1,13 @@
<script lang="ts">
import type { EditableSchemaWrapperProps } from './editable_schema_wrapper'
import EditableSchemaWrapper from './EditableSchemaWrapper.svelte'
let {
schema: oldSchema,
...props
}: EditableSchemaWrapperProps = $props()
let schema = $state(oldSchema)
</script>
<EditableSchemaWrapper {schema} {...props} />
@@ -1,8 +1,6 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { twMerge } from 'tailwind-merge'
import EditableSchemaForm from '../EditableSchemaForm.svelte'
import { createEventDispatcher } from 'svelte'
import Toggle from '../Toggle.svelte'
import { emptySchema, validateFileExtension } from '$lib/utils'
import { Alert } from '../common'
@@ -10,29 +8,21 @@
import { Plus } from 'lucide-svelte'
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
interface Props {
schema: Schema | undefined | any
uiOnly?: boolean
noPreview?: boolean
fullHeight?: boolean
formatExtension?: string | undefined
}
import type { EditableSchemaWrapperProps } from './editable_schema_wrapper'
let {
schema = $bindable(),
uiOnly = false,
noPreview = false,
fullHeight = true,
formatExtension = $bindable(undefined)
}: Props = $props()
formatExtension = $bindable(undefined),
onSchemaChange
}: EditableSchemaWrapperProps = $props()
let resourceIsTextFile: boolean = $state(false)
let addPropertyComponent: AddPropertyV2 | undefined = $state(undefined)
let editableSchemaForm: EditableSchemaForm | undefined = $state(undefined)
const dispatch = createEventDispatcher()
$effect(() => {
if (!resourceIsTextFile && formatExtension !== undefined) {
formatExtension = undefined
@@ -60,7 +50,7 @@
}
}
}
dispatch('change', schema)
onSchemaChange?.({ schema: $state.snapshot(schema) })
}
let suggestedFileExtensions = $state([
@@ -89,7 +79,7 @@
<AddPropertyV2
bind:schema
bind:this={addPropertyComponent}
on:change={() => dispatch('change', schema)}
on:change={() => onSchemaChange?.({ schema: $state.snapshot(schema) })}
on:addNew={(e) => {
editableSchemaForm?.openField(e.detail)
}}
@@ -107,7 +97,7 @@
onlyMaskPassword
bind:this={editableSchemaForm}
bind:schema
on:change={() => dispatch('change', schema)}
on:change={() => onSchemaChange?.({ schema: $state.snapshot(schema) })}
isFlowInput
on:edit={(e) => {
addPropertyComponent?.openDrawer(e.detail)
@@ -124,7 +114,7 @@
<AddPropertyV2
bind:schema
bind:this={addPropertyComponent}
on:change={() => dispatch('change', schema)}
on:change={() => onSchemaChange?.({ schema })}
>
{#snippet trigger()}
<div
@@ -0,0 +1,10 @@
import type { Schema } from '$lib/common'
export type EditableSchemaWrapperProps = {
schema: Schema | undefined | any
uiOnly?: boolean
noPreview?: boolean
fullHeight?: boolean
formatExtension?: string | undefined
onSchemaChange?: ({ schema }: { schema: Schema }) => void
}
@@ -0,0 +1,39 @@
import type { NewScript } from '$lib/gen'
import type { AssetWithAccessType } from './assets/lib'
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
import type { DiffDrawerI } from './diff_drawer'
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
import type { ScheduleTrigger } from './triggers'
import type { NewScriptWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
export interface ScriptBuilderProps {
script: NewScript & {
draft_triggers?: Trigger[]
fallback_access_types?: AssetWithAccessType[]
}
disableAi?: boolean
fullyLoaded?: boolean
initialPath?: string
template?: 'docker' | 'bunnative' | 'script'
initialArgs?: Record<string, any>
lockedLanguage?: boolean
showMeta?: boolean
neverShowMeta?: boolean
diffDrawer?: DiffDrawerI | undefined
savedScript?: NewScriptWithDraftAndDraftTriggers | undefined
searchParams?: URLSearchParams
disableHistoryChange?: boolean
replaceStateFn?: (url: string) => void
customUi?: ScriptBuilderWhitelabelCustomUi
savedPrimarySchedule?: ScheduleTrigger | undefined
functionExports?: ((exports: ScriptBuilderFunctionExports) => void) | undefined
children?: import('svelte').Snippet
onDeploy?: (e: { path: string; hash: string }) => void
onDeployError?: (e: { path: string; error: any }) => void
onSaveInitial?: (e: { path: string; hash: string }) => void
onHistoryRestore?: () => void
onSaveDraftOnlyAtNewPath?: (e: { path: string }) => void
onSaveDraft?: (e: { path: string; savedAtNewPath: boolean; script: NewScript }) => void
onSeeDetails?: (e: { path: string }) => void
onSaveDraftError?: (e: { path: string; error: any }) => void
}
@@ -25,7 +25,8 @@
Route,
Search,
SearchCode,
Unplug
Unplug,
WandSparkles
} from 'lucide-svelte'
import Portal from '$lib/components/Portal.svelte'
@@ -237,7 +238,7 @@
let opts: uFuzzy.Options = {}
let uf = new uFuzzy(opts)
let defaultMenuItemLabels = defaultMenuItems.map((item) => item.label)
// let defaultMenuItemLabels = defaultMenuItems.map((item) => item.label)
let defaultMenuItemAndHiddenLabels = defaultMenuItemsWithHidden.map((item) => item.label)
let switchModeItemLabels = switchModeItems.map((item) => item.label)
let askAiButton: AskAiButton | undefined = $state()
@@ -293,8 +294,7 @@
}
if (tab === 'default') {
if (searchTerm === '')
itemMap['default'] = fuzzyFilter(searchTerm, defaultMenuItems, defaultMenuItemLabels)
if (searchTerm === '') itemMap['default'] = defaultMenuItems
else
itemMap['default'] = fuzzyFilter(
searchTerm,
@@ -372,9 +372,6 @@
}
}
}
if ((itemMap[tab] ?? []).length === 0 && searchTerm.length > 0 && event.key === 'Enter') {
askAiButton?.onClick()
}
}
}
@@ -653,10 +650,14 @@
<div class="overflow-y-auto relative {maxModalHeight(tab)}">
{#if tab === 'default' || tab === 'switch-mode'}
{@const items = (itemMap[tab] ?? []).filter((e) =>
defaultMenuItemsWithHidden.includes(e)
defaultMenuItemsWithHidden.some((x) => e.search_id === x.search_id)
)}
{#if items.length > 0}
<div class={tab === 'switch-mode' ? 'p-2' : 'p-2 border-b'}>
<div
class={tab === 'switch-mode' || itemMap[tab].length === items.length
? 'p-2'
: 'p-2 border-b'}
>
{#each items as el}
<QuickMenuItem
onselect={(shift) => el?.action(shift)}
@@ -674,8 +675,8 @@
{/if}
{#if tab === 'default'}
<div class="p-2">
{#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0}
{#if (itemMap[tab] ?? []).filter((e) => (combinedItems ?? []).includes(e)).length > 0}
<div class="p-2">
<div class="py-2 px-1 text-xs font-semibold text-tertiary">
Flows/Scripts/Apps
</div>
@@ -694,19 +695,28 @@
bind:mouseMoved
/>
{/each}
{/if}
</div>
{/if}
{#if (itemMap[tab] ?? []).length === 0}
{#if (itemMap[tab] ?? []).length === 0}
<div class="p-2">
<QuickMenuItem
onselect={() => {
askAiButton?.onClick()
}}
id={'ai:no-results-ask-ai'}
hovered={true}
label={`Try asking \`${searchTerm}\` to AI`}
icon={WandSparkles}
bind:mouseMoved
/>
<div class="flex w-full justify-center items-center">
<div class="text-tertiary text-center">
<div class="text-2xl font-bold"
>Nothing found, ask the AI to find what you need!</div
>
<div class="text-sm">Tip: press `esc` to quickly clear the search bar</div>
<div class="pt-1 text-sm">Tip: press `esc` to quickly clear the search bar</div>
</div>
</div>
{/if}
</div>
</div>
{/if}
{:else if tab === 'content'}
<ContentSearchInner
search={removePrefix(searchTerm, '#')}
@@ -108,7 +108,7 @@
</div>
{:else if clearable && !disabled && value}
<div class="absolute z-10 right-2 h-full flex items-center">
<CloseButton noBg small on:close={clearValue} />
<CloseButton class="text-secondary" noBg small on:close={clearValue} />
</div>
{:else if RightIcon}
<div class="absolute z-10 right-2 h-full flex items-center">
@@ -16,7 +16,7 @@ export function processItems<Item extends { label?: string; value: any }>({
let items2 =
items?.map((item) => ({
...item,
label: getLabel(item)
label: getLabel(item) ?? ''
})) ?? []
if (groupBy) {
items2 =
@@ -46,12 +46,12 @@ export type ProcessedItem<T> = {
subtitle?: string
}
export function getLabel<T>(item: { label?: string; value: T } | undefined): string {
export function getLabel<T>(item: { label?: string; value: T } | undefined): string | undefined {
if (!item) return ''
if (item.label) return item.label
if (typeof item.value === 'string') return item.value
if (typeof item.value == 'number' || typeof item.value == 'boolean') return item.value.toString()
if (item.value == null) { return undefined }
return JSON.stringify(item.value)
}
@@ -17,11 +17,11 @@
import ScriptPicker from '$lib/components/ScriptPicker.svelte'
import Required from '$lib/components/Required.svelte'
import GcpTriggerEditorConfigSection from './GcpTriggerEditorConfigSection.svelte'
import { base } from '$app/paths'
import { untrack, type Snippet } from 'svelte'
import TriggerEditorToolbar from '../TriggerEditorToolbar.svelte'
import { saveGcpTriggerFromCfg } from './utils'
import { handleConfigChange, type Trigger } from '../utils'
import { base } from '$lib/base'
let drawer: Drawer | undefined = $state(undefined)
let is_flow: boolean = $state(false)
@@ -1,5 +1,4 @@
import { JobService, ScheduleService } from "$lib/gen"
import { goto } from "$lib/navigation"
import { sendUserToast } from "$lib/utils"
export async function runScheduleNow(
@@ -25,7 +24,7 @@ export async function runScheduleNow(
sendUserToast(`Schedule ${path} will run now`, false, [
{
label: 'Go to the run page',
callback: () => goto('/run/' + run + '?workspace=' + workspace_id)
callback: () => window.open('/run/' + run + '?workspace=' + workspace_id, '_blank')
}
])
} catch (err) {
+1 -1
View File
@@ -29,5 +29,5 @@ export async function clearUser() {
try {
clearStores()
await UserService.logout()
} catch (error) {}
} catch (error) { }
}
+15 -3
View File
@@ -13,6 +13,7 @@ import {
WorkspaceService
} from './gen'
import { getLocalSetting } from './utils'
import { workspaceAIClients } from './components/copilot/lib'
export interface UserExt {
email: string
@@ -106,6 +107,17 @@ export const copilotInfo = writable<{
aiModels: []
})
export async function loadCopilot(workspace: string) {
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
setCopilotInfo(info)
} catch (err) {
setCopilotInfo({})
console.error('Could not get copilot info', err)
}
}
export function setCopilotInfo(aiConfig: AIConfig) {
if (Object.keys(aiConfig.providers ?? {}).length > 0) {
const aiModels = Object.entries(aiConfig.providers ?? {}).flatMap(
@@ -162,9 +174,9 @@ const sessionProvider = getLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME)
export const copilotSessionModel = writable<AIProviderModel | undefined>(
sessionModel && sessionProvider
? {
model: sessionModel,
provider: sessionProvider as AIProvider
}
model: sessionModel,
provider: sessionProvider as AIProvider
}
: undefined
)
export const usedTriggerKinds = writable<string[]>([])
+5 -6
View File
@@ -1,5 +1,4 @@
// /* eslint-disable @typescript-eslint/explicit-module-boundary-types */
// import { goto } from '$lib/navigation'
// import { AppService, type Flow, FlowService, Script, ScriptService, type User } from '$lib/gen'
// import { toast } from '@zerodevx/svelte-toast'
// import type { Schema, SupportedLanguage } from './common'
@@ -166,9 +165,9 @@ export function displayDate(
}
const dateChoices: Intl.DateTimeFormatOptions = displayDate
? {
day: 'numeric',
month: 'numeric'
}
day: 'numeric',
month: 'numeric'
}
: {}
return date.toLocaleString(undefined, {
...timeChoices,
@@ -974,7 +973,7 @@ export async function tryEvery({
try {
await tryCode()
break
} catch (err) {}
} catch (err) { }
i++
}
if (i >= times) {
@@ -1241,7 +1240,7 @@ export function conditionalMelt(node: HTMLElement, meltItem: AnyMeltElement | un
if (meltItem) {
return meltItem(node)
}
return { destroy: () => {} }
return { destroy: () => { } }
}
export type Item = {
@@ -29,7 +29,6 @@
hubBaseUrlStore,
usedTriggerKinds,
devopsRole,
setCopilotInfo,
whitelabelNameStore
} from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
@@ -45,7 +44,6 @@
import { syncTutorialsTodos } from '$lib/tutorialUtils'
import { ArrowLeft, Search, WandSparkles } from 'lucide-svelte'
import { getUserExt } from '$lib/user'
import { workspaceAIClients } from '$lib/components/copilot/lib'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
@@ -54,10 +52,7 @@
import { base } from '$app/paths'
import { Menubar } from '$lib/components/meltComponents'
import { aiChatManager } from '$lib/components/copilot/chat/AIChatManager.svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AiChat from '$lib/components/copilot/chat/AIChat.svelte'
import { zIndexes } from '$lib/zIndexes'
import { chatState } from '$lib/components/copilot/chat/sharedChatState.svelte'
import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte'
interface Props {
children?: import('svelte').Snippet
}
@@ -259,24 +254,7 @@
}
}
let devOnly = $page.url.pathname.startsWith(base + '/scripts/dev')
async function loadCopilot(workspace: string) {
workspaceAIClients.init(workspace)
try {
const info = await WorkspaceService.getCopilotInfo({ workspace })
setCopilotInfo(info)
} catch (err) {
setCopilotInfo({})
console.error('Could not get copilot info', err)
}
}
workspaceStore.subscribe(async (workspace) => {
if (workspace) {
loadCopilot(workspace)
}
})
let devOnly = $derived($page.url.pathname.startsWith(base + '/scripts/dev'))
async function loadDefaultScripts(workspace: string, user: UserExt | undefined) {
if (!user?.operator) {
@@ -691,64 +669,14 @@
</div>
</div>
{/if}
<Splitpanes horizontal={false} class="flex-1 min-h-0">
<Pane size={99.8 - chatState.size} minSize={50} class="flex flex-col min-h-0">
<div
id="content"
class={classNames(
'w-full flex-1 flex flex-col overflow-y-auto',
devOnly || $userStore?.operator ? '!pl-0' : isCollapsed ? 'md:pl-12' : 'md:pl-40',
'transition-all ease-in-out duration-200'
)}
>
<main class="flex-1 flex flex-col">
<div class="relative w-full flex-1 flex flex-col">
<div
class={classNames(
'pt-2 px-4 sm:px-4 flex flex-row justify-between items-center shadow-sm max-w-7xl md:hidden',
devOnly || $userStore?.operator ? 'hidden' : ''
)}
>
<button
aria-label="Menu"
type="button"
onclick={() => {
menuOpen = true
}}
class="h-8 w-8 inline-flex items-center justify-center rounded-md text-tertiary hover:text-primary focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
>
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
<div class="flex-1">
{@render children?.()}
</div>
</div>
</main>
</div>
</Pane>
<Pane
bind:size={chatState.size}
minSize={15}
class={`flex flex-col min-h-0 z-[${zIndexes.aiChat}]`}
>
<AiChat />
</Pane>
</Splitpanes>
<AiChatLayout
{children}
noPadding={devOnly}
{isCollapsed}
onMenuOpen={() => {
menuOpen = true
}}
/>
</div>
{:else}
<CenteredModal title="Loading user...">
@@ -1,40 +1,82 @@
<script lang="ts">
import { page } from '$app/stores'
import { page } from '$app/state'
import type { ActionKind } from '$lib/common'
import Tooltip from '$lib/components/Tooltip.svelte'
import AuditLogDetails from '$lib/components/auditLogs/AuditLogDetails.svelte'
import AuditLogsFilters from '$lib/components/auditLogs/AuditLogsFilters.svelte'
import AuditLogsTable from '$lib/components/auditLogs/AuditLogsTable.svelte'
import AuditLogMobileFilters from '$lib/components/auditLogs/AuditLogMobileFilters.svelte'
import { Alert, DrawerContent } from '$lib/components/common'
import { Alert, DrawerContent, Skeleton } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
import type { AuditLog } from '$lib/gen'
import { AuditService } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import AuditLogsTimeline from '$lib/components/auditLogs/AuditLogsTimeline.svelte'
let username: string = $state($page.url.searchParams.get('username') ?? 'all')
let pageIndex: number | undefined = $state(Number($page.url.searchParams.get('page')) || 0)
let before: string | undefined = $state($page.url.searchParams.get('before') ?? undefined)
let username: string = $state(page.url.searchParams.get('username') ?? 'all')
let pageIndex: number | undefined = $state(Number(page.url.searchParams.get('page')) || 0)
let before: string | undefined = $state(page.url.searchParams.get('before') ?? undefined)
let hasMore: boolean = $state(false)
let after: string | undefined = $state($page.url.searchParams.get('after') ?? undefined)
let perPage: number | undefined = $state(Number($page.url.searchParams.get('perPage')) || 100)
let operation: string = $state($page.url.searchParams.get('operation') ?? 'all')
let resource: string | undefined = $state($page.url.searchParams.get('resource') ?? undefined)
let after: string | undefined = $state(page.url.searchParams.get('after') ?? undefined)
let perPage: number | undefined = $state(Number(page.url.searchParams.get('perPage')) || 100)
let operation: string = $state(page.url.searchParams.get('operation') ?? 'all')
let resource: string | undefined = $state(page.url.searchParams.get('resource') ?? undefined)
let scope: undefined | 'all_workspaces' | 'instance' = $state(
($page.url.searchParams.get('scope') ?? undefined) as undefined | 'all_workspaces' | 'instance'
(page.url.searchParams.get('scope') ?? undefined) as undefined | 'all_workspaces' | 'instance'
)
let actionKind: ActionKind | 'all' = $state(
($page.url.searchParams.get('actionKind') as ActionKind) ?? 'all'
(page.url.searchParams.get('actionKind') as ActionKind) ?? 'all'
)
let logs: AuditLog[] | undefined = $state()
let selectedId: number | undefined = $state(undefined)
let auditLogDrawer: Drawer | undefined = $state()
// Function to fetch missing job execution audit logs
async function fetchMissingJobSpan(jobId: string, jobLogs: AuditLog[]): Promise<AuditLog[]> {
if (jobLogs.length === 0) return []
const firstJobLog = jobLogs[0]
const timeBuffer = 10000 // 10 seconds buffer for safety
// Create time range around the job execution
const jobTime = new Date(firstJobLog.timestamp).getTime()
const beforeTime = new Date(jobTime + timeBuffer).toISOString()
const afterTime = new Date(jobTime - timeBuffer).toISOString()
try {
// Try multiple operation patterns to find the job execution
const operationPatterns = ['jobs.run', 'jobs.run.script', 'jobs.run.flow', 'jobs.run.preview']
for (const operation of operationPatterns) {
const additionalLogs = await AuditService.listAuditLogs({
workspace: scope === 'instance' ? 'global' : $workspaceStore!,
username: firstJobLog.username,
operation: operation,
before: beforeTime,
after: afterTime,
perPage: 100,
allWorkspaces: scope === 'all_workspaces'
})
// Check if we found the job execution log
const jobExecutionLog = additionalLogs.find((log) => log.parameters?.uuid === jobId)
if (jobExecutionLog) {
return additionalLogs
}
}
return []
} catch (error) {
return []
}
}
</script>
{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.audit_logs}
@@ -43,15 +85,15 @@
<p>Page not available for operators</p>
</div>
{:else}
<div class="w-full h-screen">
<div class="px-2">
<div class="flex items-center space-x-2 flex-row justify-between">
<div class="flex flex-row flex-wrap justify-between py-2 my-4 px-4 gap-1 items-center">
<h1 class="!text-2xl font-semibold leading-6 tracking-tight">Audit logs</h1>
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/audit_logs">
You can only see your own audit logs unless you are an admin.
</Tooltip>
</div>
<div class="flex flex-col w-full h-screen">
<div class="flex items-center space-x-2 flex-row justify-between">
<div class="flex flex-row flex-wrap justify-between py-2 my-4 px-4 gap-1 items-center">
<h1 class="!text-2xl font-semibold leading-6 tracking-tight">Audit logs</h1>
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/audit_logs">
You can only see your own audit logs unless you are an admin.
</Tooltip>
</div>
<div class="flex flex-row flex-wrap justify-between py-2 my-2 px-4 gap-1 items-center">
<div class="hidden 2xl:block">
<AuditLogsFilters
bind:logs
@@ -85,56 +127,85 @@
</AuditLogMobileFilters>
</div>
</div>
{#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
<Alert title="Redacted audit logs" type="warning">
You need an enterprise license to see unredacted audit logs.
</Alert>
<div class="py-2"></div>
</div>
<div class="h-2/6">
{#if logs}
<AuditLogsTimeline
{logs}
minTimeSet={after}
maxTimeSet={before}
onZoom={({ min, max }) => {
before = max.toISOString()
after = min.toISOString()
console.log('zoom!')
}}
onMissingJobSpan={fetchMissingJobSpan}
onLogSelected={(log) => {
console.log('selected log ')
selectedId = log.id
}}
/>
{/if}
</div>
<SplitPanesWrapper class="hidden md:block">
<Splitpanes>
<Pane size={70} minSize={50}>
<AuditLogsTable
{logs}
{selectedId}
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
bind:hasMore
on:select={(e) => {
selectedId = e.detail
}}
/>
</Pane>
<Pane size={30} minSize={15}>
{#if logs}
<AuditLogDetails {logs} {selectedId} />
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
<div class="flex-grow w-full">
<div class="px-2">
{#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
<Alert title="Redacted audit logs" type="warning">
You need an enterprise license to see unredacted audit logs.
</Alert>
<div class="py-2"></div>
{/if}
</div>
<SplitPanesWrapper>
<Splitpanes>
<Pane size={70} minSize={50}>
{#if logs}
<AuditLogsTable
{logs}
{selectedId}
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
bind:hasMore
onselect={(id) => {
selectedId = id
}}
/>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</Pane>
<Pane size={30} minSize={15}>
{#if logs}
<AuditLogDetails {logs} {selectedId} />
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
<div class="md:hidden">
<AuditLogsTable
{logs}
bind:hasMore
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
on:select={(e) => {
selectedId = e.detail
auditLogDrawer?.openDrawer()
}}
/>
<div class="md:hidden">
<AuditLogsTable
{logs}
bind:hasMore
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
onselect={(id) => {
selectedId = id
auditLogDrawer?.openDrawer()
}}
/>
</div>
</div>
</div>
{/if}
@@ -6,7 +6,7 @@
import FlowBuilder from '$lib/components/FlowBuilder.svelte'
import UnsavedConfirmationModal from '$lib/components/common/confirmationModal/UnsavedConfirmationModal.svelte'
import type { FlowState } from '$lib/components/flows/flowState'
import { importFlowStore, initFlow } from '$lib/components/flows/flowStore.svelte'
import { importFlowStore, initFlow } from '$lib/components/flows/flowStore'
import { FlowService, type Flow } from '$lib/gen'
import { initialArgsStore, userStore, workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
@@ -163,14 +163,14 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<FlowBuilder
on:saveInitial={(e) => {
goto(`/flows/edit/${e.detail}?selected=${flowBuilder?.getSelectedId?.()}`)
onSaveInitial={(e) => {
goto(`/flows/edit/${e.path}?selected=${e.id}`)
}}
on:deploy={(e) => {
goto(`/flows/get/${e.detail}?workspace=${$workspaceStore}`)
onDeploy={(e) => {
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
on:details={(e) => {
goto(`/flows/get/${e.detail}?workspace=${$workspaceStore}`)
onDetails={(e) => {
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
{initialPath}
{pathStoreInit}
@@ -11,7 +11,7 @@
orderedJsonStringify,
type StateStore
} from '$lib/utils'
import { initFlow } from '$lib/components/flows/flowStore.svelte'
import { initFlow } from '$lib/components/flows/flowStore'
import { goto } from '$lib/navigation'
import { afterNavigate, replaceState } from '$app/navigation'
import { writable } from 'svelte/store'
@@ -79,7 +79,7 @@
| undefined = $state(undefined)
let flowBuilder: FlowBuilder | undefined = $state(undefined)
let notFound = $state(false)
async function loadFlow(): Promise<void> {
console.log('loadFlow')
loading = true
@@ -93,7 +93,13 @@
workspace: $workspaceStore!,
path: statePath
})
).id
)?.id
if (version == undefined) {
notFound = true
sendUserToast(`Flow not found at path ${statePath}`, true)
return
}
savedFlow = await FlowService.getFlowByPathWithDraft({
workspace: $workspaceStore!,
@@ -262,38 +268,44 @@
<!-- <div id="monaco-widgets-root" class="monaco-editor" style="z-index: 1200;" /> -->
<DiffDrawer bind:this={diffDrawer} {restoreDeployed} {restoreDraft} />
<FlowBuilder
on:deploy={(e) => {
goto(`/flows/get/${e.detail}?workspace=${$workspaceStore}`)
}}
on:details={(e) => {
goto(`/flows/get/${e.detail}?workspace=${$workspaceStore}`)
}}
on:saveDraftOnlyAtNewPath={(e) => {
const { path, selectedId } = e.detail
goto(`/flows/edit/${path}?selected=${selectedId}`)
}}
on:historyRestore={() => {
loadFlow()
}}
{flowStore}
{flowStateStore}
initialPath={$page.params.path}
newFlow={false}
{selectedId}
{initialArgs}
{loading}
bind:this={flowBuilder}
bind:savedFlow
{diffDrawer}
{savedPrimarySchedule}
{draftTriggersFromUrl}
{selectedTriggerIndexFromUrl}
{version}
{loadedFromHistoryFromUrl}
>
<UnsavedConfirmationModal
{#if notFound}
<div class="flex flex-col items-center justify-center h-full">
<h1 class="text-2xl font-bold">Flow not found at path {$page.params.path}</h1>
<p class="text-gray-500">The flow you are looking for does not exist.</p>
</div>
{:else}
<FlowBuilder
onDeploy={(e) => {
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onDetails={(e) => {
goto(`/flows/get/${e.path}?workspace=${$workspaceStore}`)
}}
onSaveDraftOnlyAtNewPath={(e) => {
goto(`/flows/edit/${e.path}?selected=${e.selectedId}`)
}}
onHistoryRestore={() => {
loadFlow()
}}
{flowStore}
{flowStateStore}
initialPath={$page.params.path}
newFlow={false}
{selectedId}
{initialArgs}
{loading}
bind:this={flowBuilder}
bind:savedFlow
{diffDrawer}
getInitialAndModifiedValues={flowBuilder?.getInitialAndModifiedValues}
/>
</FlowBuilder>
{savedPrimarySchedule}
{draftTriggersFromUrl}
{selectedTriggerIndexFromUrl}
{version}
{loadedFromHistoryFromUrl}
>
<UnsavedConfirmationModal
{diffDrawer}
getInitialAndModifiedValues={flowBuilder?.getInitialAndModifiedValues}
/>
</FlowBuilder>
{/if}
@@ -417,7 +417,7 @@
}}
/>
{#if flow}
<FlowHistory bind:this={flowHistory} path={flow.path} on:historyRestore={loadFlow} />
<FlowHistory bind:this={flowHistory} path={flow.path} onHistoryRestore={loadFlow} />
{/if}
{#if flow}
@@ -104,13 +104,11 @@
{initialArgs}
bind:this={scriptBuilder}
lockedLanguage={templatePath != null || hubPath != null}
on:deploy={(e) => {
let newHash = e.detail
goto(`/scripts/get/${newHash}?workspace=${$workspaceStore}`)
onDeploy={(e) => {
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
on:saveInitial={(e) => {
let path = e.detail
goto(`/scripts/edit/${path}`)
onSaveInitial={(e) => {
goto(`/scripts/edit/${e.path}`)
}}
searchParams={$page.url.searchParams}
{script}
@@ -213,17 +213,14 @@
{diffDrawer}
{savedPrimarySchedule}
searchParams={$page.url.searchParams}
on:deploy={(e) => {
let newHash = e.detail
goto(`/scripts/get/${newHash}?workspace=${$workspaceStore}`)
onDeploy={(e) => {
goto(`/scripts/get/${e.hash}?workspace=${$workspaceStore}`)
}}
on:saveInitial={(e) => {
let path = e.detail
goto(`/scripts/edit/${path}`)
onSaveInitial={(e) => {
goto(`/scripts/edit/${e.path}`)
}}
on:seeDetails={(e) => {
let path = e.detail
goto(`/scripts/get/${path}?workspace=${$workspaceStore}`)
onSeeDetails={(e) => {
goto(`/scripts/get/${e.path}?workspace=${$workspaceStore}`)
}}
replaceStateFn={(path) => {
replaceState(path, $page.state)
@@ -0,0 +1,51 @@
<script lang="ts">
import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui'
import FlowWrapper from '$lib/components/FlowWrapper.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { getUserExt } from '$lib/user'
import { writable } from 'svelte/store'
loadUser()
async function loadUser() {
if ($workspaceStore) {
$userStore = await getUserExt($workspaceStore)
}
}
let flowStore = $state({
val: {
summary: '',
value: { modules: [] },
path: 'u/admin/foo',
edited_at: '',
edited_by: '',
archived: false,
extra_perms: {},
schema: {
$schema: 'https://json-schema.org/draft/2020-12/schema',
properties: {},
required: [],
type: 'object'
}
}
})
let flowStateStore = writable({})
let customUi: FlowBuilderWhitelabelCustomUi = {
// disableAi: true
}
</script>
<!-- <ScriptWrapper {script} neverShowMeta={true} {customUi} /> -->
<FlowWrapper
disableAi
pathStoreInit="u/foo/bar"
{customUi}
selectedId={undefined}
newFlow
{flowStore}
{flowStateStore}
></FlowWrapper>

Some files were not shown because too many files have changed in this diff Show More