mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
feat: Better tracing for audit logs, including a graph to visualize them (#6078)
* Migrate audit log page to svelte 5 * Add email and span cols to audit table * Add token_prefixs to audit logs (into AuditAuthorable trait) * Add audit logs graph (wip) * Add audit span on push and jwt * Unify same job audit into the same audit span * Improve the graph visually * Fix typo * functioning graph with svelte issue * Fix leak * feat: migrate AuditLogsTable from DataTable to VirtualList for performance - Replace DataTable component with VirtualList for handling thousands of rows - Migrate to Svelte 5 runes ($props, $bindable, $derived, $state) - Implement flattenLogs() for virtual scrolling with grouped date headers - Add sticky indices and dynamic height calculation - Update parent component to use callback prop pattern instead of events - Preserve all existing functionality: filtering, selection, pagination - Follows RunsTable.svelte implementation pattern Resolves performance issues when displaying large audit log datasets. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com> * Fix remaining virtual list issues * WIP graph * Fix chart styling * Fix npm check * Fix missing audit_span arguments * Update sqlx * use varchar 255 for email as in other tables * Remove syntax inconsistency * Match struct with ee crate * Update ee-repo-ref.txt * Update worker_flow.rs * Remove redefinition of trait to prevent shadowing * Re add trait on oss but only when no `private` flag --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
committed by
GitHub
co-authored by
Ruben Fiszel
claude[bot]
GitHub Action
Ruben Fiszel
parent
03318a1a43
commit
1f03eb96de
@@ -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);
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
@@ -4209,6 +4210,7 @@ async fn test_result_format(db: Pool<Postgres>) {
|
||||
"",
|
||||
&Uuid::nil(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -14304,6 +14304,8 @@ components:
|
||||
type: string
|
||||
parameters:
|
||||
type: object
|
||||
span:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- timestamp
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -941,6 +941,7 @@ async fn create_script_internal<'c>(
|
||||
&authed.username,
|
||||
&authed.email,
|
||||
permissioned_as,
|
||||
authed.token_prefix.as_deref(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -608,6 +608,7 @@ async fn trigger_dependents_to_recompute_dependencies(
|
||||
&created_by,
|
||||
email,
|
||||
permissioned_as.to_string(),
|
||||
Some("trigger.dependents.to.recompute.dependencies"),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
Reference in New Issue
Block a user