mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
feat: add trashbin system for soft-deleting items (#8519)
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd"
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE expires_at <= now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517"
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "item_data",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7"
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND item_kind = $2\n ORDER BY deleted_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by)\n VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f"
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1\n ORDER BY deleted_at DESC\n LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS trashbin;
|
||||
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE trashbin (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
item_kind VARCHAR(50) NOT NULL,
|
||||
item_path TEXT NOT NULL,
|
||||
item_data JSONB NOT NULL,
|
||||
deleted_by VARCHAR(255) NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '3 days'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trashbin_expires_at ON trashbin(expires_at);
|
||||
CREATE INDEX idx_trashbin_workspace_kind ON trashbin(workspace_id, item_kind);
|
||||
|
||||
GRANT ALL ON trashbin TO windmill_user;
|
||||
GRANT ALL ON trashbin TO windmill_admin;
|
||||
@@ -1178,6 +1178,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::error!("Error deleting custom concurrency key: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match windmill_common::trashbin::delete_expired_trash(db).await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("deleted {} expired trash items", count);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_expiring_tokens(db: &DB) {
|
||||
|
||||
@@ -151,6 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s
|
||||
skip_workspace_diff_tally: workspace_id(char), added_at(ts)
|
||||
sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
INDEX: idx_trashbin_expires_at (expires_at), idx_trashbin_workspace_kind (workspace_id, item_kind)
|
||||
token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
token_expiry_notification: token_hash(char), expiration(ts)
|
||||
|
||||
@@ -1657,6 +1657,38 @@ async fn delete_flow_by_path(
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node)
|
||||
let trash_flow: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_flow_versions: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_flow_nodes: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
path,
|
||||
@@ -1673,6 +1705,28 @@ async fn delete_flow_by_path(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(flow_data) = trash_flow {
|
||||
let mut trash_data = serde_json::json!({"row": flow_data});
|
||||
if !trash_flow_versions.is_empty() {
|
||||
trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions);
|
||||
}
|
||||
if !trash_flow_nodes.is_empty() {
|
||||
trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes);
|
||||
}
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"flow",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
|
||||
|
||||
@@ -656,6 +656,7 @@ async fn delete_group(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -963,6 +963,15 @@ async fn delete_schedule(
|
||||
)));
|
||||
}
|
||||
|
||||
// Capture row for trashbin before deleting
|
||||
let trash_data: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT jsonb_build_object('row', to_jsonb(t)) FROM schedule t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let del = sqlx::query_scalar!(
|
||||
"DELETE FROM schedule WHERE path = $1 AND workspace_id = $2 RETURNING 1",
|
||||
path,
|
||||
@@ -979,6 +988,18 @@ async fn delete_schedule(
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(data) = trash_data {
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"schedule",
|
||||
path,
|
||||
data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -2240,33 +2240,58 @@ async fn delete_script_by_path(
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let script = if !draft_only {
|
||||
if !draft_only {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
}
|
||||
|
||||
// Capture all script versions and drafts for trashbin before deleting
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let script = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?;
|
||||
|
||||
if !trash_scripts.is_empty() {
|
||||
let mut trash_data = serde_json::json!({"scripts": trash_scripts});
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"script",
|
||||
path,
|
||||
w_id
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
|
||||
} else {
|
||||
sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
|
||||
};
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
@@ -2275,7 +2300,7 @@ async fn delete_script_by_path(
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -2283,7 +2308,7 @@ async fn delete_script_by_path(
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -2370,6 +2395,30 @@ async fn delete_scripts_bulk(
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Capture scripts for trashbin per path before bulk delete
|
||||
for path in &request.paths {
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !trash_scripts.is_empty() {
|
||||
let trash_data = serde_json::json!({"scripts": trash_scripts});
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"script",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut deleted_paths = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
|
||||
w_id,
|
||||
|
||||
@@ -1451,6 +1451,30 @@ async fn delete_app(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.)
|
||||
let trash_app: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_app_versions: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
path,
|
||||
@@ -1467,6 +1491,25 @@ async fn delete_app(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(app_data) = trash_app {
|
||||
let mut trash_data = serde_json::json!({"row": app_data});
|
||||
if !trash_app_versions.is_empty() {
|
||||
trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions);
|
||||
}
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"app",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -165,6 +165,7 @@ pub mod teams_ee;
|
||||
mod teams_oss;
|
||||
mod token;
|
||||
mod tracing_init;
|
||||
mod trash;
|
||||
pub mod triggers;
|
||||
mod users;
|
||||
#[cfg(feature = "private")]
|
||||
@@ -597,6 +598,7 @@ pub async fn run_server(
|
||||
.nest("/resources", resources::workspaced_service())
|
||||
.nest("/schedules", windmill_api_schedule::workspaced_service())
|
||||
.nest("/scripts", scripts::workspaced_service())
|
||||
.nest("/trash", trash::workspaced_service())
|
||||
.nest(
|
||||
"/users",
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
use axum::{
|
||||
extract::{Extension, Json, Path, Query},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
trashbin::{self, TrashItem, TrashItemWithData},
|
||||
utils::require_admin,
|
||||
};
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_trash))
|
||||
.route("/get/:id", get(get_trash_item))
|
||||
.route("/restore/:id", post(restore_trash_item))
|
||||
.route("/delete/:id", delete(permanently_delete_item))
|
||||
.route("/empty", post(empty_trash))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListTrashQuery {
|
||||
item_kind: Option<String>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_trash(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(query): Query<ListTrashQuery>,
|
||||
) -> Result<Json<Vec<TrashItem>>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let items = trashbin::list_trash(
|
||||
&db,
|
||||
&w_id,
|
||||
query.item_kind.as_deref(),
|
||||
query.page,
|
||||
query.per_page,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
async fn get_trash_item(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<Json<TrashItemWithData>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
async fn restore_trash_item(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
match item.item_kind.as_str() {
|
||||
"script" => restore_script(&mut tx, &item).await?,
|
||||
"flow" => restore_flow(&mut tx, &item).await?,
|
||||
"app" => restore_app(&mut tx, &item).await?,
|
||||
"schedule" => restore_schedule(&mut tx, &item).await?,
|
||||
"variable" => restore_variable(&mut tx, &item).await?,
|
||||
"resource" => restore_resource(&mut tx, &item).await?,
|
||||
kind if kind.ends_with("_trigger") => restore_trigger(&mut tx, &item).await?,
|
||||
_ => {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Unknown item kind: {}",
|
||||
item.item_kind
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM trashbin WHERE id = $1", item.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"trash.restore",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&item.item_path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(format!("{} '{}' restored", item.item_kind, item.item_path))
|
||||
}
|
||||
|
||||
async fn permanently_delete_item(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
trashbin::permanently_delete_item(&mut *tx, &w_id, id).await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"trashbin.permanently_delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&item.item_path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok("permanently deleted".to_string())
|
||||
}
|
||||
|
||||
async fn empty_trash(
|
||||
authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let count = trashbin::empty_trash(&mut *tx, &w_id).await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"trashbin.empty",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(format!("{} items permanently deleted", count))
|
||||
}
|
||||
|
||||
// --- Restore functions per item kind ---
|
||||
|
||||
async fn restore_script(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
// Check for path conflict
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A script already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
// Scripts are stored as an array (all versions for the path)
|
||||
let scripts = data
|
||||
.get("scripts")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for script"))?;
|
||||
|
||||
for script in scripts {
|
||||
sqlx::query("INSERT INTO script SELECT * FROM jsonb_populate_record(null::script, $1)")
|
||||
.bind(script)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("restoring script: {e:#}")))?;
|
||||
}
|
||||
|
||||
// Restore drafts if present
|
||||
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
|
||||
for draft in drafts {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(draft)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_flow(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A flow already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for flow"))?;
|
||||
|
||||
sqlx::query("INSERT INTO flow SELECT * FROM jsonb_populate_record(null::flow, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Restore flow_versions
|
||||
if let Some(versions) = data.get("flow_versions").and_then(|v| v.as_array()) {
|
||||
for version in versions {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_version SELECT * FROM jsonb_populate_record(null::flow_version, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore flow_nodes
|
||||
if let Some(nodes) = data.get("flow_nodes").and_then(|v| v.as_array()) {
|
||||
for node in nodes {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_node SELECT * FROM jsonb_populate_record(null::flow_node, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(node)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore drafts
|
||||
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
|
||||
for draft in drafts {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(draft)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_app(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"An app already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for app"))?;
|
||||
|
||||
// Insert app first (app_version has FK to app.id)
|
||||
sqlx::query("INSERT INTO app SELECT * FROM jsonb_populate_record(null::app, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("restoring app row: {e:#}")))?;
|
||||
|
||||
// Then restore app_versions
|
||||
if let Some(versions) = data.get("app_versions").and_then(|v| v.as_array()) {
|
||||
for version in versions {
|
||||
sqlx::query(
|
||||
"INSERT INTO app_version SELECT * FROM jsonb_populate_record(null::app_version, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("restoring app_version: {e:#}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore drafts
|
||||
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
|
||||
for draft in drafts {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(draft)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_schedule(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A schedule already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for schedule"))?;
|
||||
|
||||
sqlx::query("INSERT INTO schedule SELECT * FROM jsonb_populate_record(null::schedule, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_variable(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM variable WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A variable already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for variable"))?;
|
||||
|
||||
sqlx::query("INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Restore linked resource if present
|
||||
if let Some(linked_resource) = data.get("linked_resource") {
|
||||
if !linked_resource.is_null() {
|
||||
sqlx::query(
|
||||
"INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(linked_resource)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_resource(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM resource WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A resource already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for resource"))?;
|
||||
|
||||
sqlx::query("INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Restore linked variables if present
|
||||
if let Some(linked_vars) = data.get("linked_variables").and_then(|v| v.as_array()) {
|
||||
for var in linked_vars {
|
||||
sqlx::query(
|
||||
"INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(var)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let table_name = data
|
||||
.get("table_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger: missing table_name"))?;
|
||||
|
||||
// Validate table name to prevent SQL injection
|
||||
let valid_tables = [
|
||||
"http_trigger",
|
||||
"websocket_trigger",
|
||||
"kafka_trigger",
|
||||
"nats_trigger",
|
||||
"postgres_trigger",
|
||||
"mqtt_trigger",
|
||||
"sqs_trigger",
|
||||
"gcp_trigger",
|
||||
"email_trigger",
|
||||
];
|
||||
|
||||
if !valid_tables.contains(&table_name) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid trigger table: {}",
|
||||
table_name
|
||||
)));
|
||||
}
|
||||
|
||||
let exists: bool = sqlx::query_scalar(&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM {} WHERE path = $1 AND workspace_id = $2)",
|
||||
table_name
|
||||
))
|
||||
.bind(&item.item_path)
|
||||
.bind(&item.workspace_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A trigger already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger"))?;
|
||||
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} SELECT * FROM jsonb_populate_record(null::{}, $1)",
|
||||
table_name, table_name
|
||||
))
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -96,6 +96,7 @@ pub mod stream;
|
||||
pub mod teams_ee;
|
||||
pub mod teams_oss;
|
||||
pub mod tracing_init;
|
||||
pub mod trashbin;
|
||||
pub mod triggers;
|
||||
pub mod usernames;
|
||||
pub mod users;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
use serde::Serialize;
|
||||
use sqlx::PgConnection;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct TrashItem {
|
||||
pub id: i64,
|
||||
pub workspace_id: String,
|
||||
pub item_kind: String,
|
||||
pub item_path: String,
|
||||
pub deleted_by: String,
|
||||
pub deleted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, sqlx::FromRow)]
|
||||
pub struct TrashItemWithData {
|
||||
pub id: i64,
|
||||
pub workspace_id: String,
|
||||
pub item_kind: String,
|
||||
pub item_path: String,
|
||||
pub item_data: serde_json::Value,
|
||||
pub deleted_by: String,
|
||||
pub deleted_at: chrono::DateTime<chrono::Utc>,
|
||||
pub expires_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
pub async fn move_to_trash(
|
||||
tx: &mut PgConnection,
|
||||
workspace_id: &str,
|
||||
item_kind: &str,
|
||||
item_path: &str,
|
||||
item_data: serde_json::Value,
|
||||
deleted_by: &str,
|
||||
) -> Result<i64> {
|
||||
let id = sqlx::query_scalar!(
|
||||
"INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
workspace_id,
|
||||
item_kind,
|
||||
item_path,
|
||||
item_data,
|
||||
deleted_by,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn list_trash<'e, E: sqlx::PgExecutor<'e>>(
|
||||
db: E,
|
||||
workspace_id: &str,
|
||||
kind_filter: Option<&str>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
) -> Result<Vec<TrashItem>> {
|
||||
let per_page = per_page.unwrap_or(100).min(1000);
|
||||
let offset = page.unwrap_or(0) * per_page;
|
||||
|
||||
let items = if let Some(kind) = kind_filter {
|
||||
sqlx::query_as!(
|
||||
TrashItem,
|
||||
"SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at
|
||||
FROM trashbin
|
||||
WHERE workspace_id = $1 AND item_kind = $2
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT $3 OFFSET $4",
|
||||
workspace_id,
|
||||
kind,
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
TrashItem,
|
||||
"SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at
|
||||
FROM trashbin
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY deleted_at DESC
|
||||
LIMIT $2 OFFSET $3",
|
||||
workspace_id,
|
||||
per_page,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?
|
||||
};
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
pub async fn get_trash_item<'e, E: sqlx::PgExecutor<'e>>(
|
||||
db: E,
|
||||
workspace_id: &str,
|
||||
id: i64,
|
||||
) -> Result<TrashItemWithData> {
|
||||
let item = sqlx::query_as!(
|
||||
TrashItemWithData,
|
||||
"SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at
|
||||
FROM trashbin
|
||||
WHERE workspace_id = $1 AND id = $2",
|
||||
workspace_id,
|
||||
id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| crate::error::Error::NotFound("Trash item not found".to_string()))?;
|
||||
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
pub async fn permanently_delete_item<'e, E: sqlx::PgExecutor<'e>>(
|
||||
db: E,
|
||||
workspace_id: &str,
|
||||
id: i64,
|
||||
) -> Result<()> {
|
||||
let rows = sqlx::query!(
|
||||
"DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2",
|
||||
workspace_id,
|
||||
id,
|
||||
)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
if rows == 0 {
|
||||
return Err(crate::error::Error::NotFound(
|
||||
"Trash item not found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn empty_trash<'e, E: sqlx::PgExecutor<'e>>(db: E, workspace_id: &str) -> Result<i64> {
|
||||
let rows = sqlx::query!("DELETE FROM trashbin WHERE workspace_id = $1", workspace_id,)
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
Ok(rows as i64)
|
||||
}
|
||||
|
||||
pub async fn delete_expired_trash<'e, E: sqlx::PgExecutor<'e>>(db: E) -> Result<i64> {
|
||||
let rows = sqlx::query!("DELETE FROM trashbin WHERE expires_at <= now()")
|
||||
.execute(db)
|
||||
.await?
|
||||
.rows_affected();
|
||||
|
||||
Ok(rows as i64)
|
||||
}
|
||||
@@ -877,6 +877,15 @@ async fn delete_resource(
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture resource data for trashbin before deleting
|
||||
let trash_resource: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Fetch the resource value before deleting, so we can find linked $var: references
|
||||
let resource_value: Option<Option<serde_json::Value>> =
|
||||
sqlx::query_scalar("SELECT value FROM resource WHERE path = $1 AND workspace_id = $2")
|
||||
@@ -885,6 +894,32 @@ async fn delete_resource(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Collect all $var: paths referenced in the resource value
|
||||
let mut linked_var_paths: Vec<String> = Vec::new();
|
||||
if let Some(Some(ref value)) = resource_value {
|
||||
collect_var_refs(value, &mut linked_var_paths);
|
||||
}
|
||||
|
||||
// Capture linked variables for trashbin before deleting them
|
||||
let trash_linked_vars: Vec<serde_json::Value> = if linked_var_paths.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let placeholders: Vec<String> = linked_var_paths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| format!("${}", i + 2))
|
||||
.collect();
|
||||
let query = format!(
|
||||
"SELECT to_jsonb(t) FROM variable t WHERE workspace_id = $1 AND path IN ({})",
|
||||
placeholders.join(", ")
|
||||
);
|
||||
let mut q = sqlx::query_scalar::<_, serde_json::Value>(&query).bind(&w_id);
|
||||
for var_path in &linked_var_paths {
|
||||
q = q.bind(var_path);
|
||||
}
|
||||
q.fetch_all(&mut *tx).await?
|
||||
};
|
||||
|
||||
let deleted_path = sqlx::query_scalar!(
|
||||
"DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
@@ -894,12 +929,6 @@ async fn delete_resource(
|
||||
.await?;
|
||||
not_found_if_none(deleted_path, "Resource", &path)?;
|
||||
|
||||
// Collect all $var: paths referenced in the resource value
|
||||
let mut linked_var_paths: Vec<String> = Vec::new();
|
||||
if let Some(Some(value)) = resource_value {
|
||||
collect_var_refs(&value, &mut linked_var_paths);
|
||||
}
|
||||
|
||||
// Delete linked variables that are actually referenced in the resource value
|
||||
let deleted_linked_variables: Vec<String> = if linked_var_paths.is_empty() {
|
||||
Vec::new()
|
||||
@@ -919,6 +948,23 @@ async fn delete_resource(
|
||||
}
|
||||
q.fetch_all(&mut *tx).await?
|
||||
};
|
||||
|
||||
if let Some(res_data) = trash_resource {
|
||||
let mut trash_data = serde_json::json!({"row": res_data});
|
||||
if !trash_linked_vars.is_empty() {
|
||||
trash_data["linked_variables"] = serde_json::Value::Array(trash_linked_vars);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"resource",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -1025,6 +1071,30 @@ async fn delete_resources_bulk(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture resources for trashbin per path before bulk delete
|
||||
for path in &request.paths {
|
||||
let trash_resource: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(res_data) = trash_resource {
|
||||
let trash_data = serde_json::json!({"row": res_data});
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"resource",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted_paths = sqlx::query_scalar!(
|
||||
"DELETE FROM resource WHERE path = ANY($1) AND workspace_id = $2 RETURNING path",
|
||||
&request.paths,
|
||||
|
||||
@@ -520,6 +520,23 @@ async fn delete_variable(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture data for trashbin before deleting
|
||||
let trash_var: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM variable t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_linked_resource: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM variable WHERE path = $1 AND workspace_id = $2",
|
||||
path,
|
||||
@@ -534,6 +551,23 @@ async fn delete_variable(
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(var_data) = trash_var {
|
||||
let mut trash_data = serde_json::json!({"row": var_data});
|
||||
if let Some(linked) = trash_linked_resource {
|
||||
trash_data["linked_resource"] = linked;
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"variable",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -633,6 +667,41 @@ async fn delete_variables_bulk(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture variables for trashbin per path before bulk delete
|
||||
for path in &request.paths {
|
||||
let trash_var: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM variable t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(var_data) = trash_var {
|
||||
let trash_linked: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM resource t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let mut trash_data = serde_json::json!({"row": var_data});
|
||||
if let Some(linked) = trash_linked {
|
||||
trash_data["linked_resource"] = linked;
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"variable",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let deleted_paths = sqlx::query_scalar!(
|
||||
"DELETE FROM variable WHERE path = ANY($1) AND workspace_id = $2 RETURNING path",
|
||||
&request.paths,
|
||||
|
||||
@@ -610,6 +610,17 @@ async fn delete_trigger<T: TriggerCrud>(
|
||||
})?;
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture trigger data for trashbin before deleting
|
||||
let trash_data: Option<serde_json::Value> = sqlx::query_scalar(&format!(
|
||||
"SELECT jsonb_build_object('row', to_jsonb(t), 'table_name', '{table}') FROM {table} t WHERE path = $1 AND workspace_id = $2",
|
||||
table = T::TABLE_NAME
|
||||
))
|
||||
.bind(path)
|
||||
.bind(&workspace_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let deleted = handler
|
||||
.delete_by_path(&mut *tx, &workspace_id, path)
|
||||
.await?;
|
||||
@@ -621,6 +632,19 @@ async fn delete_trigger<T: TriggerCrud>(
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(data) = trash_data {
|
||||
let item_kind = format!("{}_trigger", T::TRIGGER_TYPE);
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&workspace_id,
|
||||
&item_kind,
|
||||
path,
|
||||
data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
type?: 'danger' | 'reload'
|
||||
showIcon?: boolean
|
||||
id?: string
|
||||
trashbin?: boolean
|
||||
children?: Snippet
|
||||
onConfirmed?: () => void | Promise<void>
|
||||
onCanceled?: () => void
|
||||
@@ -29,6 +30,7 @@
|
||||
type: _type,
|
||||
showIcon = true,
|
||||
id,
|
||||
trashbin = false,
|
||||
children,
|
||||
onConfirmed,
|
||||
onCanceled
|
||||
@@ -120,6 +122,12 @@
|
||||
<div class="mt-2 text-sm text-secondary">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{#if trashbin}
|
||||
<p class="mt-3 text-xs text-tertiary"
|
||||
>This item will be moved to the trashbin and can be restored by a workspace admin
|
||||
within 3 days.</p
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 flex-row-reverse space-x-reverse mt-4">
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
open={Boolean(deleteConfirmedCallback)}
|
||||
title="Remove"
|
||||
confirmationText="Remove"
|
||||
trashbin
|
||||
on:canceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
<script lang="ts">
|
||||
import { Button, Skeleton } from '$lib/components/common'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import Head from '$lib/components/table/Head.svelte'
|
||||
import Cell from '$lib/components/table/Cell.svelte'
|
||||
import Row from '$lib/components/table/Row.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { type TrashItem, TrashService } from '$lib/services/trashService'
|
||||
import {
|
||||
Trash2,
|
||||
RotateCcw,
|
||||
FileCode2,
|
||||
GitFork,
|
||||
AppWindow,
|
||||
Clock,
|
||||
Variable,
|
||||
Database,
|
||||
Zap,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
let items: TrashItem[] | undefined = $state(undefined)
|
||||
let deleteConfirmedCallback: (() => void) | undefined = $state(undefined)
|
||||
let deleteOpen = $derived(Boolean(deleteConfirmedCallback))
|
||||
let emptyConfirmOpen = $state(false)
|
||||
|
||||
function getKindIcon(kind: string) {
|
||||
if (kind === 'script') return FileCode2
|
||||
if (kind === 'flow') return GitFork
|
||||
if (kind === 'app') return AppWindow
|
||||
if (kind === 'schedule') return Clock
|
||||
if (kind === 'variable') return Variable
|
||||
if (kind === 'resource') return Database
|
||||
if (kind.endsWith('_trigger')) return Zap
|
||||
return Trash2
|
||||
}
|
||||
|
||||
function getKindLabel(kind: string) {
|
||||
if (kind === 'script') return 'Script'
|
||||
if (kind === 'flow') return 'Flow'
|
||||
if (kind === 'app') return 'App'
|
||||
if (kind === 'schedule') return 'Schedule'
|
||||
if (kind === 'variable') return 'Variable'
|
||||
if (kind === 'resource') return 'Resource'
|
||||
if (kind.endsWith('_trigger')) {
|
||||
return (
|
||||
kind
|
||||
.replace('_trigger', '')
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase()) + ' Trigger'
|
||||
)
|
||||
}
|
||||
return kind
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const date = new Date(dateStr)
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
return `${diffDays}d ago`
|
||||
}
|
||||
|
||||
function timeRemaining(dateStr: string): string {
|
||||
const now = new Date()
|
||||
const expires = new Date(dateStr)
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
if (diffMs <= 0) return 'expired'
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
if (diffHours < 1) return '< 1h remaining'
|
||||
if (diffHours < 24) return `${diffHours}h remaining`
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const remainingHours = diffHours % 24
|
||||
if (remainingHours === 0) return `${diffDays}d remaining`
|
||||
return `${diffDays}d ${remainingHours}h remaining`
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
items = await TrashService.listTrash({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
}
|
||||
|
||||
async function restoreItem(item: TrashItem) {
|
||||
try {
|
||||
await TrashService.restoreTrashItem({
|
||||
workspace: $workspaceStore!,
|
||||
id: item.id
|
||||
})
|
||||
sendUserToast(`Restored ${getKindLabel(item.item_kind)} '${item.item_path}'`)
|
||||
loadItems()
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to restore: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function permanentlyDelete(item: TrashItem) {
|
||||
try {
|
||||
await TrashService.permanentlyDeleteTrashItem({
|
||||
workspace: $workspaceStore!,
|
||||
id: item.id
|
||||
})
|
||||
sendUserToast(`Permanently deleted '${item.item_path}'`)
|
||||
loadItems()
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to delete: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function emptyAll() {
|
||||
try {
|
||||
const result = await TrashService.emptyTrash({ workspace: $workspaceStore! })
|
||||
sendUserToast(result)
|
||||
loadItems()
|
||||
} catch (e) {
|
||||
sendUserToast(`Failed to empty trash: ${e}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
$workspaceStore
|
||||
untrack(() => loadItems())
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex justify-end mb-4 gap-2">
|
||||
<Button startIcon={{ icon: RefreshCw }} variant="default" size="xs" onclick={loadItems}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={{ icon: Trash2 }}
|
||||
variant="default"
|
||||
size="xs"
|
||||
onclick={() => {
|
||||
emptyConfirmOpen = true
|
||||
}}
|
||||
disabled={!items || items.length === 0}
|
||||
>
|
||||
Empty Trashbin
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if items === undefined}
|
||||
<Skeleton layout={[20, 8, 8, 8]} />
|
||||
{:else if items.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-tertiary">
|
||||
<Trash2 size={40} class="mb-3 opacity-50" />
|
||||
<p class="text-base">Trashbin is empty</p>
|
||||
<p class="text-sm mt-1">No recently deleted items.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<DataTable size="sm">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Type</Cell>
|
||||
<Cell head>Path</Cell>
|
||||
<Cell head>Deleted by</Cell>
|
||||
<Cell head>Deleted</Cell>
|
||||
<Cell head>Expires</Cell>
|
||||
<Cell head last>Actions</Cell>
|
||||
</tr>
|
||||
</Head>
|
||||
{#each items as item (item.id)}
|
||||
{@const Icon = getKindIcon(item.item_kind)}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon size={14} />
|
||||
<span class="text-xs">{getKindLabel(item.item_kind)}</span>
|
||||
</div>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="font-mono text-xs">{item.item_path}</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-xs">{item.deleted_by}</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-xs text-tertiary">{timeAgo(item.deleted_at)}</span>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<span class="text-xs text-tertiary">{timeRemaining(item.expires_at)}</span>
|
||||
</Cell>
|
||||
<Cell last>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
startIcon={{ icon: RotateCcw }}
|
||||
variant="default"
|
||||
size="xs2"
|
||||
onclick={() => restoreItem(item)}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={{ icon: Trash2 }}
|
||||
variant="default"
|
||||
size="xs2"
|
||||
onclick={() => {
|
||||
deleteConfirmedCallback = () => permanentlyDelete(item)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</DataTable>
|
||||
{/if}
|
||||
|
||||
<ConfirmationModal
|
||||
open={deleteOpen}
|
||||
title="Permanently delete"
|
||||
confirmationText="Delete forever"
|
||||
onCanceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
if (deleteConfirmedCallback) {
|
||||
deleteConfirmedCallback()
|
||||
}
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
>
|
||||
<p>This item will be permanently deleted. This action cannot be undone.</p>
|
||||
</ConfirmationModal>
|
||||
|
||||
<ConfirmationModal
|
||||
open={emptyConfirmOpen}
|
||||
title="Empty trashbin"
|
||||
confirmationText="Empty trashbin"
|
||||
onCanceled={() => {
|
||||
emptyConfirmOpen = false
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
emptyAll()
|
||||
emptyConfirmOpen = false
|
||||
}}
|
||||
>
|
||||
<p>All items in the trashbin will be permanently deleted. This action cannot be undone.</p>
|
||||
</ConfirmationModal>
|
||||
@@ -21,6 +21,7 @@
|
||||
title={`Are you sure you want to delete this ${trigger?.isDraft ? 'draft' : 'deployed'} trigger ?`}
|
||||
confirmationText="Delete"
|
||||
open={confirmationModalOpen}
|
||||
trashbin
|
||||
on:canceled={() => {
|
||||
confirmationModalOpen = false
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { OpenAPI } from '$lib/gen/core/OpenAPI'
|
||||
import { request as __request } from '$lib/gen/core/request'
|
||||
|
||||
export type TrashItem = {
|
||||
id: number
|
||||
workspace_id: string
|
||||
item_kind: string
|
||||
item_path: string
|
||||
deleted_by: string
|
||||
deleted_at: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
export class TrashService {
|
||||
public static listTrash(data: {
|
||||
workspace: string
|
||||
itemKind?: string
|
||||
page?: number
|
||||
perPage?: number
|
||||
}): Promise<TrashItem[]> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/w/{workspace}/trash/list',
|
||||
path: {
|
||||
workspace: data.workspace
|
||||
},
|
||||
query: {
|
||||
item_kind: data.itemKind,
|
||||
page: data.page,
|
||||
per_page: data.perPage
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public static restoreTrashItem(data: { workspace: string; id: number }): Promise<string> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/w/{workspace}/trash/restore/{id}',
|
||||
path: {
|
||||
workspace: data.workspace,
|
||||
id: data.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public static permanentlyDeleteTrashItem(data: {
|
||||
workspace: string
|
||||
id: number
|
||||
}): Promise<string> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'DELETE',
|
||||
url: '/w/{workspace}/trash/delete/{id}',
|
||||
path: {
|
||||
workspace: data.workspace,
|
||||
id: data.id
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public static emptyTrash(data: { workspace: string }): Promise<string> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/w/{workspace}/trash/empty',
|
||||
path: {
|
||||
workspace: data.workspace
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,14 @@
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import type { ResourceType, WorkspaceDeployUISettings } from '$lib/gen'
|
||||
import { FolderService, OauthService, ResourceService, WorkspaceService, type ListableResource } from '$lib/gen'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
userStore,
|
||||
workspaceStore,
|
||||
userWorkspaces
|
||||
} from '$lib/stores'
|
||||
FolderService,
|
||||
OauthService,
|
||||
ResourceService,
|
||||
WorkspaceService,
|
||||
type ListableResource
|
||||
} from '$lib/gen'
|
||||
import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import {
|
||||
canWrite,
|
||||
@@ -151,7 +152,12 @@
|
||||
let folderPresets = $derived([
|
||||
...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
|
||||
...(resourcesFilterSchema.user_folders_only
|
||||
? [{ name: resourcesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }]
|
||||
? [
|
||||
{
|
||||
name: resourcesFilterSchema.user_folders_only.label ?? '?',
|
||||
value: 'user_folders_only:\\ true'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
|
||||
@@ -577,6 +583,7 @@
|
||||
open={Boolean(deleteConfirmedCallback)}
|
||||
title="Remove resource"
|
||||
confirmationText="Remove"
|
||||
trashbin
|
||||
on:canceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
|
||||
@@ -74,7 +74,12 @@
|
||||
let folderPresets = $derived([
|
||||
...folders.map((f) => ({ name: `f/${f}`, value: `path_start:\\ f/${f}/` })),
|
||||
...(variablesFilterSchema.user_folders_only
|
||||
? [{ name: variablesFilterSchema.user_folders_only.label ?? '?', value: 'user_folders_only:\\ true' }]
|
||||
? [
|
||||
{
|
||||
name: variablesFilterSchema.user_folders_only.label ?? '?',
|
||||
value: 'user_folders_only:\\ true'
|
||||
}
|
||||
]
|
||||
: [])
|
||||
])
|
||||
let contextualVariables: ContextualVariable[] = $state([])
|
||||
@@ -576,6 +581,7 @@
|
||||
{open}
|
||||
title="Remove variable"
|
||||
confirmationText="Remove"
|
||||
trashbin
|
||||
on:canceled={() => {
|
||||
deleteConfirmedCallback = undefined
|
||||
}}
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
import StorageSettings from '$lib/components/workspaceSettings/StorageSettings.svelte'
|
||||
import VolumeStorageSettings from '$lib/components/workspaceSettings/VolumeStorageSettings.svelte'
|
||||
import GitSyncSection from '$lib/components/git_sync/GitSyncSection.svelte'
|
||||
import Trashbin from '$lib/components/settings/Trashbin.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { getHandlerType } from '$lib/components/triggers/utils'
|
||||
import DucklakeSettings, {
|
||||
@@ -473,17 +474,15 @@
|
||||
}
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
const [settings, copilotSettingsState]: [
|
||||
GetSettingsResponse,
|
||||
GetCopilotSettingsStateResponse
|
||||
] = await Promise.all([
|
||||
WorkspaceService.getSettings({
|
||||
workspace: $workspaceStore!
|
||||
}),
|
||||
WorkspaceService.getCopilotSettingsState({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
])
|
||||
const [settings, copilotSettingsState]: [GetSettingsResponse, GetCopilotSettingsStateResponse] =
|
||||
await Promise.all([
|
||||
WorkspaceService.getSettings({
|
||||
workspace: $workspaceStore!
|
||||
}),
|
||||
WorkspaceService.getCopilotSettingsState({
|
||||
workspace: $workspaceStore!
|
||||
})
|
||||
])
|
||||
slack_team_name = settings.slack_name
|
||||
teams_team_id = settings.teams_team_id
|
||||
teams_team_name = settings.teams_team_name
|
||||
@@ -1193,6 +1192,12 @@
|
||||
label: 'Encryption',
|
||||
aiId: 'workspace-settings-encryption',
|
||||
aiDescription: 'Encryption workspace settings'
|
||||
},
|
||||
{
|
||||
id: 'trashbin',
|
||||
label: 'Trashbin',
|
||||
aiId: 'workspace-settings-trashbin',
|
||||
aiDescription: 'Trashbin for recently deleted items'
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1927,6 +1932,14 @@ export async function main(
|
||||
saveLabel="Save & Re-encrypt workspace"
|
||||
disabled={!!encryptionKeyValidationError || workspaceReencryptionInProgress}
|
||||
/>
|
||||
{:else if tab == 'trashbin'}
|
||||
<SettingsPageHeader
|
||||
title="Trashbin"
|
||||
description="When scripts, flows, apps, resources, variables, schedules, or triggers are deleted, they are moved to the trashbin and kept for 3 days before being permanently removed. Admins can restore or permanently delete items from here."
|
||||
/>
|
||||
<div class="mt-4">
|
||||
<Trashbin />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user