fix(hive): support JDBC URLs and HTTP noSasl connections

This commit is contained in:
zipg authored and GitHub committed 2026-08-17 19:40:40 +08:00
1 parent c724fd46b0
commit 4794a66aba
12 files changed
+517 -67

No files matched your search

@@ -75,6 +75,7 @@ import {
ArrowLeft,
ArrowDown,
ArrowUp,
Check,
CheckSquare,
ChevronRight,
CircleHelp,
@@ -261,6 +262,8 @@ const agentInstallError = ref("");
const showConnectionErrorDialog = ref(false);
const connectionErrorRawDetail = ref("");
const connectionErrorDetail = ref("");
const testResultCopied = ref(false);
const connectionErrorCopied = ref(false);
const editingId = ref<string | null>(null);
const draftTestConnectionId = ref(uuid());
const showVisibleDatabasesDialog = ref(false);
@@ -1915,6 +1918,7 @@ function failAgentDriverInstall(error: unknown) {
function showConnectionError(message: string) {
connectionErrorRawDetail.value = message;
connectionErrorDetail.value = translateBackendError(t, message);
connectionErrorCopied.value = false;
showConnectionErrorDialog.value = true;
}
@@ -3594,6 +3598,7 @@ async function testConnection() {
const runId = ++testRunId;
isTesting.value = true;
testResult.value = null;
testResultCopied.value = false;
let config: ConnectionConfig | null = null;
const submittedSourceName = form.value.name;
try {
@@ -4421,6 +4426,8 @@ function resetTestState() {
showConnectionErrorDialog.value = false;
connectionErrorRawDetail.value = "";
connectionErrorDetail.value = "";
testResultCopied.value = false;
connectionErrorCopied.value = false;
}
function resetVisibleDatabaseDraftState() {
@@ -4793,6 +4800,7 @@ async function copyTestResult() {
if (!testResultMessage.value) return;
try {
await copyToClipboard(testResultMessage.value);
testResultCopied.value = true;
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
@@ -4824,6 +4832,7 @@ async function copyConnectionErrorDetail() {
if (!connectionErrorDetail.value) return;
try {
await copyToClipboard(connectionErrorDetail.value);
connectionErrorCopied.value = true;
toast(t("grid.copied"));
} catch (e: any) {
toast(t("grid.copyFailed", { message: e?.message || String(e) }), 5000);
@@ -8464,8 +8473,9 @@ function openExternalUrl(url: string) {
<span class="block min-w-0 flex-1 basis-0 truncate text-xs" :class="testResult.ok ? 'text-green-600' : 'text-red-600'" :title="testResultMessage" role="status" aria-live="polite">
{{ testResultMessage }}
</span>
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="t('connection.copyTestResult')" :aria-label="t('connection.copyTestResult')" @click="copyTestResult">
<Copy class="h-3 w-3" />
<Button v-if="!testResult.ok" variant="ghost" size="icon-xs" class="h-5 w-5 shrink-0" :title="testResultCopied ? t('grid.copied') : t('connection.copyTestResult')" :aria-label="testResultCopied ? t('grid.copied') : t('connection.copyTestResult')" @click="copyTestResult">
<Check v-if="testResultCopied" class="h-3 w-3" />
<Copy v-else class="h-3 w-3" />
</Button>
</template>
</div>
@@ -8550,8 +8560,9 @@ function openExternalUrl(url: string) {
{{ t("toolbar.driverManager") }}
</Button>
<Button variant="outline" @click="copyConnectionErrorDetail">
<Copy class="mr-1.5 h-3.5 w-3.5" />
{{ t("connection.copyError") }}
<Check v-if="connectionErrorCopied" class="mr-1.5 h-3.5 w-3.5" />
<Copy v-else class="mr-1.5 h-3.5 w-3.5" />
{{ connectionErrorCopied ? t("grid.copied") : t("connection.copyError") }}
</Button>
<Button @click="showConnectionErrorDialog = false">{{ t("common.close") }}</Button>
</DialogFooter>
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { copyToClipboard, type ClipboardEnvironment } from "@/lib/common/clipboard";
function legacyClipboardEnvironment(activeDialog?: { appendChild: ReturnType<typeof vi.fn>; removeChild: ReturnType<typeof vi.fn> }) {
const textarea = {
value: "",
style: {},
setAttribute: vi.fn(),
focus: vi.fn(),
select: vi.fn(),
setSelectionRange: vi.fn(),
};
const body = { appendChild: vi.fn(), removeChild: vi.fn() };
const env: ClipboardEnvironment = {
navigator: { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } },
document: {
body,
activeElement: activeDialog ? { closest: vi.fn().mockReturnValue(activeDialog) } : undefined,
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn().mockReturnValue(true),
},
};
return { body, env, textarea };
}
describe("copyToClipboard", () => {
it("keeps the legacy copy target inside the active dialog", async () => {
const dialog = { appendChild: vi.fn(), removeChild: vi.fn() };
const { body, env, textarea } = legacyClipboardEnvironment(dialog);
await copyToClipboard("connection failed", env);
expect(dialog.appendChild).toHaveBeenCalledWith(textarea);
expect(dialog.removeChild).toHaveBeenCalledWith(textarea);
expect(body.appendChild).not.toHaveBeenCalled();
expect(textarea.value).toBe("connection failed");
});
it("uses the document body when no dialog is active", async () => {
const { body, env, textarea } = legacyClipboardEnvironment();
await copyToClipboard("copied text", env);
expect(body.appendChild).toHaveBeenCalledWith(textarea);
expect(body.removeChild).toHaveBeenCalledWith(textarea);
});
});
+16 -6
View File
@@ -23,10 +23,15 @@ interface ClipboardTextarea {
setSelectionRange?(start: number, end: number): void;
}
interface ClipboardContainer {
appendChild(node: unknown): unknown;
removeChild(node: unknown): unknown;
}
interface ClipboardDocument {
body?: {
appendChild(node: unknown): unknown;
removeChild(node: unknown): unknown;
body?: ClipboardContainer;
activeElement?: {
closest?(selector: string): ClipboardContainer | null;
};
createElement(tagName: "textarea"): ClipboardTextarea;
execCommand?(command: string): boolean;
@@ -69,6 +74,10 @@ function recordClipboardWrite(): void {
clipboardWriteRevision += 1;
}
function legacyClipboardContainer(document: ClipboardDocument): ClipboardContainer | undefined {
return document.activeElement?.closest?.("[role='dialog']") ?? document.body;
}
function closestElement(target: unknown, selector: string): unknown {
return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null;
}
@@ -149,7 +158,8 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
}
const document = env.document;
if (!document?.body || !document.execCommand) {
const container = document ? legacyClipboardContainer(document) : undefined;
if (!document || !container || !document.execCommand) {
throw new Error("Clipboard API is not available");
}
@@ -161,7 +171,7 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
textarea.style.left = "-9999px";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
container.appendChild(textarea);
try {
textarea.focus?.();
textarea.select();
@@ -171,6 +181,6 @@ export async function copyToClipboard(text: string, env: ClipboardEnvironment =
}
recordClipboardWrite();
} finally {
document.body.removeChild(textarea);
container.removeChild(textarea);
}
}
@@ -257,6 +257,31 @@ function queryParamValue(params: string, key: string): string | undefined {
return undefined;
}
function extractHiveStructuredParams(params: string): { username?: string; password?: string; ssl: boolean; urlParams: string } {
let username: string | undefined;
let password: string | undefined;
let ssl = false;
const urlParams: string[] = [];
for (const part of params.split(";")) {
if (!part) continue;
const [rawKey, ...rest] = part.split("=");
const key = decodeUrlPart(rawKey).trim().toLowerCase();
const value = decodeUrlPart(rest.join("=")).trim();
if (key === "user" || key === "username") {
username = value;
} else if (key === "password") {
password = value;
} else if (key === "ssl") {
ssl = value.toLowerCase() === "true";
} else {
urlParams.push(part);
}
}
return { username, password, ssl, urlParams: urlParams.join(";") };
}
function connectionNameParam(parsed: URL): string | undefined {
for (const [key, value] of parsed.searchParams) {
if (key.toLowerCase() === "name") {
@@ -341,6 +366,40 @@ export function connectionProfileForScheme(scheme: string, preferredProfile?: st
return SCHEME_PROFILES[normalizedScheme];
}
function parseJdbcHiveUrl(source: string): ParsedConnectionUrl | null {
const match = /^jdbc:hive2:\/\/(?<hosts>[^/?#;]+)(?:\/(?<path>[^?#]*))?(?<query>\?[^#]*)?(?<fragment>#.*)?$/i.exec(source);
if (!match?.groups) return null;
const firstHost = match.groups.hosts.split(",")[0]?.trim();
if (!firstHost) return null;
let endpoint: URL;
try {
endpoint = new URL(`hive2://${firstHost}`);
} catch {
return null;
}
if (!endpoint.hostname) return null;
const [rawDatabase = "", ...paramParts] = (match.groups.path || "").split(";");
const structured = extractHiveStructuredParams(paramParts.join(";"));
const urlParams = `${structured.urlParams}${match.groups.query || ""}${match.groups.fragment || ""}`;
return {
dbType: "hive",
driverProfile: "hive",
driverLabel: "Apache Hive",
host: endpoint.hostname.replace(/^\[(.*)]$/, "$1"),
port: endpoint.port ? Number(endpoint.port) : 10000,
username: structured.username ?? decodeUrlPart(endpoint.username),
password: structured.password ?? decodeUrlPart(endpoint.password),
database: decodeUrlPart(rawDatabase) || undefined,
urlParams,
ssl: structured.ssl,
connectionString: source,
};
}
function parseJdbcSqlServerUrl(source: string): ParsedConnectionUrl | null {
const match = source.match(/^jdbc:sqlserver:\/\/([^;:/]+)(?::(\d+))?(?:;(.*))?$/i);
if (!match) return null;
@@ -580,6 +639,8 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
if (!input) {
throw new Error("Connection URL is empty");
}
const jdbcHive = parseJdbcHiveUrl(input);
if (jdbcHive) return jdbcHive;
const jdbcH2 = parseH2JdbcUrl(input);
if (jdbcH2) return jdbcH2;
const jdbcUCanAccess = parseJdbcUCanAccessUrl(input);