feat: add trashbin system for soft-deleting items (#8519)

This commit is contained in:
Ruben Fiszel
2026-03-26 09:51:34 +00:00
committed by GitHub
parent cc67fd9e46
commit 69ce946241
33 changed files with 1718 additions and 45 deletions
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd"
}
@@ -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"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE expires_at <= now()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06"
}
@@ -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;
+9
View File
@@ -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) {
+3
View File
@@ -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)
+54
View File
@@ -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,
+21
View File
@@ -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,
+69 -20
View File
@@ -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,
+43
View File
@@ -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,
+2
View File
@@ -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())),
+511
View File
@@ -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(())
}
+1
View File
@@ -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;
+155
View File
@@ -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)
}
+76 -6
View File
@@ -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,
+69
View File
@@ -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,
+24
View File
@@ -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
}}
+69
View File
@@ -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>