feat: add column sort, SQL formatter, table stats, insert dialog, saved queries & sessions monitor
- Column sort by header click in table view (ASC/DESC/none cycle, server-side) - SQL formatter with Format button and Shift+Alt+F keybinding (sql-formatter) - Table size and row count display in schema tree via pg_class - Insert row dialog with column type hints and auto-skip for identity columns - Saved queries (bookmarks) with CRUD backend, sidebar panel, and save dialog - Active sessions monitor (pg_stat_activity) with auto-refresh, cancel & terminate Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
83
package-lock.json
generated
83
package-lock.json
generated
@@ -26,6 +26,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-resizable-panels": "^4.6.2",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -5023,7 +5024,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@@ -5746,6 +5746,12 @@
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/discontinuous-range": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz",
|
||||
"integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.4",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.4.tgz",
|
||||
@@ -7832,6 +7838,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/moo": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
||||
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -7934,6 +7946,34 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nearley": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz",
|
||||
"integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^2.19.0",
|
||||
"moo": "^0.5.0",
|
||||
"railroad-diagrams": "^1.0.0",
|
||||
"randexp": "0.4.6"
|
||||
},
|
||||
"bin": {
|
||||
"nearley-railroad": "bin/nearley-railroad.js",
|
||||
"nearley-test": "bin/nearley-test.js",
|
||||
"nearley-unparse": "bin/nearley-unparse.js",
|
||||
"nearleyc": "bin/nearleyc.js"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://nearley.js.org/#give-to-nearley"
|
||||
}
|
||||
},
|
||||
"node_modules/nearley/node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||
@@ -8588,6 +8628,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
"integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/randexp": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
|
||||
"integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discontinuous-range": "1.0.0",
|
||||
"ret": "~0.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
@@ -8790,6 +8849,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ret": {
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz",
|
||||
"integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/rettime": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz",
|
||||
@@ -9201,6 +9269,19 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sql-formatter": {
|
||||
"version": "15.7.0",
|
||||
"resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.7.0.tgz",
|
||||
"integrity": "sha512-o2yiy7fYXK1HvzA8P6wwj8QSuwG3e/XcpWht/jIxkQX99c0SVPw0OXdLSV9fHASPiYB09HLA0uq8hokGydi/QA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"nearley": "^2.20.1"
|
||||
},
|
||||
"bin": {
|
||||
"sql-formatter": "bin/sql-formatter-cli.cjs"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"react-dom": "^19.2.0",
|
||||
"react-resizable-panels": "^4.6.2",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
@@ -507,3 +507,83 @@ pub async fn manage_role_membership(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_sessions(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
) -> TuskResult<Vec<SessionInfo>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT pid, usename, datname, state, query, \
|
||||
query_start::text, wait_event_type, wait_event, \
|
||||
client_addr::text \
|
||||
FROM pg_stat_activity \
|
||||
WHERE datname IS NOT NULL \
|
||||
ORDER BY query_start DESC NULLS LAST",
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
let sessions = rows
|
||||
.iter()
|
||||
.map(|row| SessionInfo {
|
||||
pid: row.get("pid"),
|
||||
usename: row.get("usename"),
|
||||
datname: row.get("datname"),
|
||||
state: row.get("state"),
|
||||
query: row.get("query"),
|
||||
query_start: row.get("query_start"),
|
||||
wait_event_type: row.get("wait_event_type"),
|
||||
wait_event: row.get("wait_event"),
|
||||
client_addr: row.get("client_addr"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_query(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
pid: i32,
|
||||
) -> TuskResult<bool> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let row = sqlx::query("SELECT pg_cancel_backend($1)")
|
||||
.bind(pid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(row.get::<bool, _>(0))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn terminate_backend(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
pid: i32,
|
||||
) -> TuskResult<bool> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let row = sqlx::query("SELECT pg_terminate_backend($1)")
|
||||
.bind(pid)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(row.get::<bool, _>(0))
|
||||
}
|
||||
|
||||
@@ -4,4 +4,5 @@ pub mod export;
|
||||
pub mod history;
|
||||
pub mod management;
|
||||
pub mod queries;
|
||||
pub mod saved_queries;
|
||||
pub mod schema;
|
||||
|
||||
72
src-tauri/src/commands/saved_queries.rs
Normal file
72
src-tauri/src/commands/saved_queries.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::saved_queries::SavedQuery;
|
||||
use std::fs;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
fn get_saved_queries_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||
fs::create_dir_all(&dir)?;
|
||||
Ok(dir.join("saved_queries.json"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_saved_queries(
|
||||
app: AppHandle,
|
||||
search: Option<String>,
|
||||
) -> TuskResult<Vec<SavedQuery>> {
|
||||
let path = get_saved_queries_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let data = fs::read_to_string(&path)?;
|
||||
let entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
|
||||
|
||||
let filtered: Vec<SavedQuery> = entries
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
if let Some(ref s) = search {
|
||||
let lower = s.to_lowercase();
|
||||
e.name.to_lowercase().contains(&lower) || e.sql.to_lowercase().contains(&lower)
|
||||
} else {
|
||||
true
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(filtered)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_query(app: AppHandle, query: SavedQuery) -> TuskResult<()> {
|
||||
let path = get_saved_queries_path(&app)?;
|
||||
let mut entries = if path.exists() {
|
||||
let data = fs::read_to_string(&path)?;
|
||||
serde_json::from_str::<Vec<SavedQuery>>(&data).unwrap_or_default()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
entries.insert(0, query);
|
||||
|
||||
let data = serde_json::to_string_pretty(&entries)?;
|
||||
fs::write(&path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_saved_query(app: AppHandle, id: String) -> TuskResult<()> {
|
||||
let path = get_saved_queries_path(&app)?;
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let data = fs::read_to_string(&path)?;
|
||||
let mut entries: Vec<SavedQuery> = serde_json::from_str(&data).unwrap_or_default();
|
||||
entries.retain(|e| e.id != id);
|
||||
|
||||
let data = serde_json::to_string_pretty(&entries)?;
|
||||
fs::write(&path, data)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::{TuskError, TuskResult};
|
||||
use crate::models::schema::{ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
|
||||
use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
|
||||
use crate::state::AppState;
|
||||
use sqlx::Row;
|
||||
use std::collections::HashMap;
|
||||
@@ -61,9 +61,14 @@ pub async fn list_tables(
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT table_name FROM information_schema.tables \
|
||||
WHERE table_schema = $1 AND table_type = 'BASE TABLE' \
|
||||
ORDER BY table_name",
|
||||
"SELECT t.table_name, \
|
||||
c.reltuples::bigint as row_count, \
|
||||
pg_total_relation_size(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::bigint as size_bytes \
|
||||
FROM information_schema.tables t \
|
||||
LEFT JOIN pg_class c ON c.relname = t.table_name \
|
||||
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) \
|
||||
WHERE t.table_schema = $1 AND t.table_type = 'BASE TABLE' \
|
||||
ORDER BY t.table_name",
|
||||
)
|
||||
.bind(&schema)
|
||||
.fetch_all(pool)
|
||||
@@ -76,6 +81,8 @@ pub async fn list_tables(
|
||||
name: r.get(0),
|
||||
object_type: "table".to_string(),
|
||||
schema: schema.clone(),
|
||||
row_count: r.get::<Option<i64>, _>(1),
|
||||
size_bytes: r.get::<Option<i64>, _>(2),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -107,6 +114,8 @@ pub async fn list_views(
|
||||
name: r.get(0),
|
||||
object_type: "view".to_string(),
|
||||
schema: schema.clone(),
|
||||
row_count: None,
|
||||
size_bytes: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -138,6 +147,8 @@ pub async fn list_functions(
|
||||
name: r.get(0),
|
||||
object_type: "function".to_string(),
|
||||
schema: schema.clone(),
|
||||
row_count: None,
|
||||
size_bytes: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -169,6 +180,8 @@ pub async fn list_indexes(
|
||||
name: r.get(0),
|
||||
object_type: "index".to_string(),
|
||||
schema: schema.clone(),
|
||||
row_count: None,
|
||||
size_bytes: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -200,6 +213,8 @@ pub async fn list_sequences(
|
||||
name: r.get(0),
|
||||
object_type: "sequence".to_string(),
|
||||
schema: schema.clone(),
|
||||
row_count: None,
|
||||
size_bytes: None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -378,3 +393,42 @@ pub async fn get_completion_schema(
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_column_details(
|
||||
state: State<'_, AppState>,
|
||||
connection_id: String,
|
||||
schema: String,
|
||||
table: String,
|
||||
) -> TuskResult<Vec<ColumnDetail>> {
|
||||
let pools = state.pools.read().await;
|
||||
let pool = pools
|
||||
.get(&connection_id)
|
||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||
|
||||
let rows = sqlx::query(
|
||||
"SELECT c.column_name, c.data_type, \
|
||||
c.is_nullable = 'YES' as is_nullable, \
|
||||
c.column_default, \
|
||||
c.is_identity = 'YES' as is_identity \
|
||||
FROM information_schema.columns c \
|
||||
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||
ORDER BY c.ordinal_position",
|
||||
)
|
||||
.bind(&schema)
|
||||
.bind(&table)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(TuskError::Database)?;
|
||||
|
||||
Ok(rows
|
||||
.iter()
|
||||
.map(|r| ColumnDetail {
|
||||
column_name: r.get::<String, _>(0),
|
||||
data_type: r.get::<String, _>(1),
|
||||
is_nullable: r.get::<bool, _>(2),
|
||||
column_default: r.get::<Option<String>, _>(3),
|
||||
is_identity: r.get::<bool, _>(4),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ pub fn run() {
|
||||
commands::schema::get_table_constraints,
|
||||
commands::schema::get_table_indexes,
|
||||
commands::schema::get_completion_schema,
|
||||
commands::schema::get_column_details,
|
||||
// data
|
||||
commands::data::get_table_data,
|
||||
commands::data::update_row,
|
||||
@@ -55,10 +56,17 @@ pub fn run() {
|
||||
commands::management::get_table_privileges,
|
||||
commands::management::grant_revoke,
|
||||
commands::management::manage_role_membership,
|
||||
commands::management::list_sessions,
|
||||
commands::management::cancel_query,
|
||||
commands::management::terminate_backend,
|
||||
// history
|
||||
commands::history::add_history_entry,
|
||||
commands::history::get_history,
|
||||
commands::history::clear_history,
|
||||
// saved queries
|
||||
commands::saved_queries::list_saved_queries,
|
||||
commands::saved_queries::save_query,
|
||||
commands::saved_queries::delete_saved_query,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -79,6 +79,19 @@ pub struct TablePrivilege {
|
||||
pub is_grantable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub pid: i32,
|
||||
pub usename: Option<String>,
|
||||
pub datname: Option<String>,
|
||||
pub state: Option<String>,
|
||||
pub query: Option<String>,
|
||||
pub query_start: Option<String>,
|
||||
pub wait_event_type: Option<String>,
|
||||
pub wait_event: Option<String>,
|
||||
pub client_addr: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GrantRevokeParams {
|
||||
pub action: String,
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod connection;
|
||||
pub mod history;
|
||||
pub mod management;
|
||||
pub mod query_result;
|
||||
pub mod saved_queries;
|
||||
pub mod schema;
|
||||
|
||||
10
src-tauri/src/models/saved_queries.rs
Normal file
10
src-tauri/src/models/saved_queries.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedQuery {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub sql: String,
|
||||
pub connection_id: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
@@ -5,6 +5,8 @@ pub struct SchemaObject {
|
||||
pub name: String,
|
||||
pub object_type: String,
|
||||
pub schema: String,
|
||||
pub row_count: Option<i64>,
|
||||
pub size_bytes: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -18,6 +20,15 @@ pub struct ColumnInfo {
|
||||
pub is_primary_key: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ColumnDetail {
|
||||
pub column_name: String,
|
||||
pub data_type: String,
|
||||
pub is_nullable: bool,
|
||||
pub column_default: Option<String>,
|
||||
pub is_identity: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConstraintInfo {
|
||||
pub name: String,
|
||||
|
||||
@@ -7,6 +7,7 @@ interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onExecute: () => void;
|
||||
onFormat?: () => void;
|
||||
schema?: Record<string, Record<string, string[]>>;
|
||||
}
|
||||
|
||||
@@ -25,7 +26,7 @@ function buildSqlNamespace(
|
||||
return ns;
|
||||
}
|
||||
|
||||
export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
|
||||
export function SqlEditor({ value, onChange, onExecute, onFormat, schema }: Props) {
|
||||
const handleChange = useCallback(
|
||||
(val: string) => {
|
||||
onChange(val);
|
||||
@@ -56,9 +57,16 @@ export function SqlEditor({ value, onChange, onExecute, schema }: Props) {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Shift-Alt-f",
|
||||
run: () => {
|
||||
onFormat?.();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
}, [onExecute, schema]);
|
||||
}, [onExecute, onFormat, schema]);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SchemaTree } from "@/components/schema/SchemaTree";
|
||||
import { HistoryPanel } from "@/components/history/HistoryPanel";
|
||||
import { SavedQueriesPanel } from "@/components/saved-queries/SavedQueriesPanel";
|
||||
import { AdminPanel } from "@/components/management/AdminPanel";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
type SidebarView = "schema" | "history" | "admin";
|
||||
type SidebarView = "schema" | "history" | "saved" | "admin";
|
||||
|
||||
export function Sidebar() {
|
||||
const [view, setView] = useState<SidebarView>("schema");
|
||||
@@ -34,6 +35,16 @@ export function Sidebar() {
|
||||
>
|
||||
History
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
||||
view === "saved"
|
||||
? "bg-background text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => setView("saved")}
|
||||
>
|
||||
Saved
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 px-3 py-1.5 font-medium ${
|
||||
view === "admin"
|
||||
@@ -65,6 +76,8 @@ export function Sidebar() {
|
||||
</>
|
||||
) : view === "history" ? (
|
||||
<HistoryPanel />
|
||||
) : view === "saved" ? (
|
||||
<SavedQueriesPanel />
|
||||
) : (
|
||||
<AdminPanel />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { useConnections } from "@/hooks/use-connections";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { X, Table2, Code, Columns, Users } from "lucide-react";
|
||||
import { X, Table2, Code, Columns, Users, Activity } from "lucide-react";
|
||||
|
||||
export function TabBar() {
|
||||
const { tabs, activeTabId, setActiveTabId, closeTab } = useAppStore();
|
||||
@@ -14,6 +14,7 @@ export function TabBar() {
|
||||
table: <Table2 className="h-3 w-3" />,
|
||||
structure: <Columns className="h-3 w-3" />,
|
||||
roles: <Users className="h-3 w-3" />,
|
||||
sessions: <Activity className="h-3 w-3" />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ChevronRight,
|
||||
HardDrive,
|
||||
Users,
|
||||
Activity,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import type { Tab, RoleInfo } from "@/types";
|
||||
@@ -57,6 +58,18 @@ export function AdminPanel() {
|
||||
addTab(tab);
|
||||
}}
|
||||
/>
|
||||
<SessionsSection
|
||||
connectionId={activeConnectionId}
|
||||
onOpenSessions={() => {
|
||||
const tab: Tab = {
|
||||
id: crypto.randomUUID(),
|
||||
type: "sessions",
|
||||
title: "Active Sessions",
|
||||
connectionId: activeConnectionId,
|
||||
};
|
||||
addTab(tab);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -298,3 +311,34 @@ function RolesSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionsSection({
|
||||
connectionId,
|
||||
onOpenSessions,
|
||||
}: {
|
||||
connectionId: string;
|
||||
onOpenSessions: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="flex items-center gap-1 px-3 py-2">
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold flex-1">Sessions</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1 text-[10px]"
|
||||
onClick={onOpenSessions}
|
||||
title="View active sessions"
|
||||
>
|
||||
View Sessions
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-6 pb-2 text-xs text-muted-foreground">
|
||||
Monitor active database connections and running queries.
|
||||
{/* The connectionId is used by parent to open the sessions tab */}
|
||||
<span className="hidden">{connectionId}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
171
src/components/management/SessionsView.tsx
Normal file
171
src/components/management/SessionsView.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
useSessions,
|
||||
useCancelQuery,
|
||||
useTerminateBackend,
|
||||
} from "@/hooks/use-management";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2, XCircle, Skull, RefreshCw } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
function getStateBadge(state: string | null) {
|
||||
if (!state) return null;
|
||||
const colors: Record<string, string> = {
|
||||
idle: "bg-green-500/15 text-green-600",
|
||||
active: "bg-yellow-500/15 text-yellow-600",
|
||||
"idle in transaction": "bg-orange-500/15 text-orange-600",
|
||||
disabled: "bg-red-500/15 text-red-600",
|
||||
};
|
||||
return (
|
||||
<Badge variant="outline" className={`text-[9px] px-1 py-0 ${colors[state] ?? ""}`}>
|
||||
{state}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDuration(queryStart: string | null): string {
|
||||
if (!queryStart) return "-";
|
||||
const start = new Date(queryStart).getTime();
|
||||
const now = Date.now();
|
||||
const diffSec = Math.floor((now - start) / 1000);
|
||||
if (diffSec < 0) return "-";
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
|
||||
return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
|
||||
}
|
||||
|
||||
function getDurationColor(queryStart: string | null, state: string | null): string {
|
||||
if (state !== "active" || !queryStart) return "";
|
||||
const diffSec = (Date.now() - new Date(queryStart).getTime()) / 1000;
|
||||
if (diffSec > 30) return "text-red-500 font-semibold";
|
||||
if (diffSec > 5) return "text-yellow-500 font-semibold";
|
||||
return "";
|
||||
}
|
||||
|
||||
export function SessionsView({ connectionId }: Props) {
|
||||
const { data: sessions, isLoading } = useSessions(connectionId);
|
||||
const cancelMutation = useCancelQuery();
|
||||
const terminateMutation = useTerminateBackend();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleCancel = (pid: number) => {
|
||||
cancelMutation.mutate(
|
||||
{ connectionId, pid },
|
||||
{
|
||||
onSuccess: () => toast.success(`Cancel signal sent to PID ${pid}`),
|
||||
onError: (err) => toast.error("Cancel failed", { description: String(err) }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleTerminate = (pid: number) => {
|
||||
if (!confirm(`Terminate backend PID ${pid}? This will kill the session.`)) return;
|
||||
terminateMutation.mutate(
|
||||
{ connectionId, pid },
|
||||
{
|
||||
onSuccess: () => toast.success(`Terminate signal sent to PID ${pid}`),
|
||||
onError: (err) => toast.error("Terminate failed", { description: String(err) }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Loading sessions...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className="text-xs font-semibold">Active Sessions</span>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{sessions?.length ?? 0}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">Auto-refresh: 5s</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 gap-1 text-xs"
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ["sessions"] })}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-card border-b">
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">PID</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">User</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Database</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">State</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Duration</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Wait</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Query</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Client</th>
|
||||
<th className="px-2 py-1.5 text-left font-medium text-muted-foreground">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions?.map((s) => (
|
||||
<tr key={s.pid} className="border-b hover:bg-accent/50">
|
||||
<td className="px-2 py-1 font-mono">{s.pid}</td>
|
||||
<td className="px-2 py-1">{s.usename ?? "-"}</td>
|
||||
<td className="px-2 py-1">{s.datname ?? "-"}</td>
|
||||
<td className="px-2 py-1">{getStateBadge(s.state)}</td>
|
||||
<td className={`px-2 py-1 ${getDurationColor(s.query_start, s.state)}`}>
|
||||
{formatDuration(s.query_start)}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-muted-foreground">
|
||||
{s.wait_event ? `${s.wait_event_type}: ${s.wait_event}` : "-"}
|
||||
</td>
|
||||
<td className="px-2 py-1 max-w-xs truncate font-mono" title={s.query ?? ""}>
|
||||
{s.query ? (s.query.length > 60 ? s.query.slice(0, 60) + "..." : s.query) : "-"}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-muted-foreground">{s.client_addr ?? "-"}</td>
|
||||
<td className="px-2 py-1">
|
||||
<div className="flex gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
title="Cancel Query"
|
||||
onClick={() => handleCancel(s.pid)}
|
||||
>
|
||||
<XCircle className="h-3 w-3 text-yellow-500" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
title="Terminate Backend"
|
||||
onClick={() => handleTerminate(s.pid)}
|
||||
>
|
||||
<Skull className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(!sessions || sessions.length === 0) && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No active sessions
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { ArrowUp, ArrowDown } from "lucide-react";
|
||||
|
||||
interface ExternalSort {
|
||||
column: string | undefined;
|
||||
direction: string | undefined;
|
||||
onSort: (column: string | undefined, direction: string | undefined) => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: string[];
|
||||
types: string[];
|
||||
@@ -21,6 +27,7 @@ interface Props {
|
||||
value: unknown
|
||||
) => void;
|
||||
highlightedCells?: Set<string>;
|
||||
externalSort?: ExternalSort;
|
||||
}
|
||||
|
||||
export function ResultsTable({
|
||||
@@ -28,6 +35,7 @@ export function ResultsTable({
|
||||
rows,
|
||||
onCellDoubleClick,
|
||||
highlightedCells,
|
||||
externalSort,
|
||||
}: Props) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnResizeMode] = useState<ColumnResizeMode>("onChange");
|
||||
@@ -96,6 +104,38 @@ export function ResultsTable({
|
||||
[columnSizing]
|
||||
);
|
||||
|
||||
const handleHeaderClick = useCallback(
|
||||
(colName: string, defaultHandler: ((e: unknown) => void) | undefined) =>
|
||||
(e: unknown) => {
|
||||
if (externalSort) {
|
||||
// Cycle: none → ASC → DESC → none
|
||||
if (externalSort.column !== colName) {
|
||||
externalSort.onSort(colName, "ASC");
|
||||
} else if (externalSort.direction === "ASC") {
|
||||
externalSort.onSort(colName, "DESC");
|
||||
} else {
|
||||
externalSort.onSort(undefined, undefined);
|
||||
}
|
||||
} else {
|
||||
defaultHandler?.(e);
|
||||
}
|
||||
},
|
||||
[externalSort]
|
||||
);
|
||||
|
||||
const getIsSorted = useCallback(
|
||||
(colName: string, localSorted: false | "asc" | "desc") => {
|
||||
if (externalSort) {
|
||||
if (externalSort.column === colName) {
|
||||
return externalSort.direction === "ASC" ? "asc" : externalSort.direction === "DESC" ? "desc" : false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return localSorted;
|
||||
},
|
||||
[externalSort]
|
||||
);
|
||||
|
||||
if (colNames.length === 0) return null;
|
||||
|
||||
return (
|
||||
@@ -111,16 +151,16 @@ export function ResultsTable({
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 hover:text-foreground"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
onClick={handleHeaderClick(header.column.id, header.column.getToggleSortingHandler())}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() === "asc" && (
|
||||
{getIsSorted(header.column.id, header.column.getIsSorted()) === "asc" && (
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
)}
|
||||
{header.column.getIsSorted() === "desc" && (
|
||||
{getIsSorted(header.column.id, header.column.getIsSorted()) === "desc" && (
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
94
src/components/saved-queries/SaveQueryDialog.tsx
Normal file
94
src/components/saved-queries/SaveQueryDialog.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useSaveQuery } from "@/hooks/use-saved-queries";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sql: string;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const saveMutation = useSaveQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setName("");
|
||||
}, [open]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) {
|
||||
toast.error("Query name is required");
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate(
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
sql,
|
||||
connection_id: connectionId,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`Query "${name}" saved`);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error("Failed to save query", { description: String(err) });
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Query</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-3">
|
||||
<label className="text-right text-sm text-muted-foreground">Name</label>
|
||||
<Input
|
||||
className="col-span-3"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My query"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-start gap-3">
|
||||
<label className="text-right text-sm text-muted-foreground pt-1">SQL</label>
|
||||
<pre className="col-span-3 rounded bg-muted p-2 text-xs max-h-32 overflow-auto font-mono">
|
||||
{sql.length > 200 ? sql.slice(0, 200) + "..." : sql}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
81
src/components/saved-queries/SavedQueriesPanel.tsx
Normal file
81
src/components/saved-queries/SavedQueriesPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState } from "react";
|
||||
import { useSavedQueries, useDeleteSavedQuery } from "@/hooks/use-saved-queries";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Search, Trash2, Bookmark } from "lucide-react";
|
||||
import type { Tab } from "@/types";
|
||||
|
||||
export function SavedQueriesPanel() {
|
||||
const [search, setSearch] = useState("");
|
||||
const { activeConnectionId, addTab } = useAppStore();
|
||||
const { data: queries } = useSavedQueries(search || undefined);
|
||||
const deleteMutation = useDeleteSavedQuery();
|
||||
|
||||
const handleOpen = (sql: string, connectionId?: string) => {
|
||||
const cid = activeConnectionId ?? connectionId ?? "";
|
||||
if (!cid) return;
|
||||
const tab: Tab = {
|
||||
id: crypto.randomUUID(),
|
||||
type: "query",
|
||||
title: "Saved Query",
|
||||
connectionId: cid,
|
||||
sql,
|
||||
};
|
||||
addTab(tab);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search saved queries..."
|
||||
className="h-7 pl-7 text-xs"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{queries?.map((query) => (
|
||||
<div
|
||||
key={query.id}
|
||||
className="group flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent cursor-pointer"
|
||||
onDoubleClick={() => handleOpen(query.sql, query.connection_id)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Bookmark className="h-3 w-3 shrink-0 text-blue-400" />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
{query.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto h-5 w-5 shrink-0 opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate(query.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="truncate font-mono text-muted-foreground">
|
||||
{query.sql.length > 80 ? query.sql.slice(0, 80) + "..." : query.sql}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{new Date(query.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(!queries || queries.length === 0) && (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
No saved queries
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,33 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { GrantRevokeDialog } from "@/components/management/GrantRevokeDialog";
|
||||
import type { Tab } from "@/types";
|
||||
import type { Tab, SchemaObject } from "@/types";
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n < 0) return "~0";
|
||||
if (n < 1000) return `~${n}`;
|
||||
if (n < 1_000_000) return `~${(n / 1000).toFixed(1)}k`;
|
||||
return `~${(n / 1_000_000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function TableSizeInfo({ item }: { item: SchemaObject }) {
|
||||
if (item.row_count == null && item.size_bytes == null) return null;
|
||||
const parts: string[] = [];
|
||||
if (item.row_count != null) parts.push(formatCount(item.row_count));
|
||||
if (item.size_bytes != null) parts.push(formatSize(item.size_bytes));
|
||||
return (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground shrink-0">
|
||||
{parts.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchemaTree() {
|
||||
const { activeConnectionId, currentDatabase, setCurrentDatabase, addTab } =
|
||||
@@ -407,6 +433,7 @@ function CategoryNode({
|
||||
<span className="w-3.5 shrink-0" />
|
||||
{icon}
|
||||
<span className="truncate">{item.name}</span>
|
||||
{category === "tables" && <TableSizeInfo item={item} />}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
|
||||
162
src/components/table-viewer/InsertRowDialog.tsx
Normal file
162
src/components/table-viewer/InsertRowDialog.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useColumnDetails } from "@/hooks/use-schema";
|
||||
import { insertRow } from "@/lib/tauri";
|
||||
import { toast } from "sonner";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
connectionId: string;
|
||||
schema: string;
|
||||
table: string;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function InsertRowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
connectionId,
|
||||
schema,
|
||||
table,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
const { data: columns } = useColumnDetails(
|
||||
open ? connectionId : null,
|
||||
open ? schema : null,
|
||||
open ? table : null
|
||||
);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [skipColumns, setSkipColumns] = useState<Set<string>>(new Set());
|
||||
const [isInserting, setIsInserting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && columns) {
|
||||
const initial: Record<string, string> = {};
|
||||
const skip = new Set<string>();
|
||||
for (const col of columns) {
|
||||
if (col.is_identity || col.column_default?.startsWith("nextval(")) {
|
||||
skip.add(col.column_name);
|
||||
} else if (col.column_default != null) {
|
||||
initial[col.column_name] = col.column_default;
|
||||
} else {
|
||||
initial[col.column_name] = "";
|
||||
}
|
||||
}
|
||||
setValues(initial);
|
||||
setSkipColumns(skip);
|
||||
}
|
||||
}, [open, columns]);
|
||||
|
||||
const handleInsert = async () => {
|
||||
if (!columns) return;
|
||||
setIsInserting(true);
|
||||
try {
|
||||
const cols: string[] = [];
|
||||
const vals: unknown[] = [];
|
||||
for (const col of columns) {
|
||||
if (skipColumns.has(col.column_name)) continue;
|
||||
const val = values[col.column_name];
|
||||
if (val === "" && col.is_nullable) {
|
||||
cols.push(col.column_name);
|
||||
vals.push(null);
|
||||
} else if (val !== undefined) {
|
||||
cols.push(col.column_name);
|
||||
vals.push(val);
|
||||
}
|
||||
}
|
||||
await insertRow({ connectionId, schema, table, columns: cols, values: vals });
|
||||
toast.success("Row inserted");
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
toast.error("Insert failed", { description: String(err) });
|
||||
} finally {
|
||||
setIsInserting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSkip = (colName: string) => {
|
||||
setSkipColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(colName)) {
|
||||
next.delete(colName);
|
||||
} else {
|
||||
next.add(colName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Insert Row into {table}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-2 py-2">
|
||||
{columns?.map((col) => {
|
||||
const isSkipped = skipColumns.has(col.column_name);
|
||||
return (
|
||||
<div key={col.column_name} className="grid grid-cols-4 items-center gap-2">
|
||||
<div className="flex items-center gap-1 col-span-1 justify-end">
|
||||
<span className="text-sm truncate">{col.column_name}</span>
|
||||
<Badge variant="outline" className="text-[9px] px-1 py-0 shrink-0">
|
||||
{col.data_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="col-span-3 flex items-center gap-1">
|
||||
{isSkipped ? (
|
||||
<span className="text-xs text-muted-foreground italic flex-1 px-2">
|
||||
auto-generated
|
||||
</span>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1 text-sm"
|
||||
value={values[col.column_name] ?? ""}
|
||||
onChange={(e) =>
|
||||
setValues((prev) => ({ ...prev, [col.column_name]: e.target.value }))
|
||||
}
|
||||
placeholder={
|
||||
col.is_nullable ? "NULL" : col.column_default ?? ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-[10px] shrink-0"
|
||||
onClick={() => toggleSkip(col.column_name)}
|
||||
>
|
||||
{isSkipped ? "Include" : "Skip"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleInsert} disabled={isInserting}>
|
||||
{isInserting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Insert
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,8 @@ import { getTableColumns } from "@/lib/tauri";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { toast } from "sonner";
|
||||
import { Save, RotateCcw, Filter, Loader2, Lock, Download } from "lucide-react";
|
||||
import { Save, RotateCcw, Filter, Loader2, Lock, Download, Plus } from "lucide-react";
|
||||
import { InsertRowDialog } from "./InsertRowDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -31,14 +32,15 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
const [sortColumn, _setSortColumn] = useState<string | undefined>();
|
||||
const [sortDirection, _setSortDirection] = useState<string | undefined>();
|
||||
const [sortColumn, setSortColumn] = useState<string | undefined>();
|
||||
const [sortDirection, setSortDirection] = useState<string | undefined>();
|
||||
const [filter, setFilter] = useState("");
|
||||
const [appliedFilter, setAppliedFilter] = useState<string | undefined>();
|
||||
const [pendingChanges, setPendingChanges] = useState<
|
||||
Map<string, { rowIndex: number; colIndex: number; value: string | null }>
|
||||
>(new Map());
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [insertDialogOpen, setInsertDialogOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error } = useTableData({
|
||||
@@ -155,6 +157,15 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
[data, table]
|
||||
);
|
||||
|
||||
const handleSort = useCallback(
|
||||
(column: string | undefined, direction: string | undefined) => {
|
||||
setSortColumn(column);
|
||||
setSortDirection(direction);
|
||||
setPage(1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleApplyFilter = () => {
|
||||
setAppliedFilter(filter || undefined);
|
||||
setPage(1);
|
||||
@@ -207,6 +218,17 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 gap-1 text-xs"
|
||||
onClick={() => setInsertDialogOpen(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Insert Row
|
||||
</Button>
|
||||
)}
|
||||
{pendingChanges.size > 0 && (
|
||||
<>
|
||||
<Button
|
||||
@@ -253,6 +275,11 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
rows={data.rows}
|
||||
onCellDoubleClick={handleCellDoubleClick}
|
||||
highlightedCells={highlightedCells}
|
||||
externalSort={{
|
||||
column: sortColumn,
|
||||
direction: sortDirection,
|
||||
onSort: handleSort,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -269,6 +296,19 @@ export function TableDataView({ connectionId, schema, table }: Props) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<InsertRowDialog
|
||||
open={insertDialogOpen}
|
||||
onOpenChange={setInsertDialogOpen}
|
||||
connectionId={connectionId}
|
||||
schema={schema}
|
||||
table={table}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["table-data", connectionId],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { WorkspacePanel } from "./WorkspacePanel";
|
||||
import { TableDataView } from "@/components/table-viewer/TableDataView";
|
||||
import { TableStructure } from "@/components/table-viewer/TableStructure";
|
||||
import { RoleManagerView } from "@/components/management/RoleManagerView";
|
||||
import { SessionsView } from "@/components/management/SessionsView";
|
||||
|
||||
export function TabContent() {
|
||||
const { tabs, activeTabId, updateTab } = useAppStore();
|
||||
@@ -51,6 +52,13 @@ export function TabContent() {
|
||||
connectionId={activeTab.connectionId}
|
||||
/>
|
||||
);
|
||||
case "sessions":
|
||||
return (
|
||||
<SessionsView
|
||||
key={activeTab.id}
|
||||
connectionId={activeTab.connectionId}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { useCompletionSchema } from "@/hooks/use-completion-schema";
|
||||
import { useConnections } from "@/hooks/use-connections";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Play, Loader2, Lock, BarChart3, Download } from "lucide-react";
|
||||
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark } from "lucide-react";
|
||||
import { format as formatSql } from "sql-formatter";
|
||||
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -47,6 +49,7 @@ export function WorkspacePanel({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [explainData, setExplainData] = useState<ExplainResult | null>(null);
|
||||
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||
|
||||
const queryMutation = useQueryExecution();
|
||||
const addHistoryMutation = useAddHistory();
|
||||
@@ -150,6 +153,17 @@ export function WorkspacePanel({
|
||||
);
|
||||
}, [connectionId, sqlValue, queryMutation]);
|
||||
|
||||
const handleFormat = useCallback(() => {
|
||||
if (!sqlValue.trim()) return;
|
||||
try {
|
||||
const formatted = formatSql(sqlValue, { language: "postgresql" });
|
||||
setSqlValue(formatted);
|
||||
onSqlChange?.(formatted);
|
||||
} catch {
|
||||
// Silently ignore format errors on invalid SQL
|
||||
}
|
||||
}, [sqlValue, onSqlChange]);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (format: "csv" | "json") => {
|
||||
if (!result || result.columns.length === 0) return;
|
||||
@@ -175,6 +189,7 @@ export function WorkspacePanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResizablePanelGroup orientation="vertical">
|
||||
<ResizablePanel id="editor" defaultSize="40%" minSize="15%">
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -207,6 +222,28 @@ export function WorkspacePanel({
|
||||
)}
|
||||
Explain
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 gap-1 text-xs"
|
||||
onClick={handleFormat}
|
||||
disabled={!sqlValue.trim()}
|
||||
title="Format SQL (Shift+Alt+F)"
|
||||
>
|
||||
<AlignLeft className="h-3 w-3" />
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 gap-1 text-xs"
|
||||
onClick={() => setSaveDialogOpen(true)}
|
||||
disabled={!sqlValue.trim()}
|
||||
title="Save query"
|
||||
>
|
||||
<Bookmark className="h-3 w-3" />
|
||||
Save
|
||||
</Button>
|
||||
{result && result.columns.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -244,6 +281,7 @@ export function WorkspacePanel({
|
||||
value={sqlValue}
|
||||
onChange={handleChange}
|
||||
onExecute={handleExecute}
|
||||
onFormat={handleFormat}
|
||||
schema={completionSchema}
|
||||
/>
|
||||
</div>
|
||||
@@ -288,5 +326,13 @@ export function WorkspacePanel({
|
||||
)}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<SaveQueryDialog
|
||||
open={saveDialogOpen}
|
||||
onOpenChange={setSaveDialogOpen}
|
||||
sql={sqlValue}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
getTablePrivileges,
|
||||
grantRevoke,
|
||||
manageRoleMembership,
|
||||
listSessions,
|
||||
cancelQuery,
|
||||
terminateBackend,
|
||||
} from "@/lib/tauri";
|
||||
import type {
|
||||
CreateDatabaseParams,
|
||||
@@ -149,6 +152,39 @@ export function useGrantRevoke() {
|
||||
});
|
||||
}
|
||||
|
||||
// Sessions
|
||||
|
||||
export function useSessions(connectionId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["sessions", connectionId],
|
||||
queryFn: () => listSessions(connectionId!),
|
||||
enabled: !!connectionId,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) =>
|
||||
cancelQuery(connectionId, pid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useTerminateBackend() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ connectionId, pid }: { connectionId: string; pid: number }) =>
|
||||
terminateBackend(connectionId, pid),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useManageRoleMembership() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
34
src/hooks/use-saved-queries.ts
Normal file
34
src/hooks/use-saved-queries.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
listSavedQueries,
|
||||
saveQuery,
|
||||
deleteSavedQuery,
|
||||
} from "@/lib/tauri";
|
||||
import type { SavedQuery } from "@/types";
|
||||
|
||||
export function useSavedQueries(search?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["saved-queries", search],
|
||||
queryFn: () => listSavedQueries({ search }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (query: SavedQuery) => saveQuery(query),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["saved-queries"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSavedQuery() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteSavedQuery(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["saved-queries"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
listFunctions,
|
||||
listSequences,
|
||||
switchDatabase,
|
||||
getColumnDetails,
|
||||
} from "@/lib/tauri";
|
||||
import type { ConnectionConfig } from "@/types";
|
||||
|
||||
@@ -72,3 +73,11 @@ export function useSequences(connectionId: string | null, schema: string) {
|
||||
enabled: !!connectionId && !!schema,
|
||||
});
|
||||
}
|
||||
|
||||
export function useColumnDetails(connectionId: string | null, schema: string | null, table: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["column-details", connectionId, schema, table],
|
||||
queryFn: () => getColumnDetails(connectionId!, schema!, table!),
|
||||
enabled: !!connectionId && !!schema && !!table,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import type {
|
||||
QueryResult,
|
||||
PaginatedQueryResult,
|
||||
SchemaObject,
|
||||
ColumnDetail,
|
||||
ColumnInfo,
|
||||
ConstraintInfo,
|
||||
IndexInfo,
|
||||
HistoryEntry,
|
||||
SavedQuery,
|
||||
SessionInfo,
|
||||
DatabaseInfo,
|
||||
CreateDatabaseParams,
|
||||
RoleInfo,
|
||||
@@ -79,6 +82,12 @@ export const getTableColumns = (
|
||||
table: string
|
||||
) => invoke<ColumnInfo[]>("get_table_columns", { connectionId, schema, table });
|
||||
|
||||
export const getColumnDetails = (
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
table: string
|
||||
) => invoke<ColumnDetail[]>("get_column_details", { connectionId, schema, table });
|
||||
|
||||
export const getTableConstraints = (
|
||||
connectionId: string,
|
||||
schema: string,
|
||||
@@ -151,6 +160,16 @@ export const getHistory = (params?: {
|
||||
|
||||
export const clearHistory = () => invoke<void>("clear_history");
|
||||
|
||||
// Saved Queries
|
||||
export const listSavedQueries = (params?: { search?: string }) =>
|
||||
invoke<SavedQuery[]>("list_saved_queries", { search: params?.search });
|
||||
|
||||
export const saveQuery = (query: SavedQuery) =>
|
||||
invoke<void>("save_query", { query });
|
||||
|
||||
export const deleteSavedQuery = (id: string) =>
|
||||
invoke<void>("delete_saved_query", { id });
|
||||
|
||||
// Completion schema
|
||||
export const getCompletionSchema = (connectionId: string) =>
|
||||
invoke<Record<string, Record<string, string[]>>>(
|
||||
@@ -201,3 +220,13 @@ export const grantRevoke = (connectionId: string, params: GrantRevokeParams) =>
|
||||
|
||||
export const manageRoleMembership = (connectionId: string, params: RoleMembershipParams) =>
|
||||
invoke<void>("manage_role_membership", { connectionId, params });
|
||||
|
||||
// Sessions
|
||||
export const listSessions = (connectionId: string) =>
|
||||
invoke<SessionInfo[]>("list_sessions", { connectionId });
|
||||
|
||||
export const cancelQuery = (connectionId: string, pid: number) =>
|
||||
invoke<boolean>("cancel_query", { connectionId, pid });
|
||||
|
||||
export const terminateBackend = (connectionId: string, pid: number) =>
|
||||
invoke<boolean>("terminate_backend", { connectionId, pid });
|
||||
|
||||
@@ -29,6 +29,16 @@ export interface SchemaObject {
|
||||
name: string;
|
||||
object_type: string;
|
||||
schema: string;
|
||||
row_count?: number;
|
||||
size_bytes?: number;
|
||||
}
|
||||
|
||||
export interface ColumnDetail {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
column_default: string | null;
|
||||
is_identity: boolean;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
@@ -186,7 +196,27 @@ export interface RoleMembershipParams {
|
||||
member_name: string;
|
||||
}
|
||||
|
||||
export type TabType = "query" | "table" | "structure" | "roles";
|
||||
export interface SessionInfo {
|
||||
pid: number;
|
||||
usename: string | null;
|
||||
datname: string | null;
|
||||
state: string | null;
|
||||
query: string | null;
|
||||
query_start: string | null;
|
||||
wait_event_type: string | null;
|
||||
wait_event: string | null;
|
||||
client_addr: string | null;
|
||||
}
|
||||
|
||||
export interface SavedQuery {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
connection_id?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type TabType = "query" | "table" | "structure" | "roles" | "sessions";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user