feat(elasticsearch): support Kibana proxy connections

This commit is contained in:
zipg 2026-07-20 00:53:49 +08:00 committed by GitHub
parent 0d2b6685d3
commit 834ad772c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 362 additions and 25 deletions

View File

@ -88,6 +88,7 @@ import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/
import { translateBackendError } from "@/i18n/backend-errors";
import { applyHiveKerberosSubmitConfig, hiveKerberosFormConfig, type HiveKerberosAuthMode } from "@/lib/database/hiveKerberosOptions";
import { hasCloudflareD1Credentials, isCloudflareD1Connection, normalizeCloudflareD1Connection } from "@/lib/connection/cloudflareD1";
import { buildElasticsearchExternalConfig, elasticsearchConnectionModeFromConfig, elasticsearchKibanaBasePathFromConfig, type ElasticsearchConnectionMode } from "@/lib/connection/elasticsearchKibanaProxy";
type DbOption = { value: string; label: string };
type DbCategory = { key: string; title: string; options: DbOption[] };
@ -251,6 +252,31 @@ const defaultForm = (): ConnectionForm => ({
visible_databases: undefined,
});
const elasticsearchConnectionMode = ref<ElasticsearchConnectionMode>("direct");
const elasticsearchKibanaBasePath = ref("");
const elasticsearchConnectionPorts = ref<Record<ElasticsearchConnectionMode, number>>({
direct: 9200,
kibana: 5601,
});
function resetElasticsearchProxyFields(externalConfig?: unknown) {
const mode = elasticsearchConnectionModeFromConfig(externalConfig);
elasticsearchConnectionMode.value = mode;
elasticsearchKibanaBasePath.value = elasticsearchKibanaBasePathFromConfig(externalConfig);
elasticsearchConnectionPorts.value = {
direct: mode === "direct" ? form.value.port : 9200,
kibana: mode === "kibana" ? form.value.port : 5601,
};
}
function switchElasticsearchConnectionMode(mode: ElasticsearchConnectionMode) {
if (mode === elasticsearchConnectionMode.value) return;
elasticsearchConnectionPorts.value[elasticsearchConnectionMode.value] = form.value.port;
form.value.port = elasticsearchConnectionPorts.value[mode];
elasticsearchConnectionMode.value = mode;
resetTestState();
}
function defaultSshTunnel(): SshTunnelConfig {
return {
id: uuid(),
@ -1481,6 +1507,7 @@ function applyProfile(val: string, preserveConnectionFields = false) {
const profile = driverProfiles[val];
if (!profile) return;
const previousDatabaseType = form.value.db_type;
selectedType.value = val;
form.value.db_type = profile.type;
form.value.driver_profile = val;
@ -1488,6 +1515,9 @@ function applyProfile(val: string, preserveConnectionFields = false) {
if (profile.type !== "sqlserver") {
form.value.external_config = undefined;
}
if (profile.type !== "elasticsearch" || previousDatabaseType !== "elasticsearch") {
resetElasticsearchProxyFields();
}
if (!preserveConnectionFields) {
form.value.port = profile.port;
@ -1652,6 +1682,7 @@ watch(
} else {
resetInfluxDbFields();
}
resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined);
resetHiveKerberosFields(config.db_type === "hive" ? config : undefined);
h2ConnectionMode.value = h2ConnectionModeForConfig(config);
customColorInput.value = config.color || "";
@ -1688,6 +1719,7 @@ watch(
resetMqFields();
resetNacosFields();
resetInfluxDbFields();
resetElasticsearchProxyFields();
resetHiveKerberosFields();
oceanbaseSubMode.value = "mysql";
h2ConnectionMode.value = "file";
@ -2657,6 +2689,8 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.password = config.password.trim();
config.database = config.database?.trim() || undefined;
}
} else if (config.db_type === "elasticsearch") {
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value);
} else if (config.db_type === "sqlserver") {
config.external_config = sqlServerPortExplicitFromConfig(config) ? { portExplicit: true } : undefined;
} else {
@ -5086,12 +5120,41 @@ function openExternalUrl(url: string) {
<!-- MySQL / PostgreSQL: host, port, user, password, database -->
<template v-else>
<div v-if="form.db_type === 'elasticsearch'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.mode") }}</Label>
<div class="col-span-3 grid h-8 grid-cols-2 overflow-hidden rounded-md border border-input bg-muted/30 p-0.5">
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="elasticsearchConnectionMode === 'direct' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="elasticsearchConnectionMode === 'direct'"
@click="switchElasticsearchConnectionMode('direct')"
>
{{ t("connection.elasticsearchDirectMode") }}
</button>
<button
type="button"
class="h-7 rounded-sm px-3 text-sm transition-colors"
:class="elasticsearchConnectionMode === 'kibana' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
:aria-pressed="elasticsearchConnectionMode === 'kibana'"
@click="switchElasticsearchConnectionMode('kibana')"
>
{{ t("connection.elasticsearchKibanaProxyMode") }}
</button>
</div>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Label :class="connectionLabelClass">{{ form.db_type === "elasticsearch" && elasticsearchConnectionMode === "kibana" ? t("connection.elasticsearchKibanaHost") : t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" @input="markSqlServerPortExplicit" />
</div>
<div v-if="form.db_type === 'elasticsearch' && elasticsearchConnectionMode === 'kibana'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.elasticsearchKibanaBasePath") }}</Label>
<Input v-model="elasticsearchKibanaBasePath" class="col-span-3" placeholder="/kibana/s/default" @input="resetTestState" />
</div>
<div v-if="form.driver_profile === 'gbase8s'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.gbaseServer") }}</Label>
<Input v-model="form.gbase_server" class="col-span-3" placeholder="gbase01" />

