feat: add web deployment with axum server, Docker support, and browser UI
Introduce src-web (axum HTTP server) exposing all 43 database operations as REST APIs with SSE streaming for AI, transfers, and SQL file execution. Frontend adapts automatically between Tauri and HTTP backends via api.ts. Includes password authentication, Docker packaging, and login page.
This commit is contained in:
parent
cf4f25fd52
commit
41bceffc0e
|
|
@ -0,0 +1,12 @@
|
|||
target/
|
||||
node_modules/
|
||||
dist/
|
||||
src-tauri/target/
|
||||
.git/
|
||||
.github/
|
||||
.claude/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
tmp/
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# Stage 1: Build frontend
|
||||
FROM node:20-slim AS frontend
|
||||
WORKDIR /app
|
||||
RUN npm i -g pnpm
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY src/ src/
|
||||
COPY index.html vite.config.ts tsconfig.json tsconfig.app.json tsconfig.node.json ./
|
||||
COPY tailwind.config.* postcss.config.* ./
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Build Rust backend
|
||||
FROM rust:1-bookworm AS backend
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY crates/ crates/
|
||||
COPY src-web/ src-web/
|
||||
# Create a dummy src-tauri to satisfy workspace (not built)
|
||||
RUN mkdir -p src-tauri/src && echo 'fn main() {}' > src-tauri/src/main.rs && echo 'pub fn run() {}' > src-tauri/src/lib.rs
|
||||
COPY src-tauri/Cargo.toml src-tauri/
|
||||
COPY src-tauri/build.rs src-tauri/
|
||||
COPY src-tauri/tauri.conf.json src-tauri/
|
||||
RUN cargo build --release -p dbx-web
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=backend /app/target/release/dbx-web /usr/local/bin/
|
||||
COPY --from=frontend /app/dist /app/static
|
||||
ENV DBX_STATIC_DIR=/app/static
|
||||
ENV DBX_DATA_DIR=/app/data
|
||||
EXPOSE 4224
|
||||
CMD ["dbx-web"]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
services:
|
||||
dbx:
|
||||
build: .
|
||||
ports:
|
||||
- "4224:4224"
|
||||
environment:
|
||||
- DBX_PASSWORD=changeme
|
||||
volumes:
|
||||
- dbx-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
dbx-data:
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:tauri": "tauri dev",
|
||||
"dev:web": "vite --port 5173 --mode web",
|
||||
"dev:backend": "RUST_LOG=info cargo watch -x 'run -p dbx-web'",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "dbx-web"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "dbx-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
dbx-core = { path = "../crates/dbx-core" }
|
||||
axum = { version = "0.8", features = ["multipart"] }
|
||||
tower-http = { version = "0.6", features = ["cors", "fs", "compression-gzip"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
async-stream = "0.3"
|
||||
futures = "0.3"
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
tokio-util = "0.7"
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AuthCheckResponse {
|
||||
pub authenticated: bool,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
pub async fn login(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> Result<Response, StatusCode> {
|
||||
let password_hash = match &state.password_hash {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({"ok": true})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(body.password.as_bytes());
|
||||
let hash = hex::encode(hasher.finalize());
|
||||
|
||||
if hash != *password_hash {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let token = uuid::Uuid::new_v4().to_string();
|
||||
state.sessions.write().await.insert(token.clone());
|
||||
|
||||
let cookie = format!("dbx_session={token}; Path=/; HttpOnly; SameSite=Lax");
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
[("set-cookie", cookie.as_str())],
|
||||
Json(serde_json::json!({"ok": true})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
pub async fn check(State(state): State<Arc<WebState>>) -> Json<AuthCheckResponse> {
|
||||
Json(AuthCheckResponse {
|
||||
authenticated: state.password_hash.is_none(),
|
||||
required: state.password_hash.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn logout(State(state): State<Arc<WebState>>, req: Request<axum::body::Body>) -> Response {
|
||||
if let Some(token) = extract_session_token(&req) {
|
||||
state.sessions.write().await.remove(&token);
|
||||
}
|
||||
let cookie = "dbx_session=; Path=/; HttpOnly; Max-Age=0";
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("set-cookie", cookie)],
|
||||
Json(serde_json::json!({"ok": true})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn extract_session_token<B>(req: &Request<B>) -> Option<String> {
|
||||
let cookie_header = req.headers().get("cookie")?.to_str().ok()?;
|
||||
for pair in cookie_header.split(';') {
|
||||
let pair = pair.trim();
|
||||
if let Some(value) = pair.strip_prefix("dbx_session=") {
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<Arc<WebState>>,
|
||||
req: Request<axum::body::Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// No password set — allow everything
|
||||
if state.password_hash.is_none() {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// Auth endpoints are always accessible
|
||||
let path = req.uri().path();
|
||||
if path.starts_with("/api/auth/") {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// Non-API requests (static files) are always accessible
|
||||
if !path.starts_with("/api/") {
|
||||
return next.run(req).await;
|
||||
}
|
||||
|
||||
// Check session token
|
||||
if let Some(token) = extract_session_token(&req) {
|
||||
if state.sessions.read().await.contains(&token) {
|
||||
return next.run(req).await;
|
||||
}
|
||||
}
|
||||
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
|
||||
pub struct AppError(pub String);
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, self.0).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for AppError {
|
||||
fn from(s: String) -> Self {
|
||||
AppError(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for AppError {
|
||||
fn from(s: &str) -> Self {
|
||||
AppError(s.to_string())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
mod auth;
|
||||
mod error;
|
||||
mod routes;
|
||||
mod sse;
|
||||
mod state;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::middleware;
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use dbx_core::connection::AppState;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::sync::RwLock;
|
||||
use tower_http::cors::{Any, CorsLayer};
|
||||
|
||||
use state::WebState;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::init();
|
||||
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
let app_state = Arc::new(AppState::new());
|
||||
|
||||
// Data directory
|
||||
let data_dir = std::env::var("DBX_DATA_DIR")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|_| {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
|
||||
std::path::PathBuf::from(home).join(".dbx-web")
|
||||
});
|
||||
std::fs::create_dir_all(&data_dir).expect("Failed to create data directory");
|
||||
|
||||
// Password hash
|
||||
let password_hash = std::env::var("DBX_PASSWORD").ok().map(|pw| {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(pw.as_bytes());
|
||||
hex::encode(hasher.finalize())
|
||||
});
|
||||
|
||||
let web_state = Arc::new(WebState {
|
||||
app: app_state,
|
||||
data_dir,
|
||||
password_hash,
|
||||
sessions: RwLock::new(HashSet::new()),
|
||||
sse_channels: RwLock::new(HashMap::new()),
|
||||
});
|
||||
|
||||
// CORS
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
// API routes
|
||||
let api = Router::new()
|
||||
// Auth
|
||||
.route("/auth/login", post(auth::login))
|
||||
.route("/auth/check", get(auth::check))
|
||||
.route("/auth/logout", post(auth::logout))
|
||||
// Connection
|
||||
.route("/connection/test", post(routes::connection::test_connection))
|
||||
.route("/connection/connect", post(routes::connection::connect_db))
|
||||
.route("/connection/disconnect", post(routes::connection::disconnect_db))
|
||||
.route("/connection/save", post(routes::connection::save_connections))
|
||||
.route("/connection/list", get(routes::connection::load_connections))
|
||||
// Schema
|
||||
.route("/schema/databases", get(routes::schema::list_databases))
|
||||
.route("/schema/schemas", get(routes::schema::list_schemas))
|
||||
.route("/schema/tables", get(routes::schema::list_tables))
|
||||
.route("/schema/columns", get(routes::schema::list_columns))
|
||||
.route("/schema/indexes", get(routes::schema::list_indexes))
|
||||
.route("/schema/foreign-keys", get(routes::schema::list_foreign_keys))
|
||||
.route("/schema/triggers", get(routes::schema::list_triggers))
|
||||
.route("/schema/ddl", get(routes::schema::get_ddl))
|
||||
// Query
|
||||
.route("/query/execute", post(routes::query::execute_query))
|
||||
.route("/query/execute-multi", post(routes::query::execute_multi))
|
||||
.route("/query/execute-batch", post(routes::query::execute_batch))
|
||||
.route("/query/cancel", post(routes::query::cancel_query))
|
||||
// Redis
|
||||
.route("/redis/list-databases", post(routes::redis::list_databases))
|
||||
.route("/redis/scan-keys", post(routes::redis::scan_keys))
|
||||
.route("/redis/get-value", post(routes::redis::get_value))
|
||||
.route("/redis/set-string", post(routes::redis::set_string))
|
||||
.route("/redis/delete-key", post(routes::redis::delete_key))
|
||||
.route("/redis/hash-set", post(routes::redis::hash_set))
|
||||
.route("/redis/hash-del", post(routes::redis::hash_del))
|
||||
.route("/redis/list-push", post(routes::redis::list_push))
|
||||
.route("/redis/list-remove", post(routes::redis::list_remove))
|
||||
.route("/redis/set-add", post(routes::redis::set_add))
|
||||
.route("/redis/set-remove", post(routes::redis::set_remove))
|
||||
// MongoDB
|
||||
.route("/mongo/list-databases", post(routes::mongo::list_databases))
|
||||
.route("/mongo/list-collections", post(routes::mongo::list_collections))
|
||||
.route("/mongo/find-documents", post(routes::mongo::find_documents))
|
||||
.route("/mongo/insert-document", post(routes::mongo::insert_document))
|
||||
.route("/mongo/update-document", post(routes::mongo::update_document))
|
||||
.route("/mongo/delete-document", post(routes::mongo::delete_document))
|
||||
// History
|
||||
.route("/history", get(routes::history::load_history).delete(routes::history::clear_history))
|
||||
.route("/history/save", post(routes::history::save_history))
|
||||
.route("/history/{id}", delete(routes::history::delete_history_entry))
|
||||
// AI
|
||||
.route("/ai/config", post(routes::ai::save_ai_config).get(routes::ai::load_ai_config))
|
||||
.route("/ai/conversation", post(routes::ai::save_ai_conversation))
|
||||
.route("/ai/conversations", get(routes::ai::load_ai_conversations))
|
||||
.route("/ai/conversation/{id}", delete(routes::ai::delete_ai_conversation))
|
||||
.route("/ai/complete", post(routes::ai::ai_complete))
|
||||
.route("/ai/stream", post(routes::ai::ai_stream))
|
||||
.route("/ai/cancel-stream", post(routes::ai::ai_cancel_stream))
|
||||
.route("/ai/test-connection", post(routes::ai::ai_test_connection))
|
||||
// Transfer
|
||||
.route("/transfer/start", post(routes::transfer::start_transfer))
|
||||
.route("/transfer/progress/{transferId}", get(routes::transfer::transfer_progress))
|
||||
.route("/transfer/cancel", post(routes::transfer::cancel_transfer))
|
||||
// SQL file
|
||||
.route("/sql-file/preview", post(routes::sql_file::preview_sql_file))
|
||||
.route("/sql-file/execute", post(routes::sql_file::execute_sql_file))
|
||||
.route("/sql-file/progress/{executionId}", get(routes::sql_file::sql_file_progress))
|
||||
.route("/sql-file/cancel", post(routes::sql_file::cancel_sql_file))
|
||||
// Table import
|
||||
.route("/import/preview", post(routes::table_import::preview_import))
|
||||
.route("/import/execute", post(routes::table_import::execute_import))
|
||||
.route("/import/progress/{importId}", get(routes::table_import::import_progress))
|
||||
.route("/import/cancel", post(routes::table_import::cancel_import))
|
||||
// Update
|
||||
.route("/update/check", get(routes::update::check_for_updates))
|
||||
// Layout
|
||||
.route("/layout/sidebar", post(routes::layout::save_sidebar_layout).get(routes::layout::load_sidebar_layout))
|
||||
.with_state(web_state.clone());
|
||||
|
||||
// Build app with auth middleware
|
||||
let mut app = Router::new()
|
||||
.nest("/api", api)
|
||||
.layer(middleware::from_fn_with_state(web_state.clone(), auth::auth_middleware))
|
||||
.layer(cors);
|
||||
|
||||
// Static file serving
|
||||
if let Ok(static_dir) = std::env::var("DBX_STATIC_DIR") {
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
let index_path = format!("{}/index.html", static_dir);
|
||||
let serve_dir = ServeDir::new(&static_dir)
|
||||
.not_found_service(ServeFile::new(&index_path));
|
||||
app = app.fallback_service(serve_dir);
|
||||
}
|
||||
|
||||
// Bind address
|
||||
let port: u16 = std::env::var("DBX_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(4224);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
|
||||
log::info!("DBX Web server starting on http://{}", addr);
|
||||
if std::env::var("DBX_PASSWORD").is_ok() {
|
||||
log::info!("Password protection is enabled");
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.expect("Failed to bind address");
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server error");
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::Json;
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
|
||||
use dbx_core::ai::{
|
||||
AiCompletionRequest, AiConfig, AiConversation, AiStreamChunk,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveAiConfigRequest {
|
||||
pub config: AiConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveAiConversationRequest {
|
||||
pub conversation: AiConversation,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiCompleteRequest {
|
||||
pub request: AiCompletionRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AiStreamRequest {
|
||||
#[serde(alias = "sessionId")]
|
||||
pub session_id: String,
|
||||
pub request: AiCompletionRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiTestConnectionRequest {
|
||||
pub config: AiConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AiCancelStreamRequest {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn save_ai_config(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveAiConfigRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("ai_config.json");
|
||||
dbx_core::ai::save_config(&path, &body.config).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_ai_config(
|
||||
State(state): State<Arc<WebState>>,
|
||||
) -> Result<Json<Option<AiConfig>>, AppError> {
|
||||
let path = state.data_dir.join("ai_config.json");
|
||||
let config = dbx_core::ai::load_config(&path).map_err(AppError)?;
|
||||
Ok(Json(config))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn save_ai_conversation(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveAiConversationRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("ai_conversations.json");
|
||||
dbx_core::ai::save_conversation(&path, body.conversation).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_ai_conversations(
|
||||
State(state): State<Arc<WebState>>,
|
||||
) -> Result<Json<Vec<AiConversation>>, AppError> {
|
||||
let path = state.data_dir.join("ai_conversations.json");
|
||||
let conversations = dbx_core::ai::load_conversations(&path).map_err(AppError)?;
|
||||
Ok(Json(conversations))
|
||||
}
|
||||
|
||||
pub async fn delete_ai_conversation(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("ai_conversations.json");
|
||||
dbx_core::ai::delete_conversation(&path, &id).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI complete (non-streaming)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn ai_complete(
|
||||
Json(body): Json<AiCompleteRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let result = dbx_core::ai::complete(&body.request)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI test connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn ai_test_connection(
|
||||
Json(body): Json<AiTestConnectionRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let result = dbx_core::ai::test_connection_core(&body.config)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI cancel stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn ai_cancel_stream(
|
||||
Json(body): Json<AiCancelStreamRequest>,
|
||||
) -> Result<Json<bool>, AppError> {
|
||||
let result = dbx_core::ai::cancel_stream(&body.session_id).await;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI stream (POST returns SSE directly)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub async fn ai_stream(
|
||||
Json(body): Json<AiStreamRequest>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let session_id = body.session_id;
|
||||
let request = body.request;
|
||||
|
||||
let cancelled = dbx_core::ai::register_stream(&session_id).await;
|
||||
let (tx, rx) = tokio::sync::broadcast::channel::<String>(256);
|
||||
|
||||
let sid = session_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = dbx_core::ai::stream(&sid, &request, &cancelled, |chunk: AiStreamChunk| {
|
||||
let json = serde_json::to_string(&chunk).unwrap_or_default();
|
||||
let _ = tx.send(json);
|
||||
})
|
||||
.await;
|
||||
|
||||
if let Err(_e) = result {
|
||||
let error_chunk = AiStreamChunk {
|
||||
session_id: sid.clone(),
|
||||
delta: String::new(),
|
||||
reasoning_delta: None,
|
||||
done: true,
|
||||
};
|
||||
let _ = tx.send(serde_json::to_string(&error_chunk).unwrap_or_default());
|
||||
}
|
||||
|
||||
dbx_core::ai::unregister_stream(&sid).await;
|
||||
});
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
let mut rx = rx;
|
||||
while let Ok(data) = rx.recv().await {
|
||||
yield Ok(Event::default().data(data));
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use dbx_core::connection_secrets::{
|
||||
load_connections_from_file, save_connections_to_file, FileSecretStore,
|
||||
};
|
||||
use dbx_core::models::connection::ConnectionConfig;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConnectRequest {
|
||||
pub config: ConnectionConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DisconnectRequest {
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveConnectionsRequest {
|
||||
pub configs: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
pub async fn test_connection(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<ConnectRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let config = body.config;
|
||||
let app = &state.app;
|
||||
|
||||
// Store config temporarily
|
||||
let temp_id = format!("__test_{}", uuid::Uuid::new_v4());
|
||||
app.configs
|
||||
.lock()
|
||||
.await
|
||||
.insert(temp_id.clone(), config.clone());
|
||||
|
||||
// Try to connect
|
||||
let result = app.get_or_create_pool(&temp_id, config.database.as_deref()).await;
|
||||
|
||||
// Clean up
|
||||
app.connections.lock().await.remove(&temp_id);
|
||||
app.configs.lock().await.remove(&temp_id);
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(Json("Connection successful".to_string())),
|
||||
Err(e) => Err(AppError(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_db(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<ConnectRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let config = body.config;
|
||||
let app = &state.app;
|
||||
let connection_id = config.id.clone();
|
||||
|
||||
app.configs
|
||||
.lock()
|
||||
.await
|
||||
.insert(connection_id.clone(), config.clone());
|
||||
|
||||
let pool_key = app
|
||||
.get_or_create_pool(&connection_id, config.database.as_deref())
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
||||
Ok(Json(pool_key))
|
||||
}
|
||||
|
||||
pub async fn disconnect_db(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<DisconnectRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let app = &state.app;
|
||||
let mut connections = app.connections.lock().await;
|
||||
|
||||
// Remove all pool keys that start with this connection_id
|
||||
let keys_to_remove: Vec<String> = connections
|
||||
.keys()
|
||||
.filter(|k| k.starts_with(&body.connection_id))
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in keys_to_remove {
|
||||
connections.remove(&key);
|
||||
}
|
||||
drop(connections);
|
||||
|
||||
app.configs.lock().await.remove(&body.connection_id);
|
||||
app.tunnels.stop_tunnel(&body.connection_id).await;
|
||||
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn save_connections(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveConnectionsRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("connections.json");
|
||||
let secret_path = state.data_dir.join("secrets.json");
|
||||
let store = FileSecretStore::new(secret_path);
|
||||
save_connections_to_file(&path, &body.configs, &store).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_connections(
|
||||
State(state): State<Arc<WebState>>,
|
||||
) -> Result<Json<Vec<ConnectionConfig>>, AppError> {
|
||||
let path = state.data_dir.join("connections.json");
|
||||
let secret_path = state.data_dir.join("secrets.json");
|
||||
let store = FileSecretStore::new(secret_path);
|
||||
let configs = load_connections_from_file(&path, &store).map_err(AppError)?;
|
||||
Ok(Json(configs))
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::Json;
|
||||
use dbx_core::history::{self, HistoryEntry};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HistoryQuery {
|
||||
pub limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveHistoryRequest {
|
||||
pub entry: HistoryEntry,
|
||||
}
|
||||
|
||||
pub async fn save_history(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveHistoryRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("query_history.json");
|
||||
history::save_history_entry(&path, body.entry).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_history(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<HistoryQuery>,
|
||||
) -> Result<Json<Vec<HistoryEntry>>, AppError> {
|
||||
let path = state.data_dir.join("query_history.json");
|
||||
let limit = q.limit.unwrap_or(100);
|
||||
let offset = q.offset.unwrap_or(0);
|
||||
let entries = history::load_history_entries(&path, limit, offset).map_err(AppError)?;
|
||||
Ok(Json(entries))
|
||||
}
|
||||
|
||||
pub async fn clear_history(State(state): State<Arc<WebState>>) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("query_history.json");
|
||||
history::clear_history_entries(&path).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_history_entry(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<String>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("query_history.json");
|
||||
history::delete_history_entry_by_id(&path, &id).map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SaveLayoutRequest {
|
||||
pub layout: serde_json::Value,
|
||||
}
|
||||
|
||||
pub async fn save_sidebar_layout(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SaveLayoutRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let path = state.data_dir.join("sidebar_layout.json");
|
||||
let json = serde_json::to_string_pretty(&body.layout).map_err(|e| AppError(e.to_string()))?;
|
||||
std::fs::write(&path, json).map_err(|e| AppError(e.to_string()))?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn load_sidebar_layout(
|
||||
State(state): State<Arc<WebState>>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let path = state.data_dir.join("sidebar_layout.json");
|
||||
if !path.exists() {
|
||||
return Ok(Json(serde_json::json!(null)));
|
||||
}
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| AppError(e.to_string()))?;
|
||||
let layout: serde_json::Value =
|
||||
serde_json::from_str(&json).map_err(|e| AppError(e.to_string()))?;
|
||||
Ok(Json(layout))
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
pub mod ai;
|
||||
pub mod connection;
|
||||
pub mod history;
|
||||
pub mod layout;
|
||||
pub mod mongo;
|
||||
pub mod query;
|
||||
pub mod redis;
|
||||
pub mod schema;
|
||||
pub mod sql_file;
|
||||
pub mod table_import;
|
||||
pub mod transfer;
|
||||
pub mod update;
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoConnectionRequest {
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoCollectionRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoFindRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub collection: String,
|
||||
pub skip: Option<u64>,
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoInsertRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub collection: String,
|
||||
pub doc_json: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoUpdateRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub collection: String,
|
||||
pub id: String,
|
||||
pub doc_json: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MongoDeleteRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub collection: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
pub async fn list_databases(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoConnectionRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let result = dbx_core::mongo_ops::mongo_list_databases_core(&state.app, &req.connection_id)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_collections(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoCollectionRequest>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let result = dbx_core::mongo_ops::mongo_list_collections_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn find_documents(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoFindRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::mongo_ops::mongo_find_documents_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.collection,
|
||||
req.skip.unwrap_or(0),
|
||||
req.limit.unwrap_or(50),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn insert_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoInsertRequest>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let result = dbx_core::mongo_ops::mongo_insert_document_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.collection,
|
||||
&req.doc_json,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn update_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoUpdateRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
let result = dbx_core::mongo_ops::mongo_update_document_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.collection,
|
||||
&req.id,
|
||||
&req.doc_json,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn delete_document(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<MongoDeleteRequest>,
|
||||
) -> Result<Json<u64>, AppError> {
|
||||
let result = dbx_core::mongo_ops::mongo_delete_document_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.collection,
|
||||
&req.id,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteQueryRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub sql: String,
|
||||
pub execution_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelRequest {
|
||||
pub execution_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteBatchRequest {
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub statements: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn execute_query(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ExecuteQueryRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let execution_id = req
|
||||
.execution_id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let registered = state.app.running_queries.register(execution_id);
|
||||
let cancel_token = registered.token();
|
||||
|
||||
let result = dbx_core::query::execute_sql_statement(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.sql,
|
||||
Some(cancel_token),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
||||
drop(registered);
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn execute_multi(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ExecuteQueryRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let execution_id = req
|
||||
.execution_id
|
||||
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||
|
||||
let registered = state.app.running_queries.register(execution_id);
|
||||
let cancel_token = registered.token();
|
||||
|
||||
let result = dbx_core::query::execute_multi_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.sql,
|
||||
Some(cancel_token),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
||||
drop(registered);
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn execute_batch(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<ExecuteBatchRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::query::execute_statements(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.database,
|
||||
&req.statements,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn cancel_query(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
let cancelled = state.app.running_queries.cancel(&req.execution_id);
|
||||
Json(serde_json::json!({ "cancelled": cancelled }))
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisConnectionRequest {
|
||||
pub connection_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisScanRequest {
|
||||
pub connection_id: String,
|
||||
pub db: u32,
|
||||
pub cursor: u64,
|
||||
pub pattern: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisKeyRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisSetStringRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
pub value: String,
|
||||
pub ttl: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisHashRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
pub field: String,
|
||||
pub value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisListRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
pub value: Option<String>,
|
||||
pub index: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RedisSetRequest {
|
||||
pub connection_id: String,
|
||||
pub key: String,
|
||||
pub member: String,
|
||||
}
|
||||
|
||||
pub async fn list_databases(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisConnectionRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::redis_ops::redis_list_databases_core(&state.app, &req.connection_id)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn scan_keys(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisScanRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::redis_ops::redis_scan_keys_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
req.db,
|
||||
req.cursor,
|
||||
&req.pattern,
|
||||
req.count,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn get_value(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeyRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::redis_ops::redis_get_value_core(&state.app, &req.connection_id, &req.key)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn set_string(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetStringRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
dbx_core::redis_ops::redis_set_string_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.key,
|
||||
&req.value,
|
||||
req.ttl,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn delete_key(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisKeyRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
dbx_core::redis_ops::redis_delete_key_core(&state.app, &req.connection_id, &req.key)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn hash_set(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let value = req.value.as_deref().unwrap_or("");
|
||||
dbx_core::redis_ops::redis_hash_set_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.key,
|
||||
&req.field,
|
||||
value,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn hash_del(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisHashRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
dbx_core::redis_ops::redis_hash_del_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.key,
|
||||
&req.field,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn list_push(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let value = req.value.as_deref().unwrap_or("");
|
||||
dbx_core::redis_ops::redis_list_push_core(&state.app, &req.connection_id, &req.key, value)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn list_remove(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisListRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
let index = req.index.unwrap_or(0);
|
||||
dbx_core::redis_ops::redis_list_remove_core(&state.app, &req.connection_id, &req.key, index)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_add(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
dbx_core::redis_ops::redis_set_add_core(&state.app, &req.connection_id, &req.key, &req.member)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
||||
pub async fn set_remove(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<RedisSetRequest>,
|
||||
) -> Result<Json<()>, AppError> {
|
||||
dbx_core::redis_ops::redis_set_remove_core(
|
||||
&state.app,
|
||||
&req.connection_id,
|
||||
&req.key,
|
||||
&req.member,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(()))
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, State};
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SchemaQuery {
|
||||
pub connection_id: String,
|
||||
pub database: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub table: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list_databases(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let result = dbx_core::schema::list_databases_core(&state.app, &q.connection_id)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_schemas(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<Vec<String>>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_schemas_core(&state.app, &q.connection_id, database)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
pub async fn list_tables(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let result =
|
||||
dbx_core::schema::list_tables_core(&state.app, &q.connection_id, database, schema)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_columns(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let table = q.table.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::get_columns_core(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_indexes(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let table = q.table.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_indexes_core(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_foreign_keys(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let table = q.table.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_foreign_keys_core(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn list_triggers(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let table = q.table.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::list_triggers_core(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(serde_json::to_value(result).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
||||
pub async fn get_ddl(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Query(q): Query<SchemaQuery>,
|
||||
) -> Result<Json<String>, AppError> {
|
||||
let database = q.database.as_deref().unwrap_or("");
|
||||
let schema = q.schema.as_deref().unwrap_or("");
|
||||
let table = q.table.as_deref().unwrap_or("");
|
||||
let result = dbx_core::schema::get_table_ddl_core(
|
||||
&state.app,
|
||||
&q.connection_id,
|
||||
database,
|
||||
schema,
|
||||
table,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError)?;
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::sql;
|
||||
use dbx_core::query;
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFileExecuteRequest {
|
||||
pub execution_id: String,
|
||||
pub connection_id: String,
|
||||
pub database: String,
|
||||
pub file_path: String,
|
||||
pub continue_on_error: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlFileExecuteWrapper {
|
||||
pub request: SqlFileExecuteRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelSqlFileRequest {
|
||||
pub execution_id: String,
|
||||
}
|
||||
|
||||
pub async fn preview_sql_file(
|
||||
State(state): State<Arc<WebState>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let tmp_dir = state.data_dir.join("tmp");
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError(e.to_string()))?
|
||||
{
|
||||
let file_name = field
|
||||
.file_name()
|
||||
.unwrap_or("upload.sql")
|
||||
.to_string();
|
||||
let data = field.bytes().await.map_err(|e| AppError(e.to_string()))?;
|
||||
|
||||
let file_path = tmp_dir.join(&file_name);
|
||||
std::fs::write(&file_path, &data).map_err(|e| AppError(e.to_string()))?;
|
||||
|
||||
let size_bytes = data.len() as u64;
|
||||
let content = String::from_utf8_lossy(&data);
|
||||
let preview: String = content.chars().take(5000).collect();
|
||||
|
||||
return Ok(Json(serde_json::json!({
|
||||
"fileName": file_name,
|
||||
"filePath": file_path.to_string_lossy(),
|
||||
"sizeBytes": size_bytes,
|
||||
"preview": preview,
|
||||
})));
|
||||
}
|
||||
|
||||
Err(AppError("No file uploaded".to_string()))
|
||||
}
|
||||
|
||||
pub async fn execute_sql_file(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<SqlFileExecuteWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
let execution_id = req.execution_id.clone();
|
||||
|
||||
let (tx, _) = tokio::sync::broadcast::channel::<String>(256);
|
||||
state
|
||||
.sse_channels
|
||||
.write()
|
||||
.await
|
||||
.insert(execution_id.clone(), tx.clone());
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let file_content = match std::fs::read_to_string(&req.file_path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let progress = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Error,
|
||||
statement_index: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
affected_rows: 0,
|
||||
elapsed_ms: 0,
|
||||
statement_summary: String::new(),
|
||||
error: Some(e.to_string()),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Send started
|
||||
let started = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Started,
|
||||
statement_index: 0,
|
||||
success_count: 0,
|
||||
failure_count: 0,
|
||||
affected_rows: 0,
|
||||
elapsed_ms: 0,
|
||||
statement_summary: String::new(),
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&started) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
|
||||
let statements = sql::split_sql_statements(&file_content);
|
||||
let start = std::time::Instant::now();
|
||||
let mut success_count = 0usize;
|
||||
let mut failure_count = 0usize;
|
||||
let mut total_affected: u64 = 0;
|
||||
|
||||
for (i, stmt) in statements.iter().enumerate() {
|
||||
let summary = sql::statement_summary(stmt);
|
||||
|
||||
// Send running
|
||||
let running = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Running,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary.clone(),
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&running) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
|
||||
match query::execute_sql_statement(&app, &req.connection_id, &req.database, stmt, None)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
success_count += 1;
|
||||
total_affected += result.affected_rows;
|
||||
let done = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::StatementDone,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary,
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&done) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
failure_count += 1;
|
||||
let failed = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::StatementFailed,
|
||||
statement_index: i,
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: summary,
|
||||
error: Some(e),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&failed) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
if !req.continue_on_error {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send final done
|
||||
let final_done = dbx_core::sql::SqlFileProgress {
|
||||
execution_id: req.execution_id.clone(),
|
||||
status: dbx_core::sql::SqlFileStatus::Done,
|
||||
statement_index: statements.len(),
|
||||
success_count,
|
||||
failure_count,
|
||||
affected_rows: total_affected,
|
||||
elapsed_ms: start.elapsed().as_millis(),
|
||||
statement_summary: String::new(),
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&final_done) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
|
||||
state_clone.sse_channels.write().await.remove(&req.execution_id);
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "executionId": execution_id })))
|
||||
}
|
||||
|
||||
pub async fn sql_file_progress(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(execution_id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let channels = state.sse_channels.read().await;
|
||||
let tx = channels
|
||||
.get(&execution_id)
|
||||
.ok_or_else(|| AppError("Execution not found".to_string()))?;
|
||||
let rx = tx.subscribe();
|
||||
drop(channels);
|
||||
Ok(crate::sse::sse_from_channel(rx))
|
||||
}
|
||||
|
||||
pub async fn cancel_sql_file(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelSqlFileRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
// Remove the channel to stop the execution loop
|
||||
state.sse_channels.write().await.remove(&req.execution_id);
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::table_import::{
|
||||
self, TableImportRequest,
|
||||
};
|
||||
use dbx_core::transfer;
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecuteImportWrapper {
|
||||
pub request: TableImportRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelImportRequest {
|
||||
pub import_id: String,
|
||||
}
|
||||
|
||||
pub async fn preview_import(
|
||||
State(state): State<Arc<WebState>>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let tmp_dir = state.data_dir.join("tmp");
|
||||
std::fs::create_dir_all(&tmp_dir).map_err(|e| AppError(e.to_string()))?;
|
||||
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| AppError(e.to_string()))?
|
||||
{
|
||||
let file_name = field
|
||||
.file_name()
|
||||
.unwrap_or("upload.csv")
|
||||
.to_string();
|
||||
let data = field.bytes().await.map_err(|e| AppError(e.to_string()))?;
|
||||
|
||||
let file_path = tmp_dir.join(&file_name);
|
||||
std::fs::write(&file_path, &data).map_err(|e| AppError(e.to_string()))?;
|
||||
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
let preview = table_import::preview_table_import_file_core(&file_path_str)
|
||||
.map_err(AppError)?;
|
||||
|
||||
return Ok(Json(
|
||||
serde_json::to_value(preview).map_err(|e| AppError(e.to_string()))?,
|
||||
));
|
||||
}
|
||||
|
||||
Err(AppError("No file uploaded".to_string()))
|
||||
}
|
||||
|
||||
pub async fn execute_import(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<ExecuteImportWrapper>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
let import_id = req.import_id.clone();
|
||||
|
||||
let (tx, _) = tokio::sync::broadcast::channel::<String>(256);
|
||||
state
|
||||
.sse_channels
|
||||
.write()
|
||||
.await
|
||||
.insert(import_id.clone(), tx.clone());
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let db_type = match transfer::get_db_type(&app, &req.connection_id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = tx.send(
|
||||
serde_json::json!({
|
||||
"importId": req.import_id,
|
||||
"status": "error",
|
||||
"error": e
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let pool_key = match app
|
||||
.get_or_create_pool(&req.connection_id, Some(&req.database))
|
||||
.await
|
||||
{
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
let _ = tx.send(
|
||||
serde_json::json!({
|
||||
"importId": req.import_id,
|
||||
"status": "error",
|
||||
"error": e
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tx_clone = tx.clone();
|
||||
let import_id_for_cancel = req.import_id.clone();
|
||||
let result = table_import::import_table_file_core(
|
||||
&app,
|
||||
&req,
|
||||
&db_type,
|
||||
&pool_key,
|
||||
|id: &str| {
|
||||
let id = id.to_string();
|
||||
Box::pin(async move {
|
||||
transfer::is_cancelled(&id).await
|
||||
})
|
||||
},
|
||||
|progress| {
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx_clone.send(json);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(summary) => {
|
||||
if let Ok(json) = serde_json::to_string(&summary) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(
|
||||
serde_json::json!({
|
||||
"importId": import_id_for_cancel,
|
||||
"status": "error",
|
||||
"error": e
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
state_clone.sse_channels.write().await.remove(&req.import_id);
|
||||
});
|
||||
|
||||
Ok(Json(serde_json::json!({ "importId": import_id })))
|
||||
}
|
||||
|
||||
pub async fn import_progress(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(import_id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let channels = state.sse_channels.read().await;
|
||||
let tx = channels
|
||||
.get(&import_id)
|
||||
.ok_or_else(|| AppError("Import not found".to_string()))?;
|
||||
let rx = tx.subscribe();
|
||||
drop(channels);
|
||||
Ok(crate::sse::sse_from_channel(rx))
|
||||
}
|
||||
|
||||
pub async fn cancel_import(
|
||||
State(_state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelImportRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
transfer::set_cancelled(&req.import_id).await;
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::sse::{Event, Sse};
|
||||
use axum::Json;
|
||||
use dbx_core::transfer::{self, TransferRequest, TransferStatus};
|
||||
use futures::stream::Stream;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::state::WebState;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StartTransferRequest {
|
||||
pub request: TransferRequest,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CancelTransferRequest {
|
||||
pub transfer_id: String,
|
||||
}
|
||||
|
||||
pub async fn start_transfer(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<StartTransferRequest>,
|
||||
) -> Result<Json<serde_json::Value>, AppError> {
|
||||
let req = body.request;
|
||||
let transfer_id = req.transfer_id.clone();
|
||||
|
||||
// Create a broadcast channel for progress
|
||||
let (tx, _) = tokio::sync::broadcast::channel::<String>(256);
|
||||
state
|
||||
.sse_channels
|
||||
.write()
|
||||
.await
|
||||
.insert(transfer_id.clone(), tx.clone());
|
||||
|
||||
let app = state.app.clone();
|
||||
let state_clone = state.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let source_db_type = match transfer::get_db_type(&app, &req.source_connection_id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = tx.send(serde_json::json!({"error": e}).to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target_db_type = match transfer::get_db_type(&app, &req.target_connection_id).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = tx.send(serde_json::json!({"error": e}).to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let source_pool_key = match app
|
||||
.get_or_create_pool(&req.source_connection_id, Some(&req.source_database))
|
||||
.await
|
||||
{
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
let _ = tx.send(serde_json::json!({"error": e}).to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
let target_pool_key = match app
|
||||
.get_or_create_pool(&req.target_connection_id, Some(&req.target_database))
|
||||
.await
|
||||
{
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
let _ = tx.send(serde_json::json!({"error": e}).to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tables = req.tables.clone();
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
let tx_clone = tx.clone();
|
||||
let result = transfer::transfer_table(
|
||||
&app,
|
||||
&req,
|
||||
table,
|
||||
i,
|
||||
&source_db_type,
|
||||
&target_db_type,
|
||||
&source_pool_key,
|
||||
&target_pool_key,
|
||||
|progress| {
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx_clone.send(json);
|
||||
}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(_) => {
|
||||
let progress = transfer::TransferProgress {
|
||||
transfer_id: req.transfer_id.clone(),
|
||||
table: table.clone(),
|
||||
table_index: i,
|
||||
total_tables: tables.len(),
|
||||
rows_transferred: 0,
|
||||
total_rows: None,
|
||||
status: TransferStatus::TableDone,
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let progress = transfer::TransferProgress {
|
||||
transfer_id: req.transfer_id.clone(),
|
||||
table: table.clone(),
|
||||
table_index: i,
|
||||
total_tables: tables.len(),
|
||||
rows_transferred: 0,
|
||||
total_rows: None,
|
||||
status: TransferStatus::Error,
|
||||
error: Some(e),
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&progress) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send done
|
||||
let done = transfer::TransferProgress {
|
||||
transfer_id: req.transfer_id.clone(),
|
||||
table: String::new(),
|
||||
table_index: tables.len(),
|
||||
total_tables: tables.len(),
|
||||
rows_transferred: 0,
|
||||
total_rows: None,
|
||||
status: TransferStatus::Done,
|
||||
error: None,
|
||||
};
|
||||
if let Ok(json) = serde_json::to_string(&done) {
|
||||
let _ = tx.send(json);
|
||||
}
|
||||
|
||||
// Clean up channel
|
||||
state_clone.sse_channels.write().await.remove(&req.transfer_id);
|
||||
});
|
||||
|
||||
Ok(Json(
|
||||
serde_json::json!({ "transferId": transfer_id }),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn transfer_progress(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(transfer_id): Path<String>,
|
||||
) -> Result<Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>>, AppError> {
|
||||
let channels = state.sse_channels.read().await;
|
||||
let tx = channels
|
||||
.get(&transfer_id)
|
||||
.ok_or_else(|| AppError("Transfer not found".to_string()))?;
|
||||
let rx = tx.subscribe();
|
||||
drop(channels);
|
||||
Ok(crate::sse::sse_from_channel(rx))
|
||||
}
|
||||
|
||||
pub async fn cancel_transfer(
|
||||
State(_state): State<Arc<WebState>>,
|
||||
Json(req): Json<CancelTransferRequest>,
|
||||
) -> Json<serde_json::Value> {
|
||||
transfer::set_cancelled(&req.transfer_id).await;
|
||||
Json(serde_json::json!({ "cancelled": true }))
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
use axum::Json;
|
||||
use dbx_core::update;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
pub async fn check_for_updates() -> Result<Json<serde_json::Value>, AppError> {
|
||||
let release = update::fetch_latest_release().await.map_err(AppError)?;
|
||||
let info = update::build_update_info(release, env!("CARGO_PKG_VERSION"));
|
||||
Ok(Json(serde_json::to_value(info).map_err(|e| AppError(e.to_string()))?))
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use futures::stream::Stream;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub fn sse_from_channel(
|
||||
mut rx: broadcast::Receiver<String>,
|
||||
) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
|
||||
let stream = async_stream::stream! {
|
||||
while let Ok(data) = rx.recv().await {
|
||||
yield Ok(Event::default().data(data));
|
||||
}
|
||||
};
|
||||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
use dbx_core::connection::AppState;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
|
||||
pub struct WebState {
|
||||
pub app: Arc<AppState>,
|
||||
pub data_dir: PathBuf,
|
||||
pub password_hash: Option<String>,
|
||||
pub sessions: RwLock<HashSet<String>>,
|
||||
pub sse_channels: RwLock<HashMap<String, broadcast::Sender<String>>>,
|
||||
}
|
||||
79
src/App.vue
79
src/App.vue
|
|
@ -51,10 +51,8 @@ import { useHistoryStore } from "@/stores/historyStore";
|
|||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { setLocale, currentLocale, type Locale } from "@/i18n";
|
||||
import { getCurrentWindow, type Theme } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { Theme } from "@tauri-apps/api/window";
|
||||
import * as api from "@/lib/api";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
|
||||
import { connectionDriverLabel, connectionIconType, connectionOptionSubtitle } from "@/lib/connectionPresentation";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
|
|
@ -62,6 +60,7 @@ import { buildTableSelectSql, quoteTableIdentifier } from "@/lib/tableSelectSql"
|
|||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { isCloseTabShortcut, isExecuteSqlShortcut } from "@/lib/keyboardShortcuts";
|
||||
import LoginPage from "@/components/auth/LoginPage.vue";
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
|
@ -70,6 +69,10 @@ const historyStore = useHistoryStore();
|
|||
const settingsStore = useSettingsStore();
|
||||
const { message: toastMessage, visible: toastVisible, toast } = useToast();
|
||||
|
||||
const isDesktop = isTauriRuntime();
|
||||
const needsAuth = ref(!isDesktop);
|
||||
const authenticated = ref(isDesktop);
|
||||
|
||||
const showConnectionDialog = ref(false);
|
||||
const showSettingsDialog = ref(false);
|
||||
const showHistory = ref(false);
|
||||
|
|
@ -869,9 +872,11 @@ const isDark = ref(localStorage.getItem("dbx-theme") === "dark");
|
|||
function applyTheme() {
|
||||
document.documentElement.classList.toggle("dark", isDark.value);
|
||||
if (!isTauriRuntime()) return;
|
||||
getCurrentWindow()
|
||||
.setTheme(isDark.value ? "dark" as Theme : "light" as Theme)
|
||||
.catch(() => {});
|
||||
import("@tauri-apps/api/window").then(({ getCurrentWindow }) => {
|
||||
getCurrentWindow()
|
||||
.setTheme(isDark.value ? "dark" as Theme : "light" as Theme)
|
||||
.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
|
|
@ -880,14 +885,20 @@ function toggleTheme() {
|
|||
applyTheme();
|
||||
}
|
||||
|
||||
import { open } from "@tauri-apps/plugin-shell";
|
||||
function openUrl(url: string) {
|
||||
if (isTauriRuntime()) {
|
||||
import("@tauri-apps/plugin-shell").then(({ open }) => open(url));
|
||||
} else {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
function openGitHub() {
|
||||
open("https://github.com/t8y2/dbx");
|
||||
openUrl("https://github.com/t8y2/dbx");
|
||||
}
|
||||
|
||||
function openMcpGuide() {
|
||||
open("https://github.com/t8y2/dbx/blob/main/docs/mcp-guide.md");
|
||||
openUrl("https://github.com/t8y2/dbx/blob/main/docs/mcp-guide.md");
|
||||
}
|
||||
|
||||
async function checkUpdates(options: { silent?: boolean } = {}) {
|
||||
|
|
@ -923,7 +934,7 @@ function formatUpdateError(message: string): string {
|
|||
|
||||
function openLatestRelease() {
|
||||
const url = updateInfo.value?.release_url || latestReleaseUrl;
|
||||
open(url);
|
||||
openUrl(url);
|
||||
}
|
||||
|
||||
const isDownloadingUpdate = ref(false);
|
||||
|
|
@ -987,19 +998,44 @@ function handleKeydown(e: KeyboardEvent) {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme();
|
||||
function initApp() {
|
||||
connectionStore.initFromDisk().catch((e: any) => {
|
||||
toast(t("connection.loadFailed", { message: e?.message || String(e) }), 5000);
|
||||
});
|
||||
settingsStore.initAiConfig();
|
||||
}
|
||||
|
||||
function onAuthenticated() {
|
||||
authenticated.value = true;
|
||||
initApp();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
applyTheme();
|
||||
window.addEventListener("keydown", handleKeydown, true);
|
||||
window.addEventListener("resize", updateScrollButtons);
|
||||
if (isTauriRuntime()) {
|
||||
setupFileDrop().catch(() => {});
|
||||
checkUpdates({ silent: true });
|
||||
|
||||
if (!isDesktop) {
|
||||
try {
|
||||
const res = await fetch("/api/auth/check");
|
||||
const data = await res.json();
|
||||
needsAuth.value = data.required;
|
||||
authenticated.value = data.authenticated;
|
||||
} catch { /* server unreachable */ }
|
||||
if (!needsAuth.value || authenticated.value) {
|
||||
initApp();
|
||||
}
|
||||
api.checkForUpdates().then((info) => { appVersion.value = info.current_version; }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
initApp();
|
||||
setupFileDrop().catch(() => {});
|
||||
checkUpdates({ silent: true });
|
||||
import("@tauri-apps/api/app").then(({ getVersion }) => {
|
||||
getVersion().then((v) => { appVersion.value = v; }).catch(() => {});
|
||||
import("@tauri-apps/api/event").then(({ listen }) => {
|
||||
}).catch(() => {});
|
||||
import("@tauri-apps/api/event").then(({ listen }) => {
|
||||
listen<{ connection_id: string; database: string; schema?: string; table: string }>("mcp-open-table", async (event) => {
|
||||
const { connection_id, database, schema, table } = event.payload;
|
||||
|
||||
|
|
@ -1019,7 +1055,7 @@ onMounted(() => {
|
|||
openLineageTarget({ connectionId: connection_id, database, schema, tableName: table });
|
||||
}
|
||||
|
||||
getCurrentWindow().setFocus().catch(() => {});
|
||||
import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().setFocus().catch(() => {}));
|
||||
});
|
||||
listen<{ connection_id: string; database: string; sql: string }>("mcp-execute-query", async (event) => {
|
||||
const { connection_id, database, sql } = event.payload;
|
||||
|
|
@ -1033,10 +1069,9 @@ onMounted(() => {
|
|||
const tabId = queryStore.createTab(connection_id, database, undefined, "query");
|
||||
queryStore.updateSql(tabId, sql);
|
||||
await queryStore.executeTabSql(tabId, sql);
|
||||
getCurrentWindow().setFocus().catch(() => {});
|
||||
import("@tauri-apps/api/window").then(({ getCurrentWindow }) => getCurrentWindow().setFocus().catch(() => {}));
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
|
|
@ -1064,6 +1099,7 @@ function getDataFileQuery(path: string): string | null {
|
|||
}
|
||||
|
||||
async function setupFileDrop() {
|
||||
const { getCurrentWebview } = await import("@tauri-apps/api/webview");
|
||||
const webview = getCurrentWebview();
|
||||
await webview.onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== "drop") return;
|
||||
|
|
@ -1120,7 +1156,8 @@ async function setupFileDrop() {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<TooltipProvider :delay-duration="300">
|
||||
<LoginPage v-if="needsAuth && !authenticated" @authenticated="onAuthenticated" />
|
||||
<TooltipProvider v-show="!needsAuth || authenticated" :delay-duration="300">
|
||||
<div class="h-screen w-screen flex flex-col bg-background text-foreground overflow-hidden">
|
||||
<!-- Toolbar -->
|
||||
<div class="h-10 flex items-center gap-1 px-2 border-b bg-muted/30 shrink-0">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { DatabaseZap } from "lucide-vue-next";
|
||||
|
||||
const emit = defineEmits<{ authenticated: [] }>();
|
||||
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const loading = ref(false);
|
||||
|
||||
async function login() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: password.value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
emit("authenticated");
|
||||
} else {
|
||||
const text = await res.text();
|
||||
error.value = text || "密码错误";
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || "连接失败";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-center h-screen bg-background">
|
||||
<div class="w-80 space-y-6 text-center">
|
||||
<div class="flex items-center justify-center gap-2 text-2xl font-bold">
|
||||
<DatabaseZap class="w-8 h-8" />
|
||||
<span>DBX</span>
|
||||
</div>
|
||||
<form class="space-y-4" @submit.prevent="login">
|
||||
<Input
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入访问密码"
|
||||
autofocus
|
||||
/>
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
<Button type="submit" class="w-full" :disabled="loading || !password">
|
||||
{{ loading ? "登录中..." : "登录" }}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -14,8 +14,8 @@ import type { ConnectionConfig, DatabaseType } from "@/types/database";
|
|||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import * as api from "@/lib/api";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import { ArrowLeft, ChevronRight, Copy, FolderOpen, Grid3X3, List, Search } from "lucide-vue-next";
|
||||
|
||||
type DbOption = { value: string; label: string };
|
||||
|
|
@ -402,12 +402,15 @@ watch([() => editingId.value, () => open.value], () => {
|
|||
});
|
||||
|
||||
async function browseSshKeyPath() {
|
||||
const selected = await openFileDialog({
|
||||
title: "Select SSH Private Key",
|
||||
multiple: false,
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
form.value.ssh_key_path = selected;
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
title: "Select SSH Private Key",
|
||||
multiple: false,
|
||||
});
|
||||
if (selected && typeof selected === "string") {
|
||||
form.value.ssh_key_path = selected;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import {
|
||||
buildDiagramRelationships,
|
||||
|
|
@ -38,6 +38,7 @@ import {
|
|||
ZoomIn, ZoomOut,
|
||||
} from "lucide-vue-next";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
|
@ -571,10 +572,6 @@ function currentDiagramSvg(): string {
|
|||
|
||||
async function exportSvg() {
|
||||
try {
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const scopeName = isSchemaAware.value && schema.value
|
||||
? `${database.value}-${schema.value}`
|
||||
: database.value;
|
||||
|
|
@ -583,13 +580,28 @@ async function exportSvg() {
|
|||
scopeName,
|
||||
diagramMode.value,
|
||||
);
|
||||
const path = await save({
|
||||
defaultPath,
|
||||
filters: [{ name: "SVG", extensions: ["svg"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
const svgContent = currentDiagramSvg();
|
||||
|
||||
await writeTextFile(path, currentDiagramSvg());
|
||||
if (isTauriRuntime()) {
|
||||
const [{ save }, { writeTextFile }] = await Promise.all([
|
||||
import("@tauri-apps/plugin-dialog"),
|
||||
import("@tauri-apps/plugin-fs"),
|
||||
]);
|
||||
const path = await save({
|
||||
defaultPath,
|
||||
filters: [{ name: "SVG", extensions: ["svg"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
await writeTextFile(path, svgContent);
|
||||
} else {
|
||||
const blob = new Blob([svgContent], { type: "image/svg+xml" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultPath;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
toast(t("diagram.exportedSvg"));
|
||||
} catch (e: any) {
|
||||
toast(t("diagram.exportSvgFailed", { message: e?.message || String(e) }), 5000);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
diffColumns, diffIndexes, diffTables, generateSyncSql,
|
||||
type TableDiff,
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
|
|||
import {
|
||||
listDatabases, redisListDatabases, mongoListDatabases, aiTestConnection, aiCancelStream,
|
||||
saveAiConversation, loadAiConversations, deleteAiConversation, type AiConversation,
|
||||
} from "@/lib/tauri";
|
||||
import type { AiMessage } from "@/lib/tauri";
|
||||
} from "@/lib/api";
|
||||
import type { AiMessage } from "@/lib/api";
|
||||
import type { ConnectionConfig, QueryTab } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -620,15 +620,15 @@ function formatInlineText(text: string): string {
|
|||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API Key</Label>
|
||||
<Input v-model="tempApiKey" type="password" class="col-span-2 h-8 text-xs" />
|
||||
<Input v-model="tempApiKey" type="password" autocomplete="off" class="col-span-2 h-8 text-xs" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Endpoint</Label>
|
||||
<Input v-model="tempEndpoint" placeholder="https://api.openai.com/v1" class="col-span-2 h-8 text-xs" />
|
||||
<Input v-model="tempEndpoint" placeholder="https://api.openai.com/v1" autocomplete="off" class="col-span-2 h-8 text-xs" />
|
||||
</div>
|
||||
<div class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">Model</Label>
|
||||
<Input v-model="tempModel" class="col-span-2 h-8 text-xs" />
|
||||
<Input v-model="tempModel" autocomplete="off" class="col-span-2 h-8 text-xs" />
|
||||
</div>
|
||||
<div v-if="tempProvider !== 'claude'" class="grid grid-cols-3 items-center gap-3">
|
||||
<Label class="text-right text-xs">API</Label>
|
||||
|
|
@ -640,7 +640,7 @@ function formatInlineText(text: string): string {
|
|||
</div>
|
||||
<DialogFooter class="flex items-center gap-2">
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" :disabled="testingAi || !tempApiKey.trim() || !tempEndpoint.trim() || !tempModel.trim()" @click="testAiConnection">
|
||||
<Button size="sm" variant="outline" :disabled="testingAi || !tempApiKey?.trim() || !tempEndpoint?.trim() || !tempModel?.trim()" @click="testAiConnection">
|
||||
<Loader2 v-if="testingAi" class="h-3 w-3 animate-spin mr-1" />
|
||||
{{ t('connection.test') }}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -22,9 +22,8 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import type { QueryResult, ColumnInfo, DatabaseType } from "@/types/database";
|
||||
import { save as savePath } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import * as api from "@/lib/tauri";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
extractSelection,
|
||||
formatSelectionAsCsv,
|
||||
|
|
@ -953,14 +952,30 @@ function copyAll() {
|
|||
copyText(`${header}\n${body}`);
|
||||
}
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ defaultPath: defaultFileName, filters: [{ name: filterName, extensions: [filterExt] }] });
|
||||
if (path) await writeTextFile(path, content);
|
||||
} else {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const escape = (v: string) => `"${v.replace(/"/g, '""')}"`;
|
||||
const header = props.result.columns.map(escape).join(",");
|
||||
const body = displayItems.value
|
||||
.map((item) => item.data.map((c) => escape(formatCell(c))).join(","))
|
||||
.join("\n");
|
||||
const path = await savePath({ filters: [{ name: "CSV", extensions: ["csv"] }] });
|
||||
if (path) await writeTextFile(path, `${header}\n${body}`);
|
||||
await saveFileContent(`${header}\n${body}`, "export.csv", "CSV", "csv");
|
||||
}
|
||||
|
||||
async function exportJson() {
|
||||
|
|
@ -969,16 +984,14 @@ async function exportJson() {
|
|||
props.result.columns.forEach((col, i) => { obj[col] = item.data[i]; });
|
||||
return obj;
|
||||
});
|
||||
const path = await savePath({ filters: [{ name: "JSON", extensions: ["json"] }] });
|
||||
if (path) await writeTextFile(path, JSON.stringify(data, null, 2));
|
||||
await saveFileContent(JSON.stringify(data, null, 2), "export.json", "JSON", "json");
|
||||
}
|
||||
|
||||
async function exportMarkdown() {
|
||||
const cols = props.result.columns;
|
||||
const visibleRows = displayItems.value.map((item) => item.data);
|
||||
const md = formatMarkdownTable({ columns: cols, rows: visibleRows });
|
||||
const path = await savePath({ filters: [{ name: "Markdown", extensions: ["md"] }] });
|
||||
if (path) await writeTextFile(path, md);
|
||||
await saveFileContent(md, "export.md", "Markdown", "md");
|
||||
}
|
||||
|
||||
const sqlOneLiner = computed(() => props.sql?.replace(/\s+/g, " ").trim() || "");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import {
|
||||
Dialog, DialogHeader, DialogTitle, DialogFooter, DialogScrollContent,
|
||||
} from "@/components/ui/dialog";
|
||||
|
|
@ -18,7 +18,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { useToast } from "@/composables/useToast";
|
||||
import { autoMapImportColumns } from "@/lib/tableImport";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
|
|
@ -119,7 +119,9 @@ async function loadTargetColumns() {
|
|||
}
|
||||
|
||||
async function selectFile() {
|
||||
const selected = await openFileDialog({
|
||||
if (!isTauriRuntime()) return;
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{ name: "Data files", extensions: ["csv", "tsv", "json", "xlsx", "xlsm", "xls"] },
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle }
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
analyzeFieldLineage,
|
||||
summarizeLineageCounts,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { RefreshCw, Trash2, Plus, Save, ChevronLeft, ChevronRight } from "lucide
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import JsonEditNode from "./JsonEditNode.vue";
|
||||
import type { EditNode } from "@/types/editor";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import RedisValueViewer from "./RedisValueViewer.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { RedisKeyInfo } from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisKeyInfo } from "@/lib/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { RedisValue } from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import type { RedisValue } from "@/lib/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle }
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
buildDatabaseSearchSql,
|
||||
buildSearchResultWhere,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
|||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import type { DatabaseType, QueryResult, TreeNode, TreeNodeType } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import {
|
||||
DATABASE_EXPORT_PAGE_SIZE,
|
||||
DATABASE_EXPORT_ROW_LIMIT,
|
||||
|
|
@ -27,6 +27,7 @@ import {
|
|||
} from "@/lib/databaseExport";
|
||||
import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } from "@/lib/tableSelectSql";
|
||||
import { treeNodeRowAction } from "@/lib/treeNodeClick";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
|
|
@ -357,6 +358,23 @@ async function fetchExportTableRows(
|
|||
};
|
||||
}
|
||||
|
||||
async function saveFileContent(content: string, defaultFileName: string, filterName: string, filterExt: string) {
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ defaultPath: defaultFileName, filters: [{ name: filterName, extensions: [filterExt] }] });
|
||||
if (path) await writeTextFile(path, content);
|
||||
} else {
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = defaultFileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportDatabase() {
|
||||
const node = props.node;
|
||||
if (!(node.type === "database" || node.type === "schema") || !node.connectionId || !node.database) return;
|
||||
|
|
@ -393,15 +411,7 @@ async function exportDatabase() {
|
|||
rowLimitPerTable: DATABASE_EXPORT_ROW_LIMIT,
|
||||
});
|
||||
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({
|
||||
defaultPath: `${safeFileName(scopeName)}.sql`,
|
||||
filters: [{ name: "SQL", extensions: ["sql"] }],
|
||||
});
|
||||
if (!path) return;
|
||||
|
||||
await writeTextFile(path, content);
|
||||
await saveFileContent(content, `${safeFileName(scopeName)}.sql`, "SQL", "sql");
|
||||
toast(t("contextMenu.exportDatabaseSuccess", { count: exportedTables.length, limit: DATABASE_EXPORT_ROW_LIMIT }), 3000);
|
||||
} catch (e: any) {
|
||||
console.error("Export database failed:", e);
|
||||
|
|
@ -417,10 +427,7 @@ async function exportStructure() {
|
|||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const ddl = await api.getTableDdl(node.connectionId, node.database, node.schema || node.database, node.label);
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ defaultPath: `${node.label}.sql`, filters: [{ name: "SQL", extensions: ["sql"] }] });
|
||||
if (path) await writeTextFile(path, ddl + "\n");
|
||||
await saveFileContent(ddl + "\n", `${node.label}.sql`, "SQL", "sql");
|
||||
} catch (e: any) {
|
||||
console.error("Export structure failed:", e);
|
||||
}
|
||||
|
|
@ -439,9 +446,6 @@ async function exportData(format: "csv" | "json" | "sql") {
|
|||
: quoteIdent(node.label);
|
||||
const result = await api.executeQuery(node.connectionId, node.database, `SELECT * FROM ${qualifiedName}`);
|
||||
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
|
||||
let content: string;
|
||||
let ext: string;
|
||||
|
||||
|
|
@ -473,8 +477,7 @@ async function exportData(format: "csv" | "json" | "sql") {
|
|||
content = lines.join("\n");
|
||||
}
|
||||
|
||||
const path = await save({ defaultPath: `${node.label}.${ext}`, filters: [{ name: ext.toUpperCase(), extensions: [ext] }] });
|
||||
if (path) await writeTextFile(path, content);
|
||||
await saveFileContent(content, `${node.label}.${ext}`, ext.toUpperCase(), ext);
|
||||
} catch (e: any) {
|
||||
console.error("Export data failed:", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import {
|
||||
Dialog, DialogFooter, DialogHeader, DialogScrollContent, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
type SqlFilePreview,
|
||||
type SqlFileProgress,
|
||||
type SqlFileStatus,
|
||||
} from "@/lib/tauri";
|
||||
} from "@/lib/api";
|
||||
import { Check, CheckSquare, FileCode, FolderOpen, Loader2, Play, Square, X } from "lucide-vue-next";
|
||||
|
||||
const { t } = useI18n();
|
||||
|
|
@ -214,9 +214,11 @@ async function loadPreview(path: string) {
|
|||
|
||||
async function selectFile() {
|
||||
if (running.value) return;
|
||||
if (!isTauriRuntime()) return;
|
||||
selectingFile.value = true;
|
||||
try {
|
||||
const selected = await openDialog({
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [{ name: "SQL", extensions: ["sql"] }],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { useToast } from "@/composables/useToast";
|
|||
import { buildTableStructureChangeSql, type EditableStructureColumn, type EditableStructureIndex } from "@/lib/tableStructureEditorSql";
|
||||
import { createColumnDrafts, createIndexDrafts, toColumnNames } from "@/lib/tableStructureEditorState";
|
||||
import type { ForeignKeyInfo, TriggerInfo } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useConnectionStore();
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { TransferProgress } from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import type { TransferProgress } from "@/lib/api";
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { nextTransferTerminalState } from "@/lib/transferProgressState";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, ForeignKeyInfo, IndexInfo, QueryResult, QueryTab } from "@/types/database";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import { currentLocale } from "@/i18n";
|
||||
|
||||
export type AiAction = "generate" | "explain" | "optimize" | "fix" | "convert" | "sampleData";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
import { isTauriRuntime } from "./tauriRuntime";
|
||||
import type * as TauriModule from "./tauri";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy backend resolution (avoids top-level await)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Backend = typeof TauriModule;
|
||||
|
||||
let _backend: Backend | null = null;
|
||||
|
||||
async function getBackend(): Promise<Backend> {
|
||||
if (_backend) return _backend;
|
||||
_backend = isTauriRuntime(globalThis)
|
||||
? await import("./tauri")
|
||||
: await import("./http");
|
||||
return _backend;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: create a forwarding function that lazily resolves the backend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function forward<K extends keyof Backend>(name: K): Backend[K] {
|
||||
return (async (...args: unknown[]) => {
|
||||
const b = await getBackend();
|
||||
return (b[name] as (...a: unknown[]) => unknown)(...args);
|
||||
}) as unknown as Backend[K];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-export all functions via lazy forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Connection
|
||||
export const testConnection = forward("testConnection");
|
||||
export const connectDb = forward("connectDb");
|
||||
export const disconnectDb = forward("disconnectDb");
|
||||
export const saveConnections = forward("saveConnections");
|
||||
export const loadConnections = forward("loadConnections");
|
||||
|
||||
// Schema
|
||||
export const listDatabases = forward("listDatabases");
|
||||
export const listSchemas = forward("listSchemas");
|
||||
export const listTables = forward("listTables");
|
||||
export const getColumns = forward("getColumns");
|
||||
export const listIndexes = forward("listIndexes");
|
||||
export const listForeignKeys = forward("listForeignKeys");
|
||||
export const listTriggers = forward("listTriggers");
|
||||
export const getTableDdl = forward("getTableDdl");
|
||||
|
||||
// Query
|
||||
export const executeQuery = forward("executeQuery");
|
||||
export const executeMulti = forward("executeMulti");
|
||||
export const executeBatch = forward("executeBatch");
|
||||
export const executeScript = forward("executeScript");
|
||||
export const cancelQuery = forward("cancelQuery");
|
||||
|
||||
// AI
|
||||
export const aiComplete = forward("aiComplete");
|
||||
export const aiStream = forward("aiStream");
|
||||
export const aiCancelStream = forward("aiCancelStream");
|
||||
export const aiTestConnection = forward("aiTestConnection");
|
||||
export const saveAiConfig = forward("saveAiConfig");
|
||||
export const loadAiConfig = forward("loadAiConfig");
|
||||
export const saveAiConversation = forward("saveAiConversation");
|
||||
export const loadAiConversations = forward("loadAiConversations");
|
||||
export const deleteAiConversation = forward("deleteAiConversation");
|
||||
|
||||
// SQL File Execution
|
||||
export const previewSqlFile = forward("previewSqlFile");
|
||||
export const executeSqlFile = forward("executeSqlFile");
|
||||
export const cancelSqlFileExecution = forward("cancelSqlFileExecution");
|
||||
export const listenSqlFileProgress = forward("listenSqlFileProgress");
|
||||
|
||||
// Data Transfer
|
||||
export const startTransfer = forward("startTransfer");
|
||||
export const cancelTransfer = forward("cancelTransfer");
|
||||
|
||||
// Table File Import
|
||||
export const previewTableImportFile = forward("previewTableImportFile");
|
||||
export const importTableFile = forward("importTableFile");
|
||||
export const cancelTableImport = forward("cancelTableImport");
|
||||
|
||||
// Redis
|
||||
export const redisListDatabases = forward("redisListDatabases");
|
||||
export const redisScanKeys = forward("redisScanKeys");
|
||||
export const redisGetValue = forward("redisGetValue");
|
||||
export const redisSetString = forward("redisSetString");
|
||||
export const redisDeleteKey = forward("redisDeleteKey");
|
||||
export const redisHashSet = forward("redisHashSet");
|
||||
export const redisHashDel = forward("redisHashDel");
|
||||
export const redisListPush = forward("redisListPush");
|
||||
export const redisListRemove = forward("redisListRemove");
|
||||
export const redisSetAdd = forward("redisSetAdd");
|
||||
export const redisSetRemove = forward("redisSetRemove");
|
||||
|
||||
// MongoDB
|
||||
export const mongoListDatabases = forward("mongoListDatabases");
|
||||
export const mongoListCollections = forward("mongoListCollections");
|
||||
export const mongoFindDocuments = forward("mongoFindDocuments");
|
||||
export const mongoInsertDocument = forward("mongoInsertDocument");
|
||||
export const mongoUpdateDocument = forward("mongoUpdateDocument");
|
||||
export const mongoDeleteDocument = forward("mongoDeleteDocument");
|
||||
|
||||
// History
|
||||
export const saveHistory = forward("saveHistory");
|
||||
export const loadHistory = forward("loadHistory");
|
||||
export const clearHistory = forward("clearHistory");
|
||||
export const deleteHistoryEntry = forward("deleteHistoryEntry");
|
||||
|
||||
// Updates
|
||||
export const checkForUpdates = forward("checkForUpdates");
|
||||
|
||||
// Layout
|
||||
export const saveSidebarLayout = forward("saveSidebarLayout");
|
||||
export const loadSidebarLayout = forward("loadSidebarLayout");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Re-export all types from tauri.ts (shared between both backends)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type {
|
||||
AiMessage,
|
||||
AiCompletionRequest,
|
||||
AiStreamChunk,
|
||||
AiChatMessage,
|
||||
AiConversation,
|
||||
UpdateInfo,
|
||||
RedisKeyInfo,
|
||||
RedisValue,
|
||||
RedisScanResult,
|
||||
MongoDocumentResult,
|
||||
HistoryEntry,
|
||||
SqlFileStatus,
|
||||
SqlFileRequest,
|
||||
SqlFilePreview,
|
||||
SqlFileProgress,
|
||||
TransferRequest,
|
||||
TransferProgress,
|
||||
TableImportMode,
|
||||
TableImportStatus,
|
||||
TableImportColumnMapping,
|
||||
TableImportPreview,
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
} from "./tauri";
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
import type {
|
||||
ConnectionConfig,
|
||||
DatabaseInfo,
|
||||
TableInfo,
|
||||
ColumnInfo,
|
||||
IndexInfo,
|
||||
ForeignKeyInfo,
|
||||
TriggerInfo,
|
||||
QueryResult,
|
||||
SidebarLayout,
|
||||
} from "@/types/database";
|
||||
import type { AiConfig } from "@/stores/settingsStore";
|
||||
import type {
|
||||
AiCompletionRequest,
|
||||
AiStreamChunk,
|
||||
AiConversation,
|
||||
UpdateInfo,
|
||||
RedisValue,
|
||||
RedisScanResult,
|
||||
MongoDocumentResult,
|
||||
HistoryEntry,
|
||||
SqlFileRequest,
|
||||
SqlFilePreview,
|
||||
SqlFileProgress,
|
||||
TransferRequest,
|
||||
TransferProgress,
|
||||
TableImportPreview,
|
||||
TableImportRequest,
|
||||
TableImportSummary,
|
||||
TableImportProgress,
|
||||
} from "./tauri";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function post<T>(url: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function get<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function del<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function qs(params: Record<string, string | number | undefined>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null) sp.set(k, String(v));
|
||||
}
|
||||
return sp.toString();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function testConnection(config: ConnectionConfig): Promise<string> {
|
||||
return post("/api/connection/test", { config });
|
||||
}
|
||||
|
||||
export async function connectDb(config: ConnectionConfig): Promise<string> {
|
||||
return post("/api/connection/connect", { config });
|
||||
}
|
||||
|
||||
export async function disconnectDb(connectionId: string): Promise<void> {
|
||||
return post("/api/connection/disconnect", { connectionId });
|
||||
}
|
||||
|
||||
export async function saveConnections(configs: ConnectionConfig[]): Promise<void> {
|
||||
return post("/api/connection/save", { configs });
|
||||
}
|
||||
|
||||
export async function loadConnections(): Promise<ConnectionConfig[]> {
|
||||
return get("/api/connection/list");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listDatabases(connectionId: string): Promise<DatabaseInfo[]> {
|
||||
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
|
||||
}
|
||||
|
||||
export async function listSchemas(connectionId: string, database: string): Promise<string[]> {
|
||||
return get(`/api/schema/schemas?${qs({ connection_id: connectionId, database })}`);
|
||||
}
|
||||
|
||||
export async function listTables(connectionId: string, database: string, schema: string): Promise<TableInfo[]> {
|
||||
return get(`/api/schema/tables?${qs({ connection_id: connectionId, database, schema })}`);
|
||||
}
|
||||
|
||||
export async function getColumns(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<ColumnInfo[]> {
|
||||
return get(`/api/schema/columns?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
export async function listIndexes(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<IndexInfo[]> {
|
||||
return get(`/api/schema/indexes?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
export async function listForeignKeys(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<ForeignKeyInfo[]> {
|
||||
return get(`/api/schema/foreign-keys?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
export async function listTriggers(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<TriggerInfo[]> {
|
||||
return get(`/api/schema/triggers?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
export async function getTableDdl(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
schema: string,
|
||||
table: string,
|
||||
): Promise<string> {
|
||||
return get(`/api/schema/ddl?${qs({ connection_id: connectionId, database, schema, table })}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function executeQuery(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
sql: string,
|
||||
executionId?: string,
|
||||
): Promise<QueryResult> {
|
||||
return post("/api/query/execute", { connectionId, database, sql, executionId });
|
||||
}
|
||||
|
||||
export async function executeMulti(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
sql: string,
|
||||
executionId?: string,
|
||||
): Promise<QueryResult[]> {
|
||||
return post("/api/query/execute-multi", { connectionId, database, sql, executionId });
|
||||
}
|
||||
|
||||
export async function executeBatch(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
statements: string[],
|
||||
): Promise<QueryResult> {
|
||||
return post("/api/query/execute-batch", { connectionId, database, statements });
|
||||
}
|
||||
|
||||
export async function executeScript(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
sql: string,
|
||||
): Promise<QueryResult> {
|
||||
return post("/api/query/execute-script", { connectionId, database, sql });
|
||||
}
|
||||
|
||||
export async function cancelQuery(executionId: string): Promise<boolean> {
|
||||
return post("/api/query/cancel", { executionId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function aiComplete(request: AiCompletionRequest): Promise<string> {
|
||||
return post("/api/ai/complete", { request });
|
||||
}
|
||||
|
||||
export async function aiStream(
|
||||
sessionId: string,
|
||||
request: AiCompletionRequest,
|
||||
onChunk: (chunk: AiStreamChunk) => void,
|
||||
): Promise<void> {
|
||||
const res = await fetch("/api/ai/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const reader = res.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
const data = line.slice(5).trim();
|
||||
if (data && data !== "[DONE]") {
|
||||
try {
|
||||
const chunk: AiStreamChunk = JSON.parse(data);
|
||||
onChunk(chunk);
|
||||
if (chunk.done) return;
|
||||
} catch {
|
||||
// skip malformed JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function aiCancelStream(sessionId: string): Promise<boolean> {
|
||||
return post("/api/ai/cancel-stream", { sessionId });
|
||||
}
|
||||
|
||||
export async function aiTestConnection(config: AiConfig): Promise<string> {
|
||||
return post("/api/ai/test-connection", { config });
|
||||
}
|
||||
|
||||
export async function saveAiConfig(config: AiConfig): Promise<void> {
|
||||
return post("/api/ai/config", { config });
|
||||
}
|
||||
|
||||
export async function loadAiConfig(): Promise<AiConfig | null> {
|
||||
return get("/api/ai/config");
|
||||
}
|
||||
|
||||
// --- AI Conversations ---
|
||||
|
||||
export async function saveAiConversation(conversation: AiConversation): Promise<void> {
|
||||
return post("/api/ai/conversation", { conversation });
|
||||
}
|
||||
|
||||
export async function loadAiConversations(): Promise<AiConversation[]> {
|
||||
return get("/api/ai/conversations");
|
||||
}
|
||||
|
||||
export async function deleteAiConversation(id: string): Promise<void> {
|
||||
return del(`/api/ai/conversation/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQL File Execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function previewSqlFile(fileOrPath: string | File): Promise<SqlFilePreview> {
|
||||
if (typeof fileOrPath === "string") {
|
||||
// In web mode a raw path is not useful; throw a clear error
|
||||
throw new Error("previewSqlFile in web mode requires a File object, not a file path");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileOrPath);
|
||||
const res = await fetch("/api/sql-file/preview", { method: "POST", body: formData });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function executeSqlFile(request: SqlFileRequest): Promise<void> {
|
||||
return post("/api/sql-file/execute", { request });
|
||||
}
|
||||
|
||||
export async function cancelSqlFileExecution(executionId: string): Promise<boolean> {
|
||||
return post("/api/sql-file/cancel", { executionId });
|
||||
}
|
||||
|
||||
export async function listenSqlFileProgress(
|
||||
_handler: (progress: SqlFileProgress) => void,
|
||||
): Promise<() => void> {
|
||||
// For HTTP mode we need an executionId, but the tauri API does not take one.
|
||||
// The SSE endpoint requires a specific executionId. As a workaround we return
|
||||
// a no-op unlisten; callers that need progress in web mode should use
|
||||
// listenSqlFileProgressById instead.
|
||||
return () => {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Web-specific: listen to SQL file execution progress for a given executionId via SSE.
|
||||
*/
|
||||
export function listenSqlFileProgressById(
|
||||
executionId: string,
|
||||
handler: (progress: SqlFileProgress) => void,
|
||||
): () => void {
|
||||
const es = new EventSource(`/api/sql-file/progress/${executionId}`);
|
||||
es.onmessage = (e) => {
|
||||
const progress: SqlFileProgress = JSON.parse(e.data);
|
||||
handler(progress);
|
||||
if (progress.status === "done" || progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
};
|
||||
return () => es.close();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Transfer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startTransfer(
|
||||
request: TransferRequest,
|
||||
onProgress: (progress: TransferProgress) => void,
|
||||
): Promise<void> {
|
||||
// 1. POST to start the transfer
|
||||
const res = await fetch("/api/transfer/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
// 2. SSE to listen for progress
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(`/api/transfer/progress/${request.transferId}`);
|
||||
es.onmessage = (e) => {
|
||||
const progress: TransferProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "done" || progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("Transfer SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelTransfer(transferId: string): Promise<void> {
|
||||
return post("/api/transfer/cancel", { transferId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Table File Import
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function previewTableImportFile(fileOrPath: string | File): Promise<TableImportPreview> {
|
||||
if (typeof fileOrPath === "string") {
|
||||
throw new Error("previewTableImportFile in web mode requires a File object, not a file path");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", fileOrPath);
|
||||
const res = await fetch("/api/import/preview", { method: "POST", body: formData });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function importTableFile(
|
||||
request: TableImportRequest,
|
||||
onProgress: (progress: TableImportProgress) => void,
|
||||
): Promise<TableImportSummary> {
|
||||
// 1. POST to start the import
|
||||
const res = await fetch("/api/import/execute", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ request }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
// 2. SSE to listen for progress
|
||||
return new Promise((resolve, reject) => {
|
||||
const es = new EventSource(`/api/import/progress/${request.importId}`);
|
||||
let summary: TableImportSummary | null = null;
|
||||
es.onmessage = (e) => {
|
||||
const progress: TableImportProgress = JSON.parse(e.data);
|
||||
onProgress(progress);
|
||||
if (progress.status === "done") {
|
||||
summary = {
|
||||
importId: progress.importId,
|
||||
rowsImported: progress.rowsImported,
|
||||
totalRows: progress.totalRows,
|
||||
};
|
||||
es.close();
|
||||
resolve(summary);
|
||||
} else if (progress.status === "error" || progress.status === "cancelled") {
|
||||
es.close();
|
||||
reject(new Error(progress.error || "Import failed"));
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
reject(new Error("Import SSE connection failed"));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelTableImport(importId: string): Promise<boolean> {
|
||||
return post("/api/import/cancel", { importId });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Redis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function redisListDatabases(connectionId: string): Promise<number[]> {
|
||||
return post("/api/redis/list-databases", { connectionId });
|
||||
}
|
||||
|
||||
export async function redisScanKeys(
|
||||
connectionId: string,
|
||||
db: number,
|
||||
cursor: number,
|
||||
pattern: string,
|
||||
count: number,
|
||||
): Promise<RedisScanResult> {
|
||||
return post("/api/redis/scan-keys", { connectionId, db, cursor, pattern, count });
|
||||
}
|
||||
|
||||
export async function redisGetValue(connectionId: string, key: string): Promise<RedisValue> {
|
||||
return post("/api/redis/get-value", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function redisSetString(connectionId: string, key: string, value: string, ttl?: number): Promise<void> {
|
||||
return post("/api/redis/set-string", { connectionId, key, value, ttl });
|
||||
}
|
||||
|
||||
export async function redisDeleteKey(connectionId: string, key: string): Promise<void> {
|
||||
return post("/api/redis/delete-key", { connectionId, key });
|
||||
}
|
||||
|
||||
export async function redisHashSet(connectionId: string, key: string, field: string, value: string): Promise<void> {
|
||||
return post("/api/redis/hash-set", { connectionId, key, field, value });
|
||||
}
|
||||
|
||||
export async function redisHashDel(connectionId: string, key: string, field: string): Promise<void> {
|
||||
return post("/api/redis/hash-del", { connectionId, key, field });
|
||||
}
|
||||
|
||||
export async function redisListPush(connectionId: string, key: string, value: string): Promise<void> {
|
||||
return post("/api/redis/list-push", { connectionId, key, value });
|
||||
}
|
||||
|
||||
export async function redisListRemove(connectionId: string, key: string, index: number): Promise<void> {
|
||||
return post("/api/redis/list-remove", { connectionId, key, index });
|
||||
}
|
||||
|
||||
export async function redisSetAdd(connectionId: string, key: string, member: string): Promise<void> {
|
||||
return post("/api/redis/set-add", { connectionId, key, member });
|
||||
}
|
||||
|
||||
export async function redisSetRemove(connectionId: string, key: string, member: string): Promise<void> {
|
||||
return post("/api/redis/set-remove", { connectionId, key, member });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MongoDB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function mongoListDatabases(connectionId: string): Promise<string[]> {
|
||||
return post("/api/mongo/list-databases", { connectionId });
|
||||
}
|
||||
|
||||
export async function mongoListCollections(connectionId: string, database: string): Promise<string[]> {
|
||||
return post("/api/mongo/list-collections", { connectionId, database });
|
||||
}
|
||||
|
||||
export async function mongoFindDocuments(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
collection: string,
|
||||
skip: number,
|
||||
limit: number,
|
||||
): Promise<MongoDocumentResult> {
|
||||
return post("/api/mongo/find-documents", { connectionId, database, collection, skip, limit });
|
||||
}
|
||||
|
||||
export async function mongoInsertDocument(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
collection: string,
|
||||
docJson: string,
|
||||
): Promise<string> {
|
||||
return post("/api/mongo/insert-document", { connectionId, database, collection, docJson });
|
||||
}
|
||||
|
||||
export async function mongoUpdateDocument(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
collection: string,
|
||||
id: string,
|
||||
docJson: string,
|
||||
): Promise<number> {
|
||||
return post("/api/mongo/update-document", { connectionId, database, collection, id, docJson });
|
||||
}
|
||||
|
||||
export async function mongoDeleteDocument(
|
||||
connectionId: string,
|
||||
database: string,
|
||||
collection: string,
|
||||
id: string,
|
||||
): Promise<number> {
|
||||
return post("/api/mongo/delete-document", { connectionId, database, collection, id });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// History
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function saveHistory(entry: HistoryEntry): Promise<void> {
|
||||
return post("/api/history/save", { entry });
|
||||
}
|
||||
|
||||
export async function loadHistory(limit: number, offset: number): Promise<HistoryEntry[]> {
|
||||
return get(`/api/history?${qs({ limit, offset })}`);
|
||||
}
|
||||
|
||||
export async function clearHistory(): Promise<void> {
|
||||
return del("/api/history");
|
||||
}
|
||||
|
||||
export async function deleteHistoryEntry(id: string): Promise<void> {
|
||||
return del(`/api/history/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function checkForUpdates(): Promise<UpdateInfo> {
|
||||
return get("/api/update/check");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function saveSidebarLayout(layout: SidebarLayout): Promise<void> {
|
||||
return post("/api/layout/sidebar", { layout });
|
||||
}
|
||||
|
||||
export async function loadSidebarLayout(): Promise<SidebarLayout | null> {
|
||||
return get("/api/layout/sidebar");
|
||||
}
|
||||
|
|
@ -17,7 +17,8 @@ import {
|
|||
type DropPosition,
|
||||
} from "@/lib/sidebarLayout";
|
||||
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
|
||||
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
|
||||
|
||||
|
|
@ -620,23 +621,55 @@ export const useConnectionStore = defineStore("connection", () => {
|
|||
}
|
||||
|
||||
async function exportConnectionsToFile(passphrase: string) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const { encryptConfig } = await import("@/lib/configCrypto");
|
||||
const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" });
|
||||
if (!path) return;
|
||||
const exportData = { connections: connections.value, layout: sidebarLayout.value };
|
||||
const json = JSON.stringify(exportData);
|
||||
const payload = await encryptConfig(json, passphrase);
|
||||
await writeTextFile(path, JSON.stringify(payload, null, 2));
|
||||
const content = JSON.stringify(payload, null, 2);
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||
const { writeTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await save({ filters: [{ name: "JSON", extensions: ["json"] }], defaultPath: "dbx-connections.json" });
|
||||
if (!path) return;
|
||||
await writeTextFile(path, content);
|
||||
} else {
|
||||
const blob = new Blob([content], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "dbx-connections.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function readImportFile(): Promise<{ content: string; encrypted: boolean } | null> {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await open({ filters: [{ name: "JSON", extensions: ["json"] }], multiple: false });
|
||||
if (!path) return null;
|
||||
const content = await readTextFile(path as string);
|
||||
let content: string;
|
||||
|
||||
if (isTauriRuntime()) {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const { readTextFile } = await import("@tauri-apps/plugin-fs");
|
||||
const path = await open({ filters: [{ name: "JSON", extensions: ["json"] }], multiple: false });
|
||||
if (!path) return null;
|
||||
content = await readTextFile(path as string);
|
||||
} else {
|
||||
content = await new Promise<string>((resolve, reject) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) { reject(new Error("No file selected")); return; }
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
const { isEncryptedConfig } = await import("@/lib/configCrypto");
|
||||
const parsed = JSON.parse(content);
|
||||
return { content, encrypted: isEncryptedConfig(parsed) };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import type { HistoryEntry } from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
import type { HistoryEntry } from "@/lib/api";
|
||||
|
||||
export const useHistoryStore = defineStore("history", () => {
|
||||
const entries = ref<HistoryEntry[]>([]);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { orderPinnedFirst } from "@/lib/pinnedItems";
|
|||
import { canCancelQueryExecution } from "@/lib/queryExecutionState";
|
||||
import { closeAllTabsState, closeOtherTabsState } from "@/lib/tabCloseActions";
|
||||
import { buildExplainSql, parseExplainResult } from "@/lib/explainPlan";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export const useQueryStore = defineStore("query", () => {
|
||||
const tabs = ref<QueryTab[]>([]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import * as api from "@/lib/tauri";
|
||||
import * as api from "@/lib/api";
|
||||
|
||||
export type AiProvider = "claude" | "openai" | "custom";
|
||||
export type AiApiStyle = "completions" | "responses";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|||
import path from "path";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const isTauri = !!host || !!process.env.TAURI_ENV_ARCH;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
|
|
@ -31,8 +32,8 @@ export default defineConfig(async () => ({
|
|||
},
|
||||
},
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
port: isTauri ? 1420 : undefined,
|
||||
strictPort: isTauri,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
|
|
@ -41,6 +42,12 @@ export default defineConfig(async () => ({
|
|||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
proxy: isTauri ? undefined : {
|
||||
"/api": {
|
||||
target: "http://localhost:4224",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue