дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(remote): SSH configurations, SFTP deployment, run targets and remote development (#91)
Цей коміт міститься в:
1 джерело
8eb4834a48
коміт
025e0019d2
196 files changed
+17920
-3729
No files matched your search
@@ -13,13 +13,26 @@ import {
|
||||
type DbOptions,
|
||||
type DbProperty,
|
||||
type DbSsh,
|
||||
type DbSshAuth,
|
||||
type SshAuth,
|
||||
type DbSslMode,
|
||||
type DbTxMode,
|
||||
} from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { tx, useTx, useTxTemplate } from "@/platform/i18n";
|
||||
import { sshLabel as configLabel } from "@/platform/remoteSettings";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui";
|
||||
import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model";
|
||||
import {
|
||||
colorVar,
|
||||
connectRemedy,
|
||||
effectiveSsh,
|
||||
fileSourceName,
|
||||
newSourceId,
|
||||
parseSourceUrl,
|
||||
sshConfigOf,
|
||||
sshLabel,
|
||||
sshTunnelReady,
|
||||
} from "./model";
|
||||
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
|
||||
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
|
||||
import styles from "./Database.module.css";
|
||||
@@ -52,7 +65,7 @@ const TX: Array<SelectOption<DbTxMode>> = [
|
||||
{ value: "manual", label: "Manual" },
|
||||
];
|
||||
|
||||
const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
|
||||
const SSH_AUTH: Array<SelectOption<SshAuth>> = [
|
||||
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
|
||||
{ value: "password", label: "Password" },
|
||||
{ value: "keyPair", label: "Key pair" },
|
||||
@@ -340,6 +353,11 @@ export function SourceDialog({
|
||||
|
||||
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
|
||||
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
|
||||
const sshConfigs = useWorkflowSettings((w) => w.ssh.configs);
|
||||
const sshConfigId = ssh.config?.trim() ?? "";
|
||||
const sshConfig = sshConfigOf(ssh, sshConfigs);
|
||||
// The host / user / authentication in effect (the chosen SSH configuration's).
|
||||
const sshUsed: DbSsh = effectiveSsh(ssh, sshConfigs) ?? ssh;
|
||||
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
|
||||
const options: DbOptions = { ...blankOptions(), ...s.options };
|
||||
const setOptions = (patch: Partial<DbOptions>) =>
|
||||
@@ -362,7 +380,9 @@ export function SourceDialog({
|
||||
const name =
|
||||
s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`);
|
||||
const sshOn = !sqlite && !!ssh.enabled;
|
||||
const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim());
|
||||
const valid = sqlite
|
||||
? !!s.file?.trim()
|
||||
: (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || sshTunnelReady(ssh, sshConfigs));
|
||||
const sslOn = (s.ssl ?? "prefer") !== "disable";
|
||||
|
||||
/** What is saved / tested: a preset's port when the field was left empty. */
|
||||
@@ -408,7 +428,7 @@ export function SourceDialog({
|
||||
setTest({ ok: true, text: tx`Succeeded: ${v}` });
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : String(e);
|
||||
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
|
||||
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message, { ssh: sshUsed }) === "trustHostKey";
|
||||
setTest({ ok: false, text, trust });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
@@ -416,7 +436,8 @@ export function SourceDialog({
|
||||
};
|
||||
|
||||
const trustAndRetry = async () => {
|
||||
await call(commands.dbTrustSshHost(s.id ?? ""));
|
||||
// A shared SSH configuration records the failed host key under its own id.
|
||||
await call(sshConfigId ? commands.sshTrustHost(sshConfigId) : commands.dbTrustSshHost(s.id ?? ""));
|
||||
await runTest();
|
||||
};
|
||||
|
||||
@@ -426,7 +447,7 @@ export function SourceDialog({
|
||||
await saveSource(
|
||||
{ ...effective(), properties: properties.filter((p) => p.name?.trim()) },
|
||||
sqlite ? undefined : password === undefined ? undefined : password || null,
|
||||
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
|
||||
!sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null,
|
||||
);
|
||||
closeDialog();
|
||||
} catch (e) {
|
||||
@@ -513,7 +534,7 @@ export function SourceDialog({
|
||||
{sshOn && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
|
||||
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh: sshUsed })}.</span>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-user">
|
||||
@@ -667,67 +688,105 @@ export function SourceDialog({
|
||||
</>
|
||||
);
|
||||
|
||||
const sshConfigOptions: SelectOption[] = [
|
||||
{ value: "", label: "Custom (fields below)" },
|
||||
...sshConfigs.map((c) => ({ value: c.id, label: c.name.trim() || configLabel(c) || c.id, detail: configLabel(c) })),
|
||||
...(sshConfigId && !sshConfig ? [{ value: sshConfigId, label: tx`${sshConfigId} (missing)` }] : []),
|
||||
];
|
||||
const sshTab = (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-host">
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
id="db-ssh-host"
|
||||
className={styles.fill}
|
||||
disabled={!sshOn}
|
||||
value={ssh.host ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
|
||||
onChange={(e) => setSsh({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-port">
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-port"
|
||||
className={styles.portInput}
|
||||
inputMode="numeric"
|
||||
disabled={!sshOn}
|
||||
value={ssh.port ?? ""}
|
||||
placeholder="22"
|
||||
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-user">
|
||||
<T>User:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-user"
|
||||
disabled={!sshOn}
|
||||
value={ssh.user ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
|
||||
onChange={(e) => setSsh({ user: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Authentication:</T>
|
||||
<T>Configuration:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="SSH authentication type"
|
||||
aria-label="SSH configuration"
|
||||
disabled={!sshOn}
|
||||
value={ssh.auth ?? "openSsh"}
|
||||
options={SSH_AUTH}
|
||||
onChange={(auth) => setSsh({ auth })}
|
||||
value={sshConfigId}
|
||||
options={sshConfigOptions}
|
||||
onChange={(config) => setSsh({ config })}
|
||||
/>
|
||||
{ssh.auth === "keyPair" && (
|
||||
<FileField
|
||||
id="db-ssh-key"
|
||||
label="Private key:"
|
||||
value={ssh.keyFile ?? ""}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
disabled={!sshOn}
|
||||
title="Private Key File"
|
||||
onChange={(keyFile) => setSsh({ keyFile })}
|
||||
/>
|
||||
{sshConfigId && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
{sshConfig ? (
|
||||
<>{txTemplate(`Connects to ${configLabel(sshConfig) || sshConfig.name}.`)} </>
|
||||
) : (
|
||||
<span className={styles.bad}>{txTemplate(`SSH configuration ${sshConfigId} no longer exists.`)} </span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => void executeAction("file.settings", "tools.sshConfigurations")}
|
||||
>
|
||||
<T>Edit SSH Configurations…</T>
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ssh.auth === "openSsh" ? (
|
||||
{!sshConfigId && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-host">
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
id="db-ssh-host"
|
||||
className={styles.fill}
|
||||
disabled={!sshOn}
|
||||
value={ssh.host ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
|
||||
onChange={(e) => setSsh({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-port">
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-port"
|
||||
className={styles.portInput}
|
||||
inputMode="numeric"
|
||||
disabled={!sshOn}
|
||||
value={ssh.port ?? ""}
|
||||
placeholder="22"
|
||||
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-user">
|
||||
<T>User:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-user"
|
||||
disabled={!sshOn}
|
||||
value={ssh.user ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
|
||||
onChange={(e) => setSsh({ user: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Authentication:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="SSH authentication type"
|
||||
disabled={!sshOn}
|
||||
value={ssh.auth ?? "openSsh"}
|
||||
options={SSH_AUTH}
|
||||
onChange={(auth) => setSsh({ auth })}
|
||||
/>
|
||||
{ssh.auth === "keyPair" && (
|
||||
<FileField
|
||||
id="db-ssh-key"
|
||||
label="Private key:"
|
||||
value={ssh.keyFile ?? ""}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
disabled={!sshOn}
|
||||
title="Private Key File"
|
||||
onChange={(keyFile) => setSsh({ keyFile })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sshUsed.auth === "openSsh" && !sshConfigId && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
@@ -736,10 +795,11 @@ export function SourceDialog({
|
||||
</T>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{sshUsed.auth !== "openSsh" && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-secret">
|
||||
{tx(ssh.auth === "password" ? "Password:" : "Passphrase:")}
|
||||
{tx(sshUsed.auth === "password" ? "Password:" : "Passphrase:")}
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
@@ -751,12 +811,14 @@ export function SourceDialog({
|
||||
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
|
||||
onChange={(e) => setSshSecret(e.target.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={!!ssh.saveSecret}
|
||||
disabled={!sshOn}
|
||||
onChange={(saveSecret) => setSsh({ saveSecret })}
|
||||
label="Save password"
|
||||
/>
|
||||
{!sshConfigId && (
|
||||
<Checkbox
|
||||
checked={!!ssh.saveSecret}
|
||||
disabled={!sshOn}
|
||||
onChange={(saveSecret) => setSsh({ saveSecret })}
|
||||
label="Save password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
|
||||
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSortKey, DbSslMode, DataSource } from "@/platform/ipc";
|
||||
import type {
|
||||
DbCellValue,
|
||||
DbColor,
|
||||
DbProperty,
|
||||
DbRowEdit,
|
||||
DbSortKey,
|
||||
DbSsh,
|
||||
DbSslMode,
|
||||
DataSource,
|
||||
} from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { vendorOf, vendorOfScheme } from "./vendors";
|
||||
|
||||
export type Cell = string | null;
|
||||
@@ -253,6 +263,31 @@ export function sshLabel(source: Pick<DataSource, "ssh">): string {
|
||||
return `${ssh.user ? `${ssh.user}@` : ""}${ssh.host}${ssh.port ? `:${ssh.port}` : ""}`;
|
||||
}
|
||||
|
||||
/** The SSH configuration a tunnel connects with; undefined for the inline fields or a missing one. */
|
||||
export function sshConfigOf(
|
||||
ssh: DbSsh | null | undefined,
|
||||
configs: readonly SshConfigValid[],
|
||||
): SshConfigValid | undefined {
|
||||
const id = ssh?.config?.trim();
|
||||
return id ? configs.find((c) => c.id === id) : undefined;
|
||||
}
|
||||
|
||||
/** The SSH settings in effect: those of the chosen SSH configuration, else the inline fields. */
|
||||
export function effectiveSsh(ssh: DbSsh | null | undefined, configs: readonly SshConfigValid[]): DbSsh | null {
|
||||
if (!ssh) return null;
|
||||
const c = sshConfigOf(ssh, configs);
|
||||
return c
|
||||
? { ...ssh, host: c.host, port: c.port, user: c.user, auth: c.auth, keyFile: c.keyFile, saveSecret: c.saveSecret }
|
||||
: ssh;
|
||||
}
|
||||
|
||||
/** Whether the SSH tab names a host to tunnel through (always, when the tunnel is off). */
|
||||
export function sshTunnelReady(ssh: DbSsh | null | undefined, configs: readonly SshConfigValid[]): boolean {
|
||||
if (!ssh?.enabled) return true;
|
||||
if (ssh.config?.trim()) return !!sshConfigOf(ssh, configs)?.host.trim();
|
||||
return !!ssh.host?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection target (backend `split_target`): a data source id for its configured
|
||||
* database, or `<id>:<database>` for another database of the same server.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DbSsh } from "@/platform/ipc";
|
||||
import { DEFAULT_SSH_CONFIG, type SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { connectRemedy, effectiveSsh, sshConfigOf, sshLabel, sshTunnelReady } from "./model";
|
||||
|
||||
const configs: SshConfigValid[] = [
|
||||
{ ...DEFAULT_SSH_CONFIG, id: "dev", name: "Dev", host: "box", port: 2222, user: "me", auth: "password" },
|
||||
{ ...DEFAULT_SSH_CONFIG, id: "blank", name: "Blank" },
|
||||
];
|
||||
const inline: DbSsh = { enabled: true, config: "", host: "bastion", port: null, user: "ops", auth: "openSsh" };
|
||||
|
||||
describe("data source SSH configuration", () => {
|
||||
it("uses the chosen SSH configuration instead of the inline fields", () => {
|
||||
const ssh = { ...inline, config: "dev" };
|
||||
expect(sshConfigOf(ssh, configs)?.id).toBe("dev");
|
||||
const used = effectiveSsh(ssh, configs)!;
|
||||
expect(sshLabel({ ssh: used })).toBe("me@box:2222");
|
||||
expect(used.auth).toBe("password");
|
||||
expect(used.config).toBe("dev");
|
||||
});
|
||||
|
||||
it("keeps the inline fields when no configuration (or a missing one) is chosen", () => {
|
||||
expect(effectiveSsh(inline, configs)).toBe(inline);
|
||||
expect(sshConfigOf({ ...inline, config: "gone" }, configs)).toBeUndefined();
|
||||
expect(effectiveSsh({ ...inline, config: "gone" }, configs)?.host).toBe("bastion");
|
||||
expect(effectiveSsh(null, configs)).toBeNull();
|
||||
});
|
||||
|
||||
it("needs a host to tunnel through only when the tunnel is on", () => {
|
||||
expect(sshTunnelReady({ ...inline, enabled: false, host: "" }, configs)).toBe(true);
|
||||
expect(sshTunnelReady(inline, configs)).toBe(true);
|
||||
expect(sshTunnelReady({ ...inline, host: " " }, configs)).toBe(false);
|
||||
expect(sshTunnelReady({ ...inline, host: "", config: "dev" }, configs)).toBe(true);
|
||||
expect(sshTunnelReady({ ...inline, config: "blank" }, configs)).toBe(false);
|
||||
expect(sshTunnelReady({ ...inline, config: "gone" }, configs)).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies auth failures by the configuration's authentication", () => {
|
||||
const msg = "SSH authentication failed for me@box:2222";
|
||||
expect(connectRemedy("permissionDenied", msg, { ssh: inline })).toBeNull();
|
||||
expect(connectRemedy("permissionDenied", msg, { ssh: effectiveSsh({ ...inline, config: "dev" }, configs) })).toBe(
|
||||
"sshSecret",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
toolWindowService,
|
||||
type FileDecoration,
|
||||
} from "@/workbench/contributions";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
|
||||
import { colorVar, connectRemedy, databaseKey, effectiveSsh, idleTargets, newSourceId, splitTarget } from "./model";
|
||||
import { tableFolder, type DbNode } from "./nodes";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import type { DbObjectRef } from "./objects";
|
||||
@@ -331,11 +332,13 @@ function askTrustHost(prompt: string): Promise<boolean> {
|
||||
/** Asks the user what a failed connection needs (password, SSH secret, host key trust); false = give up. */
|
||||
export async function resolveConnectError(e: unknown, source: DataSource): Promise<boolean> {
|
||||
if (!(e instanceof IpcError)) return false;
|
||||
switch (connectRemedy(e.kind, e.message, source)) {
|
||||
// Prompts show the SSH configuration's host and authentication when the tunnel uses one.
|
||||
const resolved = { ...source, ssh: effectiveSsh(source.ssh, workflowSettings().ssh.configs) };
|
||||
switch (connectRemedy(e.kind, e.message, resolved)) {
|
||||
case "password":
|
||||
return askPassword(source);
|
||||
case "sshSecret":
|
||||
return askSshSecret(source, e.message);
|
||||
return askSshSecret(resolved, e.message);
|
||||
case "trustHostKey":
|
||||
if (!(await askTrustHost(e.message))) return false;
|
||||
await call(commands.dbTrustSshHost(source.id ?? ""));
|
||||
|
||||
Reference in new issue
Заблокувати користувача