View File

@ -200,6 +200,10 @@ export default {
mongoDriverLegacy: "Legacy",
serviceName: "Service/SID",
serviceNameOnly: "Service Name",
elasticsearchDirectMode: "Direct",
elasticsearchKibanaProxyMode: "Kibana Proxy",
elasticsearchKibanaHost: "Kibana Host",
elasticsearchKibanaBasePath: "Base Path",
version: "Version",
driverInstallHintPrefix: "Install the required driver from ",
driverInstallHintSuffix: " in the top toolbar before connecting.",

View File

@ -524,6 +524,10 @@ export default withEnglishFallback({
mixed: "Mixto",
},
},
elasticsearchDirectMode: "Conexión directa",
elasticsearchKibanaProxyMode: "proxy de Kibana",
elasticsearchKibanaHost: "host de Kibana",
elasticsearchKibanaBasePath: "ruta base",
},
editor: {
pressToExecute: "Presiona {mod}+Enter para ejecutar",

View File

@ -522,6 +522,10 @@ export default withEnglishFallback({
mixed: "Misto",
},
},
elasticsearchDirectMode: "Connessione diretta",
elasticsearchKibanaProxyMode: "Proxy Kibana",
elasticsearchKibanaHost: "Host Kibana",
elasticsearchKibanaBasePath: "Percorso base",
},
editor: {
pressToExecute: "Premi {mod}+Enter per eseguire",

View File

@ -522,6 +522,10 @@ export default withEnglishFallback({
mixed: "大文字小文字混在",
},
},
elasticsearchDirectMode: "直接接続",
elasticsearchKibanaProxyMode: "Kibana プロキシ",
elasticsearchKibanaHost: "Kibana ホスト",
elasticsearchKibanaBasePath: "ベースパス",
},
editor: {
pressToExecute: "{mod}+Enter で実行",

View File

@ -523,6 +523,10 @@ export default withEnglishFallback({
mixed: "Maiúsculas e minúsculas",
},
},
elasticsearchDirectMode: "Conexão Direta",
elasticsearchKibanaProxyMode: "Proxy do Kibana",
elasticsearchKibanaHost: "Host do Kibana",
elasticsearchKibanaBasePath: "Caminho Base",
},
editor: {
pressToExecute: "Pressione {mod}+Enter para executar",

View File

@ -202,6 +202,10 @@ export default withEnglishFallback({
mongoDriverLegacy: "旧版兼容",
serviceName: "服务名/SID",
serviceNameOnly: "服务名",
elasticsearchDirectMode: "直连",
elasticsearchKibanaProxyMode: "Kibana 代理",
elasticsearchKibanaHost: "Kibana 主机",
elasticsearchKibanaBasePath: "基础路径",
version: "版本",
driverInstallHintPrefix: "需要在顶部导航栏「",
driverInstallHintSuffix: "」中安装对应的驱动才能连接。",

View File

@ -523,6 +523,10 @@ export default withEnglishFallback({
mixed: "混合大小寫",
},
},
elasticsearchDirectMode: "直接連線",
elasticsearchKibanaProxyMode: "Kibana 代理",
elasticsearchKibanaHost: "Kibana 主機",
elasticsearchKibanaBasePath: "基礎路徑",
},
editor: {
pressToExecute: "按 {mod}+Enter 執行查詢",

View File

@ -0,0 +1,33 @@
export type ElasticsearchConnectionMode = "direct" | "kibana";
export interface ElasticsearchExternalConfig {
mode: "kibana";
kibanaBasePath?: string;
}
function externalConfigRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
}
export function normalizeKibanaBasePath(value: string): string {
const path = value.trim().replace(/^\/+|\/+$/g, "");
return path ? `/${path}` : "";
}
export function elasticsearchConnectionModeFromConfig(value: unknown): ElasticsearchConnectionMode {
const config = externalConfigRecord(value);
return config.mode === "kibana" ? "kibana" : "direct";
}
export function elasticsearchKibanaBasePathFromConfig(value: unknown): string {
if (elasticsearchConnectionModeFromConfig(value) !== "kibana") return "";
const config = externalConfigRecord(value);
const path = config.kibanaBasePath;
return typeof path === "string" ? normalizeKibanaBasePath(path) : "";
}
export function buildElasticsearchExternalConfig(mode: ElasticsearchConnectionMode, kibanaBasePath: string): ElasticsearchExternalConfig | undefined {
if (mode !== "kibana") return undefined;
const normalizedPath = normalizeKibanaBasePath(kibanaBasePath);
return normalizedPath ? { mode: "kibana", kibanaBasePath: normalizedPath } : { mode: "kibana" };
}

View File

@ -1312,6 +1312,7 @@ impl AppState {
Some(&db_config.password),
db_config.ssl,
db_config.url_params.as_deref(),
db_config.external_config.as_ref(),
connect_timeout,
);
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;

View File

@ -1,5 +1,5 @@
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use reqwest::{Client as HttpClient, Method};
use reqwest::{Client as HttpClient, Method, StatusCode};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashSet;
@ -30,11 +30,20 @@ const ELASTICSEARCH_PATH_SEGMENT_ENCODE_SET: &AsciiSet = &CONTROLS
const ELASTICSEARCH_QUERY_VALUE_ENCODE_SET: &AsciiSet =
&CONTROLS.add(b' ').add(b'"').add(b'#').add(b'%').add(b'&').add(b'+').add(b'/').add(b'=').add(b'?');
const KIBANA_PROXY_STATUS_HEADER: &str = "x-console-proxy-status-code";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ElasticsearchTransportMode {
Direct,
KibanaProxy,
}
pub struct EsClient {
http: HttpClient,
base_url: String,
fallback_base_urls: Vec<String>,
auth: Option<(String, String)>,
transport_mode: ElasticsearchTransportMode,
}
impl EsClient {
@ -44,6 +53,17 @@ impl EsClient {
password: Option<&str>,
accept_invalid_certs: bool,
timeout: Duration,
) -> Self {
Self::new_with_mode(url, username, password, accept_invalid_certs, timeout, ElasticsearchTransportMode::Direct)
}
fn new_with_mode(
url: &str,
username: Option<&str>,
password: Option<&str>,
accept_invalid_certs: bool,
timeout: Duration,
transport_mode: ElasticsearchTransportMode,
) -> Self {
let base_url = url.trim_end_matches('/').to_string();
let auth = match (username, password) {
@ -53,7 +73,7 @@ impl EsClient {
let builder = http_client_builder(timeout).danger_accept_invalid_certs(accept_invalid_certs);
let http = builder.build().unwrap_or_else(|_| HttpClient::new());
let fallback_base_urls = elasticsearch_base_url_fallbacks(&base_url);
Self { http, base_url, fallback_base_urls, auth }
Self { http, base_url, fallback_base_urls, auth, transport_mode }
}
pub fn from_config(
@ -62,33 +82,51 @@ impl EsClient {
password: Option<&str>,
tls_enabled: bool,
url_params: Option<&str>,
external_config: Option<&Value>,
timeout: Duration,
) -> Self {
Self::new(url, username, password, elasticsearch_accept_invalid_certs(tls_enabled, url_params), timeout)
let kibana_base_path = elasticsearch_kibana_base_path(external_config);
let transport_mode = if kibana_base_path.is_some() {
ElasticsearchTransportMode::KibanaProxy
} else {
ElasticsearchTransportMode::Direct
};
let base_url = format!("{}{}", url.trim_end_matches('/'), kibana_base_path.as_deref().unwrap_or(""));
Self::new_with_mode(
&base_url,
username,
password,
elasticsearch_accept_invalid_certs(tls_enabled, url_params),
timeout,
transport_mode,
)
}
fn get(&self, path: &str) -> reqwest::RequestBuilder {
let req = self.http.get(format!("{}{}", self.base_url, path));
self.with_auth(req)
self.request(Method::GET, path)
}
fn post(&self, path: &str) -> reqwest::RequestBuilder {
let req = self.http.post(format!("{}{}", self.base_url, path));
self.with_auth(req)
self.request(Method::POST, path)
}
fn put(&self, path: &str) -> reqwest::RequestBuilder {
let req = self.http.put(format!("{}{}", self.base_url, path));
self.with_auth(req)
self.request(Method::PUT, path)
}
fn delete(&self, path: &str) -> reqwest::RequestBuilder {
let req = self.http.delete(format!("{}{}", self.base_url, path));
self.with_auth(req)
self.request(Method::DELETE, path)
}
fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
let req = self.http.request(method, format!("{}{}", self.base_url, path));
let req = match self.transport_mode {
ElasticsearchTransportMode::Direct => self.http.request(method, format!("{}{}", self.base_url, path)),
ElasticsearchTransportMode::KibanaProxy => self
.http
.post(format!("{}/api/console/proxy", self.base_url))
.query(&[("path", path), ("method", method.as_str())])
.header("kbn-xsrf", "true"),
};
self.with_auth(req)
}
@ -99,6 +137,21 @@ impl EsClient {
req
}
}
fn response_status(&self, response: &reqwest::Response) -> StatusCode {
if self.transport_mode == ElasticsearchTransportMode::KibanaProxy {
if let Some(status) = response
.headers()
.get(KIBANA_PROXY_STATUS_HEADER)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u16>().ok())
.and_then(|value| StatusCode::from_u16(value).ok())
{
return status;
}
}
response.status()
}
}
impl Clone for EsClient {
@ -108,10 +161,22 @@ impl Clone for EsClient {
base_url: self.base_url.clone(),
fallback_base_urls: self.fallback_base_urls.clone(),
auth: self.auth.clone(),
transport_mode: self.transport_mode,
}
}
}
fn elasticsearch_kibana_base_path(external_config: Option<&Value>) -> Option<String> {
let config = external_config?.as_object()?;
let mode = config.get("mode").and_then(Value::as_str)?;
if !mode.eq_ignore_ascii_case("kibana") {
return None;
}
let base_path = config.get("kibanaBasePath").and_then(Value::as_str).unwrap_or("").trim().trim_matches('/');
Some(if base_path.is_empty() { String::new() } else { format!("/{base_path}") })
}
pub async fn test_connection(client: &mut EsClient, timeout: Duration) -> Result<(), String> {
let mut errors = Vec::new();
let urls = std::iter::once(client.base_url.clone()).chain(client.fallback_base_urls.clone());
@ -137,8 +202,8 @@ pub async fn test_connection(client: &mut EsClient, timeout: Duration) -> Result
}
};
if !resp.status().is_success() {
let status = resp.status();
let status = client.response_status(&resp);
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error ({status}): {body}"));
}
@ -257,7 +322,7 @@ pub async fn list_indices(client: &EsClient) -> Result<Vec<String>, String> {
.send()
.await
.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !resp.status().is_success() {
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
@ -270,7 +335,7 @@ pub async fn list_indices(client: &EsClient) -> Result<Vec<String>, String> {
pub async fn get_columns(client: &EsClient, index: &str) -> Result<Vec<crate::db::ColumnInfo>, String> {
let path = elasticsearch_index_path(index, "_mapping");
let resp = client.get(&path).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !resp.status().is_success() {
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
@ -416,7 +481,7 @@ pub async fn find_documents(
let path = elasticsearch_index_path(index, "_search");
let resp = client.post(&path).json(&body).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !resp.status().is_success() {
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
@ -693,7 +758,7 @@ pub async fn insert_document(
let path = elasticsearch_auto_id_document_path(index, routing.as_deref());
let resp = client.post(&path).json(&doc).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !resp.status().is_success() {
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
@ -714,7 +779,7 @@ pub async fn update_document(
let path = elasticsearch_document_path(index, id, routing.as_deref());
let resp = client.put(&path).json(&doc).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !resp.status().is_success() {
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
@ -752,7 +817,7 @@ pub async fn delete_document(client: &EsClient, index: &str, id: &str, routing:
let path = elasticsearch_document_path(index, id, routing);
let resp = client.delete(&path).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
if !resp.status().is_success() {
if !client.response_status(&resp).is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(format!("Elasticsearch error: {body}"));
}
@ -949,7 +1014,7 @@ pub async fn execute_rest_query(client: &EsClient, input: &str) -> Result<crate:
}
let resp = builder.send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
let status = resp.status().as_u16();
let status = client.response_status(&resp).as_u16();
let body = resp.text().await.map_err(|e| format!("Elasticsearch response read failed: {e}"))?;
parse_elasticsearch_rest_response(status, &body, start)
@ -981,7 +1046,7 @@ async fn execute_search_query(
let path = elasticsearch_index_path(&query.index, "_search");
let resp =
client.post(&path).json(&query.body).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
let status = resp.status().as_u16();
let status = client.response_status(&resp).as_u16();
let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::Value::Null);
// Capture the index's true match total before the body is consumed by the
// parser — needed below when we report total instead of rows.len().
@ -1258,7 +1323,7 @@ async fn execute_translated_select_star(
.send()
.await
.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
let status = resp.status().as_u16();
let status = client.response_status(&resp).as_u16();
let body: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::Value::Null);
let index_total = body.pointer("/hits/total/value").and_then(|v| v.as_u64());
@ -1280,7 +1345,7 @@ async fn execute_sql_query(
let body = serde_json::json!({ "query": query });
let resp =
client.post("/_sql").json(&body).send().await.map_err(|e| format!("Elasticsearch request failed: {e}"))?;
let status = resp.status();
let status = client.response_status(&resp);
let response_body: serde_json::Value = resp.json().await.unwrap_or_else(|_| serde_json::Value::Null);
if !status.is_success() {
@ -1783,6 +1848,7 @@ mod tests {
Some("secret"),
false,
Some("sslmode=disable"),
None,
Duration::from_secs(1),
);
@ -1790,6 +1856,24 @@ mod tests {
assert_eq!(client.fallback_base_urls, vec!["https://127.0.0.1:9200"]);
}
#[test]
fn elasticsearch_client_from_config_enables_kibana_proxy_with_base_path() {
let external_config = json!({ "mode": "kibana", "kibanaBasePath": "/kibana/s/analytics/" });
let client = EsClient::from_config(
"https://localhost:5601/",
Some("elastic"),
Some("secret"),
false,
None,
Some(&external_config),
Duration::from_secs(1),
);
assert_eq!(client.base_url, "https://localhost:5601/kibana/s/analytics");
assert_eq!(client.fallback_base_urls, vec!["https://127.0.0.1:5601/kibana/s/analytics"]);
assert_eq!(client.transport_mode, super::ElasticsearchTransportMode::KibanaProxy);
}
#[test]
fn redacts_elasticsearch_url_credentials_in_errors() {
assert_eq!(
@ -2219,6 +2303,57 @@ mod tests {
assert_eq!(result.rows[0][1], json!("null"));
}
#[tokio::test]
async fn kibana_proxy_rewrites_method_path_and_preserves_elasticsearch_status() {
use std::collections::HashMap;
use tokio::io::AsyncWriteExt;
let response_body = r#"{"error":{"type":"index_not_found_exception"},"status":404}"#;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
let (headers, body) = request.split_once("\r\n\r\n").unwrap();
let request_target = headers.lines().next().unwrap().split_whitespace().nth(1).unwrap();
let url = reqwest::Url::parse(&format!("http://localhost{request_target}")).unwrap();
let query = url.query_pairs().into_owned().collect::<HashMap<_, _>>();
assert_eq!(url.path(), "/kibana/s/analytics/api/console/proxy");
assert_eq!(query.get("path").map(String::as_str), Some("/missing/_doc/1?refresh=true"));
assert_eq!(query.get("method").map(String::as_str), Some("DELETE"));
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("kbn-xsrf: true")), "{headers}");
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("authorization: Basic ZWxhc3RpYzpzZWNyZXQ=")));
assert_eq!(serde_json::from_str::<serde_json::Value>(body).unwrap(), json!({ "reason": "cleanup" }));
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nx-console-proxy-status-code: 404\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response_body.len(),
response_body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
let external_config = json!({ "mode": "kibana", "kibanaBasePath": "/kibana/s/analytics" });
let client = EsClient::from_config(
&format!("http://{addr}"),
Some("elastic"),
Some("secret"),
false,
None,
Some(&external_config),
Duration::from_secs(1),
);
let result =
super::execute_rest_query(&client, "DELETE /missing/_doc/1?refresh=true\n{\"reason\":\"cleanup\"}")
.await
.unwrap();
server.await.unwrap();
assert_eq!(result.rows[0][0], json!(404));
assert_eq!(result.rows[0][1], json!(response_body));
}
#[tokio::test]
async fn execute_rest_query_sends_encoded_date_math_path() {
use tokio::io::AsyncWriteExt;
@ -2419,6 +2554,58 @@ mod tests {
assert_eq!(serde_json::from_str::<serde_json::Value>(response).unwrap(), body);
}
#[tokio::test]
#[ignore = "requires DBX_TEST_KIBANA_URL pointing to a reachable Kibana instance"]
async fn live_kibana_proxy_supports_metadata_queries_and_document_writes() {
let url = std::env::var("DBX_TEST_KIBANA_URL").expect("DBX_TEST_KIBANA_URL is required");
let username = std::env::var("DBX_TEST_KIBANA_USERNAME").ok();
let password = std::env::var("DBX_TEST_KIBANA_PASSWORD").ok();
let base_path = std::env::var("DBX_TEST_KIBANA_BASE_PATH").unwrap_or_default();
let external_config = json!({ "mode": "kibana", "kibanaBasePath": base_path });
let mut client = EsClient::from_config(
&url,
username.as_deref(),
password.as_deref(),
false,
None,
Some(&external_config),
Duration::from_secs(20),
);
super::test_connection(&mut client, Duration::from_secs(20)).await.unwrap();
let index = format!("dbx-kibana-proxy-{}", uuid::Uuid::new_v4().simple());
let create = super::execute_rest_query(
&client,
&format!(
"PUT /{index}\n{{\"mappings\":{{\"properties\":{{\"name\":{{\"type\":\"keyword\"}},\"price\":{{\"type\":\"double\"}}}}}}}}"
),
)
.await
.unwrap();
assert_eq!(create.rows[0][0], json!(200));
let id = super::insert_document(&client, &index, r#"{"name":"Notebook","price":12.5}"#, None).await.unwrap();
assert!(!id.is_empty());
assert!(super::list_indices(&client).await.unwrap().contains(&index));
let columns = super::get_columns(&client, &index).await.unwrap();
assert!(columns.iter().any(|column| column.name == "name" && column.data_type == "keyword"));
assert!(columns.iter().any(|column| column.name == "price" && column.data_type == "double"));
let documents = super::find_documents(&client, &index, 0, 10, None, None).await.unwrap();
assert_eq!(documents.total, 1);
assert_eq!(documents.documents[0]["name"], json!("Notebook"));
super::update_document(&client, &index, &id, r#"{"name":"Notebook Pro","price":15.0}"#, None).await.unwrap();
let sql_result = super::execute_rest_query(&client, &format!("SELECT * FROM {index} LIMIT 10")).await.unwrap();
let name_index = sql_result.columns.iter().position(|column| column == "name").unwrap();
assert_eq!(sql_result.rows[0][name_index], json!("Notebook Pro"));
super::delete_document(&client, &index, &id, None).await.unwrap();
let delete = super::execute_rest_query(&client, &format!("DELETE /{index}")).await.unwrap();
assert_eq!(delete.rows[0][0], json!(200));
}
#[test]
fn document_body_removes_elasticsearch_id_metadata() {
let (doc, _) = super::elasticsearch_document_body_and_routing_from_json(

View File

@ -0,0 +1,23 @@
import { strict as assert } from "node:assert";
import { test } from "vitest";
import {
buildElasticsearchExternalConfig,
elasticsearchConnectionModeFromConfig,
elasticsearchKibanaBasePathFromConfig,
normalizeKibanaBasePath,
} from "../../apps/desktop/src/lib/connection/elasticsearchKibanaProxy.ts";
test("keeps existing Elasticsearch connections in direct mode", () => {
assert.equal(elasticsearchConnectionModeFromConfig(undefined), "direct");
assert.equal(elasticsearchConnectionModeFromConfig({ mode: "direct" }), "direct");
assert.equal(buildElasticsearchExternalConfig("direct", "/kibana"), undefined);
});
test("round trips Kibana proxy mode and normalizes its base path", () => {
const config = buildElasticsearchExternalConfig("kibana", " kibana/s/analytics/ ");
assert.deepEqual(config, { mode: "kibana", kibanaBasePath: "/kibana/s/analytics" });
assert.equal(elasticsearchConnectionModeFromConfig(config), "kibana");
assert.equal(elasticsearchKibanaBasePathFromConfig(config), "/kibana/s/analytics");
assert.equal(normalizeKibanaBasePath("/"), "");
});

View File

@ -806,6 +806,7 @@ async fn test_connection_with_info_inner(
Some(&config.password),
config.ssl,
config.url_params.as_deref(),
config.external_config.as_ref(),
connect_timeout,
);
db::elasticsearch_driver::test_connection(&mut client, connect_timeout)
@ -1121,6 +1122,7 @@ pub async fn connect_db(
Some(&db_config.password),
db_config.ssl,
db_config.url_params.as_deref(),
db_config.external_config.as_ref(),
connect_timeout,
);
db::elasticsearch_driver::test_connection(&mut client, connect_timeout).await?;