diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 2ee8365..30e446b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,3 +2,4 @@ pub mod agents; pub mod claude; pub mod mcp; pub mod usage; +pub mod storage; diff --git a/src-tauri/src/commands/storage.rs b/src-tauri/src/commands/storage.rs new file mode 100644 index 0000000..1bcdb1b --- /dev/null +++ b/src-tauri/src/commands/storage.rs @@ -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, +} + +/// 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, + pub pk: bool, +} + +/// Represents a page of table data +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TableData { + pub table_name: String, + pub columns: Vec, + pub rows: Vec>, + 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, + pub rows: Vec>, + pub rows_affected: Option, + pub last_insert_rowid: Option, +} + +/// List all tables in the database +#[tauri::command] +pub async fn storage_list_tables(db: State<'_, AgentDb>) -> Result, 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 = stmt + .query_map([], |row| row.get(0)) + .map_err(|e| e.to_string())? + .collect::>>() + .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 = 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::>>() + .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, +) -> Result { + 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 = 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::>>() + .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 = 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> = 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::>>() + .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, + updates: HashMap, +) -> 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 = updates + .keys() + .enumerate() + .map(|(idx, key)| format!("{} = ?{}", key, idx + 1)) + .collect(); + + let where_clauses: Vec = 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> = 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, +) -> 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 = 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> = primaryKeyValues + .values() + .map(json_to_sql_value) + .collect::, _>>()?; + + // 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, +) -> Result { + 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 = (1..=columns.len()) + .map(|i| format!("?{}", i)) + .collect(); + + let query = format!( + "INSERT INTO {} ({}) VALUES ({})", + tableName, + columns.iter().map(|c| c.as_str()).collect::>().join(", "), + placeholders.join(", ") + ); + + // Prepare parameters + let params: Vec> = values + .values() + .map(json_to_sql_value) + .collect::, _>>()?; + + // 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 { + 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 = (0..column_count) + .map(|i| stmt.column_name(i).unwrap_or("").to_string()) + .collect(); + + // Execute query and collect results + let rows: Vec> = 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::>>() + .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::(); + 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::(); + 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::(); + 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 { + 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, 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; \ No newline at end of file diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ce6946c..b6d7be5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -37,6 +37,10 @@ use commands::mcp::{ use commands::usage::{ 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 std::sync::Mutex; use tauri::Manager; @@ -163,7 +167,13 @@ fn main() { mcp_get_server_status, mcp_read_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!()) .expect("error while running tauri application"); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index bb1c8c3..740211d 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -10,7 +10,8 @@ import { Code, Settings2, Terminal, - Loader2 + Loader2, + Database } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -26,6 +27,7 @@ import { import { cn } from "@/lib/utils"; import { Toast, ToastContainer } from "@/components/ui/toast"; import { ClaudeVersionSelector } from "./ClaudeVersionSelector"; +import { StorageTab } from "./StorageTab"; interface SettingsProps { /** @@ -369,6 +371,10 @@ export const Settings: React.FC = ({ Advanced + + + Storage + {/* General Settings */} @@ -683,6 +689,11 @@ export const Settings: React.FC = ({ + + {/* Storage Tab */} + + + )} diff --git a/src/components/StorageTab.tsx b/src/components/StorageTab.tsx new file mode 100644 index 0000000..aa4071c --- /dev/null +++ b/src/components/StorageTab.tsx @@ -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[]; + 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([]); + const [selectedTable, setSelectedTable] = useState(""); + const [tableData, setTableData] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize] = useState(25); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Dialog states + const [editingRow, setEditingRow] = useState | null>(null); + const [newRow, setNewRow] = useState | null>(null); + const [deletingRow, setDeletingRow] = useState | null>(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + const [showSqlEditor, setShowSqlEditor] = useState(false); + const [sqlQuery, setSqlQuery] = useState(""); + const [sqlResult, setSqlResult] = useState(null); + const [sqlError, setSqlError] = useState(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): Record => { + if (!tableData) return {}; + + const pkColumns = tableData.columns.filter(col => col.pk); + const pkValues: Record = {}; + + pkColumns.forEach(col => { + pkValues[col.name] = row[col.name]; + }); + + return pkValues; + }; + + /** + * Handle row update + */ + const handleUpdateRow = async (updates: Record) => { + 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) => { + 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 ( +
+ {/* Header */} + +
+
+
+ +

Database Storage

+
+
+ + +
+
+ + {/* Table Selector and Search */} +
+ + +
+ + handleSearch(e.target.value)} + className="pl-8 h-8 text-xs" + /> +
+ + {tableData && ( + + )} +
+
+
+ + {/* Table Data */} + {tableData && ( + +
+ + + + {tableData.columns.map((column) => ( + + ))} + + + + + + {tableData.rows.map((row, index) => ( + + {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 ( + + ); + })} + + + ))} + + +
+
+ {column.name} + {column.pk && ( + PK + )} +
+
+ {column.type_name} +
+
+ Actions +
+ {isTruncated ? ( + + + + + {formattedValue} + + + +
{fullValue}
+
+
+
+ ) : ( + + {formattedValue} + + )} +
+
+ + +
+
+
+ + {/* Pagination */} + {tableData.total_pages > 1 && ( +
+
+ Showing {(currentPage - 1) * pageSize + 1} to{" "} + {Math.min(currentPage * pageSize, tableData.total_rows)} of{" "} + {tableData.total_rows} rows +
+
+ +
+ Page {currentPage} of {tableData.total_pages} +
+ +
+
+ )} +
+ )} + + {/* Loading State */} + {loading && ( +
+ +
+ )} + + {/* Error State */} + {error && ( + +
+ + {error} +
+
+ )} + + {/* Edit Row Dialog */} + setEditingRow(null)}> + + + Edit Row + + Update the values for this row in the {selectedTable} table. + + + {editingRow && tableData && ( +
+ {tableData.columns.map((column) => ( +
+ + {getInputType(column) === "checkbox" ? ( + + setEditingRow({ + ...editingRow, + [column.name]: e.target.checked, + }) + } + disabled={column.pk} + className="h-4 w-4" + /> + ) : ( + + setEditingRow({ + ...editingRow, + [column.name]: e.target.value, + }) + } + disabled={column.pk} + placeholder={column.dflt_value || "NULL"} + /> + )} +

+ Type: {column.type_name} + {column.notnull && ", NOT NULL"} + {column.dflt_value && `, Default: ${column.dflt_value}`} +

+
+ ))} +
+ )} + + + + +
+
+ + {/* New Row Dialog */} + setNewRow(null)}> + + + New Row + + Add a new row to the {selectedTable} table. + + + {newRow && tableData && ( +
+ {tableData.columns.map((column) => ( +
+ + {getInputType(column) === "checkbox" ? ( + + setNewRow({ + ...newRow, + [column.name]: e.target.checked, + }) + } + className="h-4 w-4" + /> + ) : ( + + setNewRow({ + ...newRow, + [column.name]: e.target.value, + }) + } + placeholder={column.dflt_value || "NULL"} + /> + )} +

+ Type: {column.type_name} + {column.dflt_value && `, Default: ${column.dflt_value}`} +

+
+ ))} +
+ )} + + + + +
+
+ + {/* Delete Confirmation Dialog */} + setDeletingRow(null)}> + + + Delete Row + + Are you sure you want to delete this row? This action cannot be + undone. + + + {deletingRow && ( +
+
+                {JSON.stringify(
+                  Object.fromEntries(
+                    Object.entries(deletingRow).map(([key, value]) => [
+                      key,
+                      typeof value === "string" && value.length > 100
+                        ? value.substring(0, 100) + "..."
+                        : value
+                    ])
+                  ),
+                  null,
+                  2
+                )}
+              
+
+ )} + + + + +
+
+ + {/* Reset Database Confirmation */} + + + + Reset Database + + 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. + + +
+ + + All your agents, runs, and settings will be permanently deleted! + +
+ + + + +
+
+ + {/* SQL Query Editor */} + + + + SQL Query Editor + + Execute raw SQL queries on the database. Use with caution. + + +
+
+ +