fix(hive): support JDBC URLs and HTTP noSasl connections
This commit is contained in:
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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user