Add update checks and improve SSH tunnel handling

Merge update checks and SSH tunnel connection handling improvements.
This commit is contained in:
skyler 2026-04-30 16:18:32 +08:00 committed by GitHub
commit da154c0dc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 373 additions and 69 deletions

View File

@ -79,7 +79,8 @@ impl AppState {
}
}
let url = db_config.connection_url();
let (host, port) = self.connection_host_port(connection_id, &db_config).await?;
let url = connection_url_for_endpoint(&db_config, &host, port);
let pool = match db_config.db_type {
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?),
DatabaseType::Postgres => PoolKind::Postgres(db::postgres::connect(&url).await?),
@ -103,18 +104,23 @@ impl AppState {
}
DatabaseType::SqlServer => {
let client = db::sqlserver::connect(
&db_config.host, db_config.port,
&db_config.username, &db_config.password,
&host,
port,
&db_config.username,
&db_config.password,
db_config.database.as_deref(),
).await?;
)
.await?;
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
}
DatabaseType::Oracle => {
let client = db::oracle_driver::connect(
&db_config.host, db_config.port,
&host,
port,
db_config.database.as_deref().unwrap_or("ORCL"),
&db_config.username, &db_config.password,
).await?;
)
.await?;
PoolKind::Oracle(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
}
};
@ -123,6 +129,36 @@ impl AppState {
Ok(pool_key)
}
async fn connection_host_port(
&self,
connection_id: &str,
config: &ConnectionConfig,
) -> Result<(String, u16), String> {
if !config.ssh_enabled || config.ssh_host.is_empty() {
return Ok((config.host.clone(), config.port));
}
if let Some(local_port) = self.tunnels.local_port(connection_id).await {
return Ok(("127.0.0.1".to_string(), local_port));
}
let local_port = self
.tunnels
.start_tunnel(
connection_id,
&config.ssh_host,
config.ssh_port,
&config.ssh_user,
&config.ssh_password,
&config.ssh_key_path,
&config.host,
config.port,
)
.await?;
Ok(("127.0.0.1".to_string(), local_port))
}
pub async fn reconnect_pool(
&self,
connection_id: &str,
@ -153,6 +189,26 @@ fn connections_file(app: &AppHandle) -> Result<std::path::PathBuf, String> {
Ok(dir.join("connections.json"))
}
fn connection_url_for_endpoint(config: &ConnectionConfig, host: &str, port: u16) -> String {
if host == config.host && port == config.port {
config.connection_url()
} else {
config.connection_url_with_host(host, port)
}
}
fn redacted_connection_url_for_endpoint(
config: &ConnectionConfig,
host: &str,
port: u16,
) -> String {
if host == config.host && port == config.port {
config.redacted_connection_url()
} else {
config.redacted_connection_url_with_host(host, port)
}
}
#[tauri::command]
pub async fn save_connections(
app: AppHandle,
@ -177,64 +233,95 @@ pub async fn load_connections(app: AppHandle) -> Result<Vec<ConnectionConfig>, S
}
#[tauri::command]
pub async fn test_connection(config: ConnectionConfig) -> Result<String, String> {
let url = config.connection_url();
pub async fn test_connection(
state: State<'_, Arc<AppState>>,
config: ConnectionConfig,
) -> Result<String, String> {
let tunnel_id = format!("{}:test", config.id);
let connection_id = if config.ssh_enabled && !config.ssh_host.is_empty() {
tunnel_id.as_str()
} else {
config.id.as_str()
};
let (host, port) = state.connection_host_port(connection_id, &config).await?;
let url = connection_url_for_endpoint(&config, &host, port);
let target = redacted_connection_url_for_endpoint(&config, &host, port);
log::info!(
"[test_connection] db_type={:?} target={}",
config.db_type,
config.redacted_connection_url()
target
);
match config.db_type {
DatabaseType::Mysql => {
let pool = db::mysql::connect(&url).await?;
pool.close().await;
Ok("Connection successful".to_string())
}
DatabaseType::Postgres => {
let pool = db::postgres::connect(&url).await?;
pool.close().await;
Ok("Connection successful".to_string())
}
DatabaseType::Sqlite => {
let pool = db::sqlite::connect(&url).await?;
pool.close().await;
Ok("Connection successful".to_string())
}
let result = match config.db_type {
DatabaseType::Mysql => match db::mysql::connect(&url).await {
Ok(pool) => {
pool.close().await;
Ok("Connection successful".to_string())
}
Err(e) => Err(e),
},
DatabaseType::Postgres => match db::postgres::connect(&url).await {
Ok(pool) => {
pool.close().await;
Ok("Connection successful".to_string())
}
Err(e) => Err(e),
},
DatabaseType::Sqlite => match db::sqlite::connect(&url).await {
Ok(pool) => {
pool.close().await;
Ok("Connection successful".to_string())
}
Err(e) => Err(e),
},
DatabaseType::Redis => {
let _con = db::redis_driver::connect(&url).await?;
Ok("Connection successful".to_string())
db::redis_driver::connect(&url)
.await
.map(|_| "Connection successful".to_string())
}
DatabaseType::DuckDb => {
let _con = duckdb::Connection::open(&config.host).map_err(|e| e.to_string())?;
Ok("Connection successful".to_string())
}
DatabaseType::MongoDb => {
let client = mongodb::Client::with_uri_str(&url).await.map_err(|e| e.to_string())?;
client.list_database_names().await.map_err(|e| e.to_string())?;
Ok("Connection successful".to_string())
duckdb::Connection::open(&config.host)
.map(|_| "Connection successful".to_string())
.map_err(|e| e.to_string())
}
DatabaseType::MongoDb => match mongodb::Client::with_uri_str(&url).await {
Ok(client) => client
.list_database_names()
.await
.map(|_| "Connection successful".to_string())
.map_err(|e| e.to_string()),
Err(e) => Err(e.to_string()),
},
DatabaseType::ClickHouse => {
let client = db::clickhouse_driver::ChClient::new(&url);
db::clickhouse_driver::test_connection(&client).await?;
Ok("Connection successful".to_string())
}
DatabaseType::SqlServer => {
let _client = db::sqlserver::connect(
&config.host, config.port,
&config.username, &config.password,
config.database.as_deref(),
).await?;
Ok("Connection successful".to_string())
}
DatabaseType::Oracle => {
let _client = db::oracle_driver::connect(
&config.host, config.port,
config.database.as_deref().unwrap_or("ORCL"),
&config.username, &config.password,
).await?;
Ok("Connection successful".to_string())
db::clickhouse_driver::test_connection(&client)
.await
.map(|_| "Connection successful".to_string())
}
DatabaseType::SqlServer => db::sqlserver::connect(
&host,
port,
&config.username,
&config.password,
config.database.as_deref(),
)
.await
.map(|_| "Connection successful".to_string()),
DatabaseType::Oracle => db::oracle_driver::connect(
&host,
port,
config.database.as_deref().unwrap_or("ORCL"),
&config.username,
&config.password,
)
.await
.map(|_| "Connection successful".to_string()),
};
if config.ssh_enabled && !config.ssh_host.is_empty() {
state.tunnels.stop_tunnel(&tunnel_id).await;
}
result
}
#[tauri::command]
@ -244,16 +331,8 @@ pub async fn connect_db(
) -> Result<String, String> {
let id = config.id.clone();
let url = if config.ssh_enabled && !config.ssh_host.is_empty() {
let local_port = state.tunnels.start_tunnel(
&id, &config.ssh_host, config.ssh_port,
&config.ssh_user, &config.ssh_password, &config.ssh_key_path,
&config.host, config.port,
).await?;
config.connection_url_with_host("127.0.0.1", local_port)
} else {
config.connection_url()
};
let (host, port) = state.connection_host_port(&id, &config).await?;
let url = connection_url_for_endpoint(&config, &host, port);
let pool = match config.db_type {
DatabaseType::Mysql => PoolKind::Mysql(db::mysql::connect(&url).await?),
@ -278,17 +357,23 @@ pub async fn connect_db(
}
DatabaseType::SqlServer => {
let client = db::sqlserver::connect(
&config.host, config.port,
&host,
port,
&config.username, &config.password,
config.database.as_deref(),
).await?;
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client))) }
)
.await?;
PoolKind::SqlServer(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
}
DatabaseType::Oracle => {
let client = db::oracle_driver::connect(
&config.host, config.port,
&host,
port,
config.database.as_deref().unwrap_or("ORCL"),
&config.username, &config.password,
).await?;
&config.username,
&config.password,
)
.await?;
PoolKind::Oracle(std::sync::Arc::new(tokio::sync::Mutex::new(client)))
}
};

View File

@ -5,3 +5,4 @@ pub mod mongo_cmd;
pub mod query;
pub mod redis_cmd;
pub mod schema;
pub mod update;

View File

@ -0,0 +1,98 @@
use serde::{Deserialize, Serialize};
const LATEST_RELEASE_URL: &str = "https://api.github.com/repos/t8y2/dbx/releases/latest";
#[derive(Debug, Deserialize)]
struct GithubRelease {
tag_name: String,
name: Option<String>,
html_url: String,
body: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub update_available: bool,
pub release_name: String,
pub release_url: String,
pub release_notes: String,
}
#[tauri::command]
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
let client = reqwest::Client::new();
let release = client
.get(LATEST_RELEASE_URL)
.header(reqwest::header::USER_AGENT, "dbx-update-checker")
.send()
.await
.map_err(|e| format!("Failed to check updates: {e}"))?
.error_for_status()
.map_err(|e| format!("Failed to check updates: {e}"))?
.json::<GithubRelease>()
.await
.map_err(|e| format!("Failed to parse update response: {e}"))?;
let current_version = env!("CARGO_PKG_VERSION").to_string();
let latest_version = normalize_version(&release.tag_name);
Ok(UpdateInfo {
update_available: is_newer_version(&latest_version, &current_version),
current_version,
latest_version,
release_name: release.name.unwrap_or_else(|| release.tag_name.clone()),
release_url: release.html_url,
release_notes: release.body.unwrap_or_default(),
})
}
fn normalize_version(version: &str) -> String {
version.trim().trim_start_matches('v').to_string()
}
fn parse_version(version: &str) -> Vec<u64> {
normalize_version(version)
.split(['.', '-', '+'])
.map(|part| part.parse::<u64>().unwrap_or(0))
.collect()
}
fn is_newer_version(latest: &str, current: &str) -> bool {
let latest_parts = parse_version(latest);
let current_parts = parse_version(current);
let max_len = latest_parts.len().max(current_parts.len());
for i in 0..max_len {
let latest_part = *latest_parts.get(i).unwrap_or(&0);
let current_part = *current_parts.get(i).unwrap_or(&0);
if latest_part > current_part {
return true;
}
if latest_part < current_part {
return false;
}
}
false
}
#[cfg(test)]
mod tests {
use super::{is_newer_version, normalize_version};
#[test]
fn normalizes_tag_versions() {
assert_eq!(normalize_version("v1.2.3"), "1.2.3");
assert_eq!(normalize_version(" 0.2.0 "), "0.2.0");
}
#[test]
fn compares_semver_like_versions() {
assert!(is_newer_version("0.2.1", "0.2.0"));
assert!(is_newer_version("1.0.0", "0.9.9"));
assert!(!is_newer_version("0.2.0", "0.2.0"));
assert!(!is_newer_version("0.1.9", "0.2.0"));
}
}

