feat(storage): add comprehensive database management interface
- Add new storage commands module with full CRUD operations - Implement SQLite database viewer with table browsing, search, and pagination - Add row editing, insertion, and deletion capabilities - Include SQL query editor for advanced operations - Add database reset functionality with confirmation dialogs - Export storage API methods for frontend integration - Add storage tab to settings with modern UI components - Implement comprehensive error handling and loading states - Add tooltips for truncated content and responsive design - Include proper TypeScript interfaces for all data structures This enables users to directly interact with the SQLite database through a user-friendly interface, providing transparency and control over stored data.
This commit is contained in:
@@ -2,3 +2,4 @@ pub mod agents;
|
|||||||
pub mod claude;
|
pub mod claude;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
pub mod storage;
|
||||||
|
516
src-tauri/src/commands/storage.rs
Normal file
516
src-tauri/src/commands/storage.rs
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use rusqlite::{params, Connection, Result as SqliteResult, types::ValueRef};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value as JsonValue};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
use super::agents::AgentDb;
|
||||||
|
|
||||||
|
/// Represents metadata about a database table
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct TableInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub row_count: i64,
|
||||||
|
pub columns: Vec<ColumnInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents metadata about a table column
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ColumnInfo {
|
||||||
|
pub cid: i32,
|
||||||
|
pub name: String,
|
||||||
|
pub type_name: String,
|
||||||
|
pub notnull: bool,
|
||||||
|
pub dflt_value: Option<String>,
|
||||||
|
pub pk: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a page of table data
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct TableData {
|
||||||
|
pub table_name: String,
|
||||||
|
pub columns: Vec<ColumnInfo>,
|
||||||
|
pub rows: Vec<Map<String, JsonValue>>,
|
||||||
|
pub total_rows: i64,
|
||||||
|
pub page: i64,
|
||||||
|
pub page_size: i64,
|
||||||
|
pub total_pages: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SQL query result
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct QueryResult {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub rows: Vec<Vec<JsonValue>>,
|
||||||
|
pub rows_affected: Option<i64>,
|
||||||
|
pub last_insert_rowid: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all tables in the database
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn storage_list_tables(db: State<'_, AgentDb>) -> Result<Vec<TableInfo>, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Query for all tables
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let table_names: Vec<String> = stmt
|
||||||
|
.query_map([], |row| row.get(0))
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<SqliteResult<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
drop(stmt);
|
||||||
|
|
||||||
|
let mut tables = Vec::new();
|
||||||
|
|
||||||
|
for table_name in table_names {
|
||||||
|
// Get row count
|
||||||
|
let row_count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
&format!("SELECT COUNT(*) FROM {}", table_name),
|
||||||
|
[],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Get column information
|
||||||
|
let mut pragma_stmt = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({})", table_name))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let columns: Vec<ColumnInfo> = pragma_stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(ColumnInfo {
|
||||||
|
cid: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
type_name: row.get(2)?,
|
||||||
|
notnull: row.get::<_, i32>(3)? != 0,
|
||||||
|
dflt_value: row.get(4)?,
|
||||||
|
pk: row.get::<_, i32>(5)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<SqliteResult<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
tables.push(TableInfo {
|
||||||
|
name: table_name,
|
||||||
|
row_count,
|
||||||
|
columns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tables)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read table data with pagination
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub async fn storage_read_table(
|
||||||
|
db: State<'_, AgentDb>,
|
||||||
|
tableName: String,
|
||||||
|
page: i64,
|
||||||
|
pageSize: i64,
|
||||||
|
searchQuery: Option<String>,
|
||||||
|
) -> Result<TableData, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Validate table name to prevent SQL injection
|
||||||
|
if !is_valid_table_name(&conn, &tableName)? {
|
||||||
|
return Err("Invalid table name".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get column information
|
||||||
|
let mut pragma_stmt = conn
|
||||||
|
.prepare(&format!("PRAGMA table_info({})", tableName))
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let columns: Vec<ColumnInfo> = pragma_stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(ColumnInfo {
|
||||||
|
cid: row.get(0)?,
|
||||||
|
name: row.get(1)?,
|
||||||
|
type_name: row.get(2)?,
|
||||||
|
notnull: row.get::<_, i32>(3)? != 0,
|
||||||
|
dflt_value: row.get(4)?,
|
||||||
|
pk: row.get::<_, i32>(5)? != 0,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<SqliteResult<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
drop(pragma_stmt);
|
||||||
|
|
||||||
|
// Build query with optional search
|
||||||
|
let (query, count_query) = if let Some(search) = &searchQuery {
|
||||||
|
// Create search conditions for all text columns
|
||||||
|
let search_conditions: Vec<String> = columns
|
||||||
|
.iter()
|
||||||
|
.filter(|col| col.type_name.contains("TEXT") || col.type_name.contains("VARCHAR"))
|
||||||
|
.map(|col| format!("{} LIKE '%{}%'", col.name, search.replace("'", "''")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if search_conditions.is_empty() {
|
||||||
|
(
|
||||||
|
format!("SELECT * FROM {} LIMIT ? OFFSET ?", tableName),
|
||||||
|
format!("SELECT COUNT(*) FROM {}", tableName),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let where_clause = search_conditions.join(" OR ");
|
||||||
|
(
|
||||||
|
format!("SELECT * FROM {} WHERE {} LIMIT ? OFFSET ?", tableName, where_clause),
|
||||||
|
format!("SELECT COUNT(*) FROM {} WHERE {}", tableName, where_clause),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
format!("SELECT * FROM {} LIMIT ? OFFSET ?", tableName),
|
||||||
|
format!("SELECT COUNT(*) FROM {}", tableName),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get total row count
|
||||||
|
let total_rows: i64 = conn
|
||||||
|
.query_row(&count_query, [], |row| row.get(0))
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
let offset = (page - 1) * pageSize;
|
||||||
|
let total_pages = (total_rows as f64 / pageSize as f64).ceil() as i64;
|
||||||
|
|
||||||
|
// Query data
|
||||||
|
let mut data_stmt = conn
|
||||||
|
.prepare(&query)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let rows: Vec<Map<String, JsonValue>> = data_stmt
|
||||||
|
.query_map(params![pageSize, offset], |row| {
|
||||||
|
let mut row_map = Map::new();
|
||||||
|
|
||||||
|
for (idx, col) in columns.iter().enumerate() {
|
||||||
|
let value = match row.get_ref(idx)? {
|
||||||
|
ValueRef::Null => JsonValue::Null,
|
||||||
|
ValueRef::Integer(i) => JsonValue::Number(serde_json::Number::from(i)),
|
||||||
|
ValueRef::Real(f) => {
|
||||||
|
if let Some(n) = serde_json::Number::from_f64(f) {
|
||||||
|
JsonValue::Number(n)
|
||||||
|
} else {
|
||||||
|
JsonValue::String(f.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ValueRef::Text(s) => JsonValue::String(String::from_utf8_lossy(s).to_string()),
|
||||||
|
ValueRef::Blob(b) => JsonValue::String(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b)),
|
||||||
|
};
|
||||||
|
row_map.insert(col.name.clone(), value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(row_map)
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<SqliteResult<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(TableData {
|
||||||
|
table_name: tableName,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
total_rows,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a row in a table
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub async fn storage_update_row(
|
||||||
|
db: State<'_, AgentDb>,
|
||||||
|
tableName: String,
|
||||||
|
primaryKeyValues: HashMap<String, JsonValue>,
|
||||||
|
updates: HashMap<String, JsonValue>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Validate table name
|
||||||
|
if !is_valid_table_name(&conn, &tableName)? {
|
||||||
|
return Err("Invalid table name".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build UPDATE query
|
||||||
|
let set_clauses: Vec<String> = updates
|
||||||
|
.keys()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, key)| format!("{} = ?{}", key, idx + 1))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let where_clauses: Vec<String> = primaryKeyValues
|
||||||
|
.keys()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, key)| format!("{} = ?{}", key, idx + updates.len() + 1))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"UPDATE {} SET {} WHERE {}",
|
||||||
|
tableName,
|
||||||
|
set_clauses.join(", "),
|
||||||
|
where_clauses.join(" AND ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare parameters
|
||||||
|
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
// Add update values
|
||||||
|
for value in updates.values() {
|
||||||
|
params.push(json_to_sql_value(value)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add where clause values
|
||||||
|
for value in primaryKeyValues.values() {
|
||||||
|
params.push(json_to_sql_value(value)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute update
|
||||||
|
conn.execute(&query, rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())))
|
||||||
|
.map_err(|e| format!("Failed to update row: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a row from a table
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub async fn storage_delete_row(
|
||||||
|
db: State<'_, AgentDb>,
|
||||||
|
tableName: String,
|
||||||
|
primaryKeyValues: HashMap<String, JsonValue>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Validate table name
|
||||||
|
if !is_valid_table_name(&conn, &tableName)? {
|
||||||
|
return Err("Invalid table name".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build DELETE query
|
||||||
|
let where_clauses: Vec<String> = primaryKeyValues
|
||||||
|
.keys()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(idx, key)| format!("{} = ?{}", key, idx + 1))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"DELETE FROM {} WHERE {}",
|
||||||
|
tableName,
|
||||||
|
where_clauses.join(" AND ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare parameters
|
||||||
|
let params: Vec<Box<dyn rusqlite::ToSql>> = primaryKeyValues
|
||||||
|
.values()
|
||||||
|
.map(json_to_sql_value)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
// Execute delete
|
||||||
|
conn.execute(&query, rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())))
|
||||||
|
.map_err(|e| format!("Failed to delete row: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Insert a new row into a table
|
||||||
|
#[tauri::command]
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
pub async fn storage_insert_row(
|
||||||
|
db: State<'_, AgentDb>,
|
||||||
|
tableName: String,
|
||||||
|
values: HashMap<String, JsonValue>,
|
||||||
|
) -> Result<i64, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Validate table name
|
||||||
|
if !is_valid_table_name(&conn, &tableName)? {
|
||||||
|
return Err("Invalid table name".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build INSERT query
|
||||||
|
let columns: Vec<&String> = values.keys().collect();
|
||||||
|
let placeholders: Vec<String> = (1..=columns.len())
|
||||||
|
.map(|i| format!("?{}", i))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({})",
|
||||||
|
tableName,
|
||||||
|
columns.iter().map(|c| c.as_str()).collect::<Vec<_>>().join(", "),
|
||||||
|
placeholders.join(", ")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare parameters
|
||||||
|
let params: Vec<Box<dyn rusqlite::ToSql>> = values
|
||||||
|
.values()
|
||||||
|
.map(json_to_sql_value)
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
// Execute insert
|
||||||
|
conn.execute(&query, rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())))
|
||||||
|
.map_err(|e| format!("Failed to insert row: {}", e))?;
|
||||||
|
|
||||||
|
Ok(conn.last_insert_rowid())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Execute a raw SQL query
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn storage_execute_sql(
|
||||||
|
db: State<'_, AgentDb>,
|
||||||
|
query: String,
|
||||||
|
) -> Result<QueryResult, String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Check if it's a SELECT query
|
||||||
|
let is_select = query.trim().to_uppercase().starts_with("SELECT");
|
||||||
|
|
||||||
|
if is_select {
|
||||||
|
// Handle SELECT queries
|
||||||
|
let mut stmt = conn.prepare(&query).map_err(|e| e.to_string())?;
|
||||||
|
let column_count = stmt.column_count();
|
||||||
|
|
||||||
|
// Get column names
|
||||||
|
let columns: Vec<String> = (0..column_count)
|
||||||
|
.map(|i| stmt.column_name(i).unwrap_or("").to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Execute query and collect results
|
||||||
|
let rows: Vec<Vec<JsonValue>> = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
let mut row_values = Vec::new();
|
||||||
|
for i in 0..column_count {
|
||||||
|
let value = match row.get_ref(i)? {
|
||||||
|
ValueRef::Null => JsonValue::Null,
|
||||||
|
ValueRef::Integer(n) => JsonValue::Number(serde_json::Number::from(n)),
|
||||||
|
ValueRef::Real(f) => {
|
||||||
|
if let Some(n) = serde_json::Number::from_f64(f) {
|
||||||
|
JsonValue::Number(n)
|
||||||
|
} else {
|
||||||
|
JsonValue::String(f.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ValueRef::Text(s) => JsonValue::String(String::from_utf8_lossy(s).to_string()),
|
||||||
|
ValueRef::Blob(b) => JsonValue::String(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b)),
|
||||||
|
};
|
||||||
|
row_values.push(value);
|
||||||
|
}
|
||||||
|
Ok(row_values)
|
||||||
|
})
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.collect::<SqliteResult<Vec<_>>>()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(QueryResult {
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
rows_affected: None,
|
||||||
|
last_insert_rowid: None,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Handle non-SELECT queries (INSERT, UPDATE, DELETE, etc.)
|
||||||
|
let rows_affected = conn.execute(&query, []).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(QueryResult {
|
||||||
|
columns: vec![],
|
||||||
|
rows: vec![],
|
||||||
|
rows_affected: Some(rows_affected as i64),
|
||||||
|
last_insert_rowid: Some(conn.last_insert_rowid()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reset the entire database (with confirmation)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn storage_reset_database(app: AppHandle) -> Result<(), String> {
|
||||||
|
{
|
||||||
|
// Drop all existing tables within a scoped block
|
||||||
|
let db_state = app.state::<AgentDb>();
|
||||||
|
let conn = db_state.0.lock()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Disable foreign key constraints temporarily to allow dropping tables
|
||||||
|
conn.execute("PRAGMA foreign_keys = OFF", [])
|
||||||
|
.map_err(|e| format!("Failed to disable foreign keys: {}", e))?;
|
||||||
|
|
||||||
|
// Drop tables - order doesn't matter with foreign keys disabled
|
||||||
|
conn.execute("DROP TABLE IF EXISTS agent_runs", [])
|
||||||
|
.map_err(|e| format!("Failed to drop agent_runs table: {}", e))?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS agents", [])
|
||||||
|
.map_err(|e| format!("Failed to drop agents table: {}", e))?;
|
||||||
|
conn.execute("DROP TABLE IF EXISTS app_settings", [])
|
||||||
|
.map_err(|e| format!("Failed to drop app_settings table: {}", e))?;
|
||||||
|
|
||||||
|
// Re-enable foreign key constraints
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON", [])
|
||||||
|
.map_err(|e| format!("Failed to re-enable foreign keys: {}", e))?;
|
||||||
|
|
||||||
|
// Connection is automatically dropped at end of scope
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-initialize the database which will recreate all tables empty
|
||||||
|
let new_conn = init_database(&app).map_err(|e| format!("Failed to reset database: {}", e))?;
|
||||||
|
|
||||||
|
// Update the managed state with the new connection
|
||||||
|
{
|
||||||
|
let db_state = app.state::<AgentDb>();
|
||||||
|
let mut conn_guard = db_state.0.lock()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
*conn_guard = new_conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run VACUUM to optimize the database
|
||||||
|
{
|
||||||
|
let db_state = app.state::<AgentDb>();
|
||||||
|
let conn = db_state.0.lock()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("VACUUM", [])
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to validate table name exists
|
||||||
|
fn is_valid_table_name(conn: &Connection, table_name: &str) -> Result<bool, String> {
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
params![table_name],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(count > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to convert JSON value to SQL value
|
||||||
|
fn json_to_sql_value(value: &JsonValue) -> Result<Box<dyn rusqlite::ToSql>, String> {
|
||||||
|
match value {
|
||||||
|
JsonValue::Null => Ok(Box::new(rusqlite::types::Null)),
|
||||||
|
JsonValue::Bool(b) => Ok(Box::new(*b)),
|
||||||
|
JsonValue::Number(n) => {
|
||||||
|
if let Some(i) = n.as_i64() {
|
||||||
|
Ok(Box::new(i))
|
||||||
|
} else if let Some(f) = n.as_f64() {
|
||||||
|
Ok(Box::new(f))
|
||||||
|
} else {
|
||||||
|
Err("Invalid number value".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JsonValue::String(s) => Ok(Box::new(s.clone())),
|
||||||
|
_ => Err("Unsupported value type".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the agents database (re-exported from agents module)
|
||||||
|
use super::agents::init_database;
|
@@ -37,6 +37,10 @@ use commands::mcp::{
|
|||||||
use commands::usage::{
|
use commands::usage::{
|
||||||
get_session_stats, get_usage_by_date_range, get_usage_details, get_usage_stats,
|
get_session_stats, get_usage_by_date_range, get_usage_details, get_usage_stats,
|
||||||
};
|
};
|
||||||
|
use commands::storage::{
|
||||||
|
storage_list_tables, storage_read_table, storage_update_row, storage_delete_row,
|
||||||
|
storage_insert_row, storage_execute_sql, storage_reset_database,
|
||||||
|
};
|
||||||
use process::ProcessRegistryState;
|
use process::ProcessRegistryState;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -163,7 +167,13 @@ fn main() {
|
|||||||
mcp_get_server_status,
|
mcp_get_server_status,
|
||||||
mcp_read_project_config,
|
mcp_read_project_config,
|
||||||
mcp_save_project_config,
|
mcp_save_project_config,
|
||||||
|
storage_list_tables,
|
||||||
|
storage_read_table,
|
||||||
|
storage_update_row,
|
||||||
|
storage_delete_row,
|
||||||
|
storage_insert_row,
|
||||||
|
storage_execute_sql,
|
||||||
|
storage_reset_database,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
@@ -10,7 +10,8 @@ import {
|
|||||||
Code,
|
Code,
|
||||||
Settings2,
|
Settings2,
|
||||||
Terminal,
|
Terminal,
|
||||||
Loader2
|
Loader2,
|
||||||
|
Database
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Toast, ToastContainer } from "@/components/ui/toast";
|
import { Toast, ToastContainer } from "@/components/ui/toast";
|
||||||
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
import { ClaudeVersionSelector } from "./ClaudeVersionSelector";
|
||||||
|
import { StorageTab } from "./StorageTab";
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
/**
|
/**
|
||||||
@@ -369,6 +371,10 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
<Code className="h-4 w-4 text-purple-500" />
|
<Code className="h-4 w-4 text-purple-500" />
|
||||||
Advanced
|
Advanced
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="storage" className="gap-2">
|
||||||
|
<Database className="h-4 w-4 text-green-500" />
|
||||||
|
Storage
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* General Settings */}
|
{/* General Settings */}
|
||||||
@@ -683,6 +689,11 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Storage Tab */}
|
||||||
|
<TabsContent value="storage">
|
||||||
|
<StorageTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
956
src/components/StorageTab.tsx
Normal file
956
src/components/StorageTab.tsx
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Database,
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Edit3,
|
||||||
|
Trash2,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Terminal,
|
||||||
|
AlertTriangle,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Table,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Toast, ToastContainer } from "./ui/toast";
|
||||||
|
|
||||||
|
interface TableInfo {
|
||||||
|
name: string;
|
||||||
|
row_count: number;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnInfo {
|
||||||
|
cid: number;
|
||||||
|
name: string;
|
||||||
|
type_name: string;
|
||||||
|
notnull: boolean;
|
||||||
|
dflt_value: string | null;
|
||||||
|
pk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableData {
|
||||||
|
table_name: string;
|
||||||
|
columns: ColumnInfo[];
|
||||||
|
rows: Record<string, any>[];
|
||||||
|
total_rows: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QueryResult {
|
||||||
|
columns: string[];
|
||||||
|
rows: any[][];
|
||||||
|
rows_affected?: number;
|
||||||
|
last_insert_rowid?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StorageTab component - A beautiful SQLite database viewer/editor
|
||||||
|
*/
|
||||||
|
export const StorageTab: React.FC = () => {
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [selectedTable, setSelectedTable] = useState<string>("");
|
||||||
|
const [tableData, setTableData] = useState<TableData | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize] = useState(25);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const [editingRow, setEditingRow] = useState<Record<string, any> | null>(null);
|
||||||
|
const [newRow, setNewRow] = useState<Record<string, any> | null>(null);
|
||||||
|
const [deletingRow, setDeletingRow] = useState<Record<string, any> | null>(null);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
const [showSqlEditor, setShowSqlEditor] = useState(false);
|
||||||
|
const [sqlQuery, setSqlQuery] = useState("");
|
||||||
|
const [sqlResult, setSqlResult] = useState<QueryResult | null>(null);
|
||||||
|
const [sqlError, setSqlError] = useState<string | null>(null);
|
||||||
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all tables on mount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
loadTables();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load table data when selected table changes
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedTable) {
|
||||||
|
loadTableData(1);
|
||||||
|
}
|
||||||
|
}, [selectedTable]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all tables from the database
|
||||||
|
*/
|
||||||
|
const loadTables = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await api.storageListTables();
|
||||||
|
setTables(result);
|
||||||
|
if (result.length > 0 && !selectedTable) {
|
||||||
|
setSelectedTable(result[0].name);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load tables:", err);
|
||||||
|
setError("Failed to load tables");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load data for the selected table
|
||||||
|
*/
|
||||||
|
const loadTableData = async (page: number, search?: string) => {
|
||||||
|
if (!selectedTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const result = await api.storageReadTable(
|
||||||
|
selectedTable,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
search || searchQuery || undefined
|
||||||
|
);
|
||||||
|
setTableData(result);
|
||||||
|
setCurrentPage(page);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load table data:", err);
|
||||||
|
setError("Failed to load table data");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle search
|
||||||
|
*/
|
||||||
|
const handleSearch = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
loadTableData(1, value);
|
||||||
|
},
|
||||||
|
[selectedTable]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get primary key values for a row
|
||||||
|
*/
|
||||||
|
const getPrimaryKeyValues = (row: Record<string, any>): Record<string, any> => {
|
||||||
|
if (!tableData) return {};
|
||||||
|
|
||||||
|
const pkColumns = tableData.columns.filter(col => col.pk);
|
||||||
|
const pkValues: Record<string, any> = {};
|
||||||
|
|
||||||
|
pkColumns.forEach(col => {
|
||||||
|
pkValues[col.name] = row[col.name];
|
||||||
|
});
|
||||||
|
|
||||||
|
return pkValues;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle row update
|
||||||
|
*/
|
||||||
|
const handleUpdateRow = async (updates: Record<string, any>) => {
|
||||||
|
if (!editingRow || !selectedTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const pkValues = getPrimaryKeyValues(editingRow);
|
||||||
|
await api.storageUpdateRow(selectedTable, pkValues, updates);
|
||||||
|
await loadTableData(currentPage);
|
||||||
|
setEditingRow(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update row:", err);
|
||||||
|
setError("Failed to update row");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle row deletion
|
||||||
|
*/
|
||||||
|
const handleDeleteRow = async () => {
|
||||||
|
if (!deletingRow || !selectedTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const pkValues = getPrimaryKeyValues(deletingRow);
|
||||||
|
await api.storageDeleteRow(selectedTable, pkValues);
|
||||||
|
await loadTableData(currentPage);
|
||||||
|
setDeletingRow(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete row:", err);
|
||||||
|
setError("Failed to delete row");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle new row insertion
|
||||||
|
*/
|
||||||
|
const handleInsertRow = async (values: Record<string, any>) => {
|
||||||
|
if (!selectedTable) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await api.storageInsertRow(selectedTable, values);
|
||||||
|
await loadTableData(currentPage);
|
||||||
|
setNewRow(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to insert row:", err);
|
||||||
|
setError("Failed to insert row");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle SQL query execution
|
||||||
|
*/
|
||||||
|
const handleExecuteSql = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setSqlError(null);
|
||||||
|
const result = await api.storageExecuteSql(sqlQuery);
|
||||||
|
setSqlResult(result);
|
||||||
|
|
||||||
|
// Refresh tables and data if it was a non-SELECT query
|
||||||
|
if (result.rows_affected !== undefined) {
|
||||||
|
await loadTables();
|
||||||
|
if (selectedTable) {
|
||||||
|
await loadTableData(currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to execute SQL:", err);
|
||||||
|
setSqlError(err instanceof Error ? err.message : "Failed to execute SQL");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle database reset
|
||||||
|
*/
|
||||||
|
const handleResetDatabase = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await api.storageResetDatabase();
|
||||||
|
await loadTables();
|
||||||
|
setSelectedTable("");
|
||||||
|
setTableData(null);
|
||||||
|
setShowResetConfirm(false);
|
||||||
|
setToast({
|
||||||
|
message: "Database Reset Complete: The database has been restored to its default state with empty tables (agents, agent_runs, app_settings).",
|
||||||
|
type: "success",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to reset database:", err);
|
||||||
|
setError("Failed to reset database");
|
||||||
|
setToast({
|
||||||
|
message: "Reset Failed: Failed to reset the database. Please try again.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format cell value for display
|
||||||
|
*/
|
||||||
|
const formatCellValue = (value: any, maxLength: number = 100): string => {
|
||||||
|
if (value === null) return "NULL";
|
||||||
|
if (value === undefined) return "";
|
||||||
|
if (typeof value === "boolean") return value ? "true" : "false";
|
||||||
|
if (typeof value === "object") return JSON.stringify(value);
|
||||||
|
|
||||||
|
const stringValue = String(value);
|
||||||
|
if (stringValue.length > maxLength) {
|
||||||
|
return stringValue.substring(0, maxLength) + "...";
|
||||||
|
}
|
||||||
|
return stringValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get input type for column
|
||||||
|
*/
|
||||||
|
const getInputType = (column: ColumnInfo): string => {
|
||||||
|
const type = column.type_name.toUpperCase();
|
||||||
|
if (type.includes("INT")) return "number";
|
||||||
|
if (type.includes("REAL") || type.includes("FLOAT") || type.includes("DOUBLE")) return "number";
|
||||||
|
if (type.includes("BOOL")) return "checkbox";
|
||||||
|
return "text";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Database className="h-4 w-4 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">Database Storage</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowSqlEditor(true)}
|
||||||
|
className="gap-2 h-8 text-xs"
|
||||||
|
>
|
||||||
|
<Terminal className="h-3 w-3" />
|
||||||
|
SQL Query
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
className="gap-2 h-8 text-xs"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
Reset DB
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table Selector and Search */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={selectedTable} onValueChange={setSelectedTable}>
|
||||||
|
<SelectTrigger className="w-[200px] h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Select a table">
|
||||||
|
{selectedTable && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Table className="h-3 w-3" />
|
||||||
|
{selectedTable}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tables.map((table) => (
|
||||||
|
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span>{table.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground ml-2">
|
||||||
|
{table.row_count} rows
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search in table..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="pl-8 h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tableData && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setNewRow({})}
|
||||||
|
className="gap-2 h-8 text-xs"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
New Row
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Table Data */}
|
||||||
|
{tableData && (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
{tableData.columns.map((column) => (
|
||||||
|
<th
|
||||||
|
key={column.name}
|
||||||
|
className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{column.name}
|
||||||
|
{column.pk && (
|
||||||
|
<span className="text-[10px] text-primary">PK</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-normal">
|
||||||
|
{column.type_name}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<AnimatePresence>
|
||||||
|
{tableData.rows.map((row, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={index}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="border-b hover:bg-muted/25 transition-colors"
|
||||||
|
>
|
||||||
|
{tableData.columns.map((column) => {
|
||||||
|
const value = row[column.name];
|
||||||
|
const formattedValue = formatCellValue(value, 50);
|
||||||
|
const fullValue = value === null ? "NULL" :
|
||||||
|
value === undefined ? "" :
|
||||||
|
typeof value === "object" ? JSON.stringify(value, null, 2) :
|
||||||
|
String(value);
|
||||||
|
const isTruncated = fullValue.length > 50;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={column.name}
|
||||||
|
className="px-3 py-2 text-xs font-mono"
|
||||||
|
>
|
||||||
|
{isTruncated ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-help block truncate max-w-[200px]">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
className="max-w-[500px] max-h-[300px] overflow-auto"
|
||||||
|
>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap">{fullValue}</pre>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate max-w-[200px]">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setEditingRow(row)}
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<Edit3 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setDeletingRow(row)}
|
||||||
|
className="h-6 w-6 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{tableData.total_pages > 1 && (
|
||||||
|
<div className="flex items-center justify-between p-3 border-t">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Showing {(currentPage - 1) * pageSize + 1} to{" "}
|
||||||
|
{Math.min(currentPage * pageSize, tableData.total_rows)} of{" "}
|
||||||
|
{tableData.total_rows} rows
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadTableData(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3" />
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="text-xs">
|
||||||
|
Page {currentPage} of {tableData.total_pages}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => loadTableData(currentPage + 1)}
|
||||||
|
disabled={currentPage === tableData.total_pages}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<Card className="p-6 border-destructive/50 bg-destructive/10">
|
||||||
|
<div className="flex items-center gap-3 text-destructive">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
<span className="font-medium">{error}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Row Dialog */}
|
||||||
|
<Dialog open={!!editingRow} onOpenChange={() => setEditingRow(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Row</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the values for this row in the {selectedTable} table.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{editingRow && tableData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tableData.columns.map((column) => (
|
||||||
|
<div key={column.name} className="space-y-2">
|
||||||
|
<Label htmlFor={`edit-${column.name}`}>
|
||||||
|
{column.name}
|
||||||
|
{column.pk && (
|
||||||
|
<span className="text-xs text-muted-foreground ml-2">
|
||||||
|
(Primary Key)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{getInputType(column) === "checkbox" ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`edit-${column.name}`}
|
||||||
|
checked={!!editingRow[column.name]}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingRow({
|
||||||
|
...editingRow,
|
||||||
|
[column.name]: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={column.pk}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`edit-${column.name}`}
|
||||||
|
type={getInputType(column)}
|
||||||
|
value={editingRow[column.name] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingRow({
|
||||||
|
...editingRow,
|
||||||
|
[column.name]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={column.pk}
|
||||||
|
placeholder={column.dflt_value || "NULL"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Type: {column.type_name}
|
||||||
|
{column.notnull && ", NOT NULL"}
|
||||||
|
{column.dflt_value && `, Default: ${column.dflt_value}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setEditingRow(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleUpdateRow(editingRow!)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Update"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* New Row Dialog */}
|
||||||
|
<Dialog open={!!newRow} onOpenChange={() => setNewRow(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New Row</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Add a new row to the {selectedTable} table.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{newRow && tableData && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tableData.columns.map((column) => (
|
||||||
|
<div key={column.name} className="space-y-2">
|
||||||
|
<Label htmlFor={`new-${column.name}`}>
|
||||||
|
{column.name}
|
||||||
|
{column.notnull && (
|
||||||
|
<span className="text-xs text-destructive ml-2">
|
||||||
|
(Required)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{getInputType(column) === "checkbox" ? (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`new-${column.name}`}
|
||||||
|
checked={newRow[column.name] || false}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewRow({
|
||||||
|
...newRow,
|
||||||
|
[column.name]: e.target.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`new-${column.name}`}
|
||||||
|
type={getInputType(column)}
|
||||||
|
value={newRow[column.name] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewRow({
|
||||||
|
...newRow,
|
||||||
|
[column.name]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder={column.dflt_value || "NULL"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Type: {column.type_name}
|
||||||
|
{column.dflt_value && `, Default: ${column.dflt_value}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setNewRow(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleInsertRow(newRow!)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Insert"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={!!deletingRow} onOpenChange={() => setDeletingRow(null)}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete Row</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this row? This action cannot be
|
||||||
|
undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{deletingRow && (
|
||||||
|
<div className="rounded-md bg-muted p-4">
|
||||||
|
<pre className="text-xs font-mono overflow-x-auto max-h-[200px] overflow-y-auto">
|
||||||
|
{JSON.stringify(
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(deletingRow).map(([key, value]) => [
|
||||||
|
key,
|
||||||
|
typeof value === "string" && value.length > 100
|
||||||
|
? value.substring(0, 100) + "..."
|
||||||
|
: value
|
||||||
|
])
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeletingRow(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteRow}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Delete"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reset Database Confirmation */}
|
||||||
|
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reset Database</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will delete all data and recreate the database with its default structure
|
||||||
|
(empty tables for agents, agent_runs, and app_settings). The database will be
|
||||||
|
restored to the same state as when you first installed the app. This action
|
||||||
|
cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-md bg-destructive/10 text-destructive">
|
||||||
|
<AlertTriangle className="h-5 w-5" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
All your agents, runs, and settings will be permanently deleted!
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowResetConfirm(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleResetDatabase}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Reset Database"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* SQL Query Editor */}
|
||||||
|
<Dialog open={showSqlEditor} onOpenChange={setShowSqlEditor}>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>SQL Query Editor</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Execute raw SQL queries on the database. Use with caution.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sql-query">SQL Query</Label>
|
||||||
|
<Textarea
|
||||||
|
id="sql-query"
|
||||||
|
value={sqlQuery}
|
||||||
|
onChange={(e) => setSqlQuery(e.target.value)}
|
||||||
|
placeholder="SELECT * FROM agents LIMIT 10;"
|
||||||
|
className="font-mono text-sm h-32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sqlError && (
|
||||||
|
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
{sqlError}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sqlResult && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sqlResult.rows_affected !== undefined ? (
|
||||||
|
<div className="p-3 rounded-md bg-green-500/10 text-green-600 dark:text-green-400 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
Query executed successfully. {sqlResult.rows_affected} rows
|
||||||
|
affected.
|
||||||
|
{sqlResult.last_insert_rowid && (
|
||||||
|
<span>
|
||||||
|
Last insert ID: {sqlResult.last_insert_rowid}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<div className="overflow-x-auto max-h-96">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50">
|
||||||
|
{sqlResult.columns.map((col, i) => (
|
||||||
|
<th
|
||||||
|
key={i}
|
||||||
|
className="px-2 py-1 text-left font-medium"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sqlResult.rows.map((row, i) => (
|
||||||
|
<tr key={i} className="border-b">
|
||||||
|
{row.map((cell, j) => {
|
||||||
|
const formattedValue = formatCellValue(cell, 50);
|
||||||
|
const fullValue = cell === null ? "NULL" :
|
||||||
|
cell === undefined ? "" :
|
||||||
|
typeof cell === "object" ? JSON.stringify(cell, null, 2) :
|
||||||
|
String(cell);
|
||||||
|
const isTruncated = fullValue.length > 50;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td key={j} className="px-2 py-1 font-mono">
|
||||||
|
{isTruncated ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-help block truncate max-w-[200px]">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="bottom"
|
||||||
|
className="max-w-[500px] max-h-[300px] overflow-auto"
|
||||||
|
>
|
||||||
|
<pre className="text-xs whitespace-pre-wrap">{fullValue}</pre>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
<span className="block truncate max-w-[200px]">
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setShowSqlEditor(false);
|
||||||
|
setSqlQuery("");
|
||||||
|
setSqlResult(null);
|
||||||
|
setSqlError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleExecuteSql}
|
||||||
|
disabled={loading || !sqlQuery.trim()}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Execute"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Toast Notification */}
|
||||||
|
<ToastContainer>
|
||||||
|
{toast && (
|
||||||
|
<Toast
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onDismiss={() => setToast(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ToastContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
134
src/lib/api.ts
134
src/lib/api.ts
@@ -1511,5 +1511,139 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Storage API methods
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists all tables in the SQLite database
|
||||||
|
* @returns Promise resolving to an array of table information
|
||||||
|
*/
|
||||||
|
async storageListTables(): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
return await invoke<any[]>("storage_list_tables");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to list tables:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads table data with pagination
|
||||||
|
* @param tableName - Name of the table to read
|
||||||
|
* @param page - Page number (1-indexed)
|
||||||
|
* @param pageSize - Number of rows per page
|
||||||
|
* @param searchQuery - Optional search query
|
||||||
|
* @returns Promise resolving to table data with pagination info
|
||||||
|
*/
|
||||||
|
async storageReadTable(
|
||||||
|
tableName: string,
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
searchQuery?: string
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await invoke<any>("storage_read_table", {
|
||||||
|
tableName,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
searchQuery,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to read table:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a row in a table
|
||||||
|
* @param tableName - Name of the table
|
||||||
|
* @param primaryKeyValues - Map of primary key column names to values
|
||||||
|
* @param updates - Map of column names to new values
|
||||||
|
* @returns Promise resolving when the row is updated
|
||||||
|
*/
|
||||||
|
async storageUpdateRow(
|
||||||
|
tableName: string,
|
||||||
|
primaryKeyValues: Record<string, any>,
|
||||||
|
updates: Record<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("storage_update_row", {
|
||||||
|
tableName,
|
||||||
|
primaryKeyValues,
|
||||||
|
updates,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update row:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a row from a table
|
||||||
|
* @param tableName - Name of the table
|
||||||
|
* @param primaryKeyValues - Map of primary key column names to values
|
||||||
|
* @returns Promise resolving when the row is deleted
|
||||||
|
*/
|
||||||
|
async storageDeleteRow(
|
||||||
|
tableName: string,
|
||||||
|
primaryKeyValues: Record<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("storage_delete_row", {
|
||||||
|
tableName,
|
||||||
|
primaryKeyValues,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete row:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts a new row into a table
|
||||||
|
* @param tableName - Name of the table
|
||||||
|
* @param values - Map of column names to values
|
||||||
|
* @returns Promise resolving to the last insert row ID
|
||||||
|
*/
|
||||||
|
async storageInsertRow(
|
||||||
|
tableName: string,
|
||||||
|
values: Record<string, any>
|
||||||
|
): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await invoke<number>("storage_insert_row", {
|
||||||
|
tableName,
|
||||||
|
values,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to insert row:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a raw SQL query
|
||||||
|
* @param query - SQL query string
|
||||||
|
* @returns Promise resolving to query result
|
||||||
|
*/
|
||||||
|
async storageExecuteSql(query: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
return await invoke<any>("storage_execute_sql", { query });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to execute SQL:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the entire database
|
||||||
|
* @returns Promise resolving when the database is reset
|
||||||
|
*/
|
||||||
|
async storageResetDatabase(): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("storage_reset_database");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset database:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user