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:
wendrul
2025-07-03 21:02:45 +00:00
committed by GitHub
co-authored by Ruben Fiszel claude[bot] GitHub Action Ruben Fiszel
parent 03318a1a43
commit 1f03eb96de
31 changed files with 1165 additions and 260 deletions
+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,
@@ -4209,6 +4210,7 @@ async fn test_result_format(db: Pool<Postgres>) {
"",
&Uuid::nil(),
None,
None,
)
.await
.unwrap();
+2
View File
@@ -14304,6 +14304,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
@@ -941,6 +941,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)]
+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),
@@ -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}