View File

@ -79,6 +79,14 @@ impl TunnelManager {
Ok(local_port)
}
pub async fn local_port(&self, connection_id: &str) -> Option<u16> {
self.tunnels
.lock()
.await
.get(connection_id)
.map(|(_, port)| *port)
}
pub async fn stop_tunnel(&self, connection_id: &str) {
if let Some((mut child, _)) = self.tunnels.lock().await.remove(connection_id) {
let _ = child.kill().await;

View File

@ -75,6 +75,7 @@ pub fn run() {
commands::history::load_history,
commands::history::clear_history,
commands::history::delete_history_entry,
commands::update::check_for_updates,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { useI18n } from "vue-i18n";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin, AlignLeft } from "lucide-vue-next";
import { DatabaseZap, FilePlus2, Play, Loader2, X, Globe, Moon, Sun, Upload, Download, Plus, History, Server, Table2, Database, Search, ShieldCheck, Sparkles, Pin, AlignLeft, CloudDownload } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -12,6 +12,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import ConnectionDialog from "@/components/connection/ConnectionDialog.vue";
@ -45,6 +48,7 @@ const { message: toastMessage, visible: toastVisible, toast } = useToast();
const showConnectionDialog = ref(false);
const showHistory = ref(false);
const showUpdateDialog = ref(false);
const dangerSql = ref("");
const pendingDangerSql = ref("");
const selectedSql = ref("");
@ -52,6 +56,10 @@ const formatSqlRequestId = ref(0);
const showDangerDialog = ref(false);
const databaseOptions = ref<Record<string, string[]>>({});
const loadingDatabaseOptions = ref<Record<string, boolean>>({});
const checkingUpdates = ref(false);
const updateInfo = ref<api.UpdateInfo | null>(null);
const updateCheckMessage = ref("");
const latestReleaseUrl = "https://github.com/t8y2/dbx/releases/latest";
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
@ -445,6 +453,42 @@ function openGitHub() {
open("https://github.com/t8y2/dbx");
}
async function checkUpdates(options: { silent?: boolean } = {}) {
if (checkingUpdates.value) return;
checkingUpdates.value = true;
updateCheckMessage.value = "";
try {
const info = await api.checkForUpdates();
updateInfo.value = info;
if (info.update_available) {
showUpdateDialog.value = true;
} else if (!options.silent) {
updateCheckMessage.value = t("updates.upToDate", { version: info.current_version });
showUpdateDialog.value = true;
}
} catch (e: any) {
if (!options.silent) {
updateCheckMessage.value = formatUpdateError(String(e));
showUpdateDialog.value = true;
}
} finally {
checkingUpdates.value = false;
}
}
function formatUpdateError(message: string): string {
const lower = message.toLowerCase();
if (lower.includes("403") || lower.includes("rate limit")) {
return t("updates.rateLimited");
}
return t("updates.failed", { error: message });
}
function openLatestRelease() {
const url = updateInfo.value?.release_url || latestReleaseUrl;
open(url);
}
function handleKeydown(e: KeyboardEvent) {
if (e.metaKey && e.key === "w") {
e.preventDefault();
@ -460,6 +504,7 @@ onMounted(() => {
settingsStore.initAiConfig();
window.addEventListener("keydown", handleKeydown);
setupFileDrop();
checkUpdates({ silent: true });
});
onUnmounted(() => {
@ -559,6 +604,16 @@ async function setupFileDrop() {
<div class="flex-1" />
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="checkingUpdates" @click="checkUpdates()">
<Loader2 v-if="checkingUpdates" class="h-4 w-4 animate-spin" />
<CloudDownload v-else class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t('updates.check') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-7 w-7" :class="{ 'bg-accent': showHistory }" @click="showHistory = !showHistory">
@ -900,6 +955,28 @@ async function setupFileDrop() {
<ConnectionDialog v-model:open="showConnectionDialog" :edit-config="editConfig" />
<DangerConfirmDialog v-model:open="showDangerDialog" :sql="dangerSql" @confirm="onDangerConfirm" />
<Dialog v-model:open="showUpdateDialog">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>{{ updateInfo?.update_available ? t('updates.availableTitle') : t('updates.title') }}</DialogTitle>
</DialogHeader>
<div class="space-y-3 text-sm">
<p v-if="updateInfo?.update_available">
{{ t('updates.availableMessage', { current: updateInfo.current_version, latest: updateInfo.latest_version }) }}
</p>
<p v-else class="text-muted-foreground">
{{ updateCheckMessage || t('updates.upToDate', { version: updateInfo?.current_version || '' }) }}
</p>
<div v-if="updateInfo?.update_available && updateInfo.release_notes" class="max-h-48 overflow-auto rounded-md border bg-muted/30 p-3 text-xs whitespace-pre-wrap">
{{ updateInfo.release_notes }}
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showUpdateDialog = false">{{ t('dangerDialog.cancel') }}</Button>
<Button v-if="updateInfo?.update_available || updateCheckMessage" @click="openLatestRelease">{{ t('updates.openRelease') }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Global Toast -->
<Transition name="toast">

View File

@ -10,6 +10,16 @@ export default {
formatSql: "Format SQL",
formatSqlFailed: "Failed to format SQL",
},
updates: {
title: "Updates",
check: "Check for updates",
availableTitle: "Update available",
availableMessage: "DBX {latest} is available. You are using {current}.",
upToDate: "DBX is up to date ({version}).",
failed: "Failed to check updates: {error}",
rateLimited: "GitHub update checks are temporarily rate limited. You can still open the release page to check manually.",
openRelease: "Open Release",
},
sidebar: {
connections: "CONNECTIONS",
noConnections: "No connections yet",

View File

@ -10,6 +10,16 @@ export default {
formatSql: "格式化 SQL",
formatSqlFailed: "SQL 格式化失败",
},
updates: {
title: "更新",
check: "检查更新",
availableTitle: "发现新版本",
availableMessage: "DBX {latest} 已发布,当前版本为 {current}。",
upToDate: "DBX 已是最新版本 ({version})。",
failed: "检查更新失败:{error}",
rateLimited: "GitHub 更新检查暂时触发频率限制。你仍然可以打开下载页手动查看。",
openRelease: "打开下载页",
},
sidebar: {
connections: "连接",
noConnections: "暂无连接",

View File

@ -101,6 +101,20 @@ export async function loadConnections(): Promise<ConnectionConfig[]> {
return invoke("load_connections");
}
// --- Updates ---
export interface UpdateInfo {
current_version: string;
latest_version: string;
update_available: boolean;
release_name: string;
release_url: string;
release_notes: string;
}
export async function checkForUpdates(): Promise<UpdateInfo> {
return invoke("check_for_updates");
}
// --- Redis ---
export interface RedisKeyInfo {
key: string;