feat(plugins): reconnect after connection config changes

Closes #9974
This commit is contained in:
t8y2
2026-09-26 16:45:22 +00:00
parent 0a4e46a25f
commit 5a272565bb
2 changed files with 171 additions and 8 deletions
@@ -619,4 +619,140 @@ describe("connectionStore plugin password prompt", () => {
await expect(store.connect(connection)).rejects.toThrow(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
});
it("reconnects an open plugin with its saved credential after runtime config changes", async () => {
const connectDb = vi.fn().mockResolvedValue("ssh-1");
const savedPassword = ["stored", "pw"].join("-");
installApiMocks({ connectDb, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection({ save_password: true, password: savedPassword, external_config: { authentication: "password" } });
store.connections = [connection];
store.connectedIds.add(connection.id);
await store.updateConnection({ ...connection, host: "new.example.com" });
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", host: "new.example.com", password: savedPassword }), expect.any(Number));
expect(requestPassword).not.toHaveBeenCalled();
expect(store.connectedIds.has(connection.id)).toBe(true);
});
it("reconnects an open plugin through its session credential without prompting", async () => {
const connectDb = vi.fn().mockResolvedValue("ssh-1");
const sessionCredentialStatus = vi.fn().mockResolvedValue(true);
installApiMocks({ connectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection({ external_config: { authentication: "password" } });
store.connections = [connection];
store.connectedIds.add(connection.id);
await store.updateConnection({ ...connection, host: "new.example.com" });
expect(sessionCredentialStatus).toHaveBeenCalledWith(connection.id);
expect(requestPassword).not.toHaveBeenCalled();
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: "ssh-1", host: "new.example.com", password: "" }), expect.any(Number));
expect(store.connectedIds.has(connection.id)).toBe(true);
});
it("disconnects an open plugin instead of prompting when no credential is available", async () => {
const connectDb = vi.fn().mockResolvedValue("ssh-1");
const disconnectDb = vi.fn().mockResolvedValue(undefined);
const sessionCredentialStatus = vi.fn().mockResolvedValue(false);
installApiMocks({ connectDb, disconnectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection({ external_config: { authentication: "password" } });
store.connections = [connection];
store.connectedIds.add(connection.id);
await store.updateConnection({ ...connection, host: "new.example.com" });
expect(requestPassword).not.toHaveBeenCalled();
expect(connectDb).not.toHaveBeenCalled();
expect(disconnectDb).toHaveBeenCalledWith(connection.id, undefined);
expect(store.connectedIds.has(connection.id)).toBe(false);
expect(store.connectionErrors[connection.id]).toContain("Reconnect manually");
expect(store.getConfig(connection.id)?.host).toBe("new.example.com");
});
it("does not connect a plugin that was offline when its config was saved", async () => {
const connectDb = vi.fn().mockResolvedValue("ssh-1");
const disconnectDb = vi.fn().mockResolvedValue(undefined);
const sessionCredentialStatus = vi.fn().mockResolvedValue(true);
installApiMocks({ connectDb, disconnectDb, sessionCredentialStatus, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection({ external_config: { authentication: "password" } });
store.connections = [connection];
await store.updateConnection({ ...connection, host: "new.example.com" });
expect(connectDb).not.toHaveBeenCalled();
expect(disconnectDb).not.toHaveBeenCalled();
expect(sessionCredentialStatus).not.toHaveBeenCalled();
expect(requestPassword).not.toHaveBeenCalled();
expect(store.connectedIds.has(connection.id)).toBe(false);
});
it("reconnects an open plugin when its display name changes", async () => {
const connectDb = vi.fn().mockResolvedValue("ssh-1");
const disconnectDb = vi.fn().mockResolvedValue(undefined);
installApiMocks({ connectDb, disconnectDb, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection();
store.connections = [connection];
store.connectedIds.add(connection.id);
await store.updateConnection({ ...connection, name: "Renamed SSH" });
expect(connectDb).toHaveBeenCalledWith(expect.objectContaining({ id: connection.id, name: "Renamed SSH" }), expect.any(Number));
expect(disconnectDb).not.toHaveBeenCalled();
expect(store.connectedIds.has(connection.id)).toBe(true);
});
it("preserves native connection invalidation without reconnecting it", async () => {
const connectDb = vi.fn().mockResolvedValue("pg-1");
const disconnectDb = vi.fn().mockResolvedValue(undefined);
installApiMocks({ connectDb, disconnectDb });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = postgresConnection({ password: "" });
store.connections = [connection];
store.connectedIds.add(connection.id);
await store.updateConnection({ ...connection, host: "new.example.com" });
expect(connectDb).not.toHaveBeenCalled();
expect(disconnectDb).not.toHaveBeenCalled();
expect(store.connectedIds.has(connection.id)).toBe(false);
});
it("keeps saved plugin settings and an offline error when automatic reconnect fails", async () => {
const connectDb = vi.fn().mockRejectedValue(new Error("sidecar rejected config"));
const saveConnections = vi.fn().mockResolvedValue(undefined);
const savedPassword = ["stored", "pw"].join("-");
installApiMocks({ connectDb, saveConnections, listPlugins: vi.fn().mockResolvedValue([sshInstalledPlugin()]) });
installPasswordPromptMock();
const { useConnectionStore } = await import("@/stores/connectionStore");
const store = useConnectionStore();
const connection = pluginConnection({ save_password: true, password: savedPassword, external_config: { authentication: "password" } });
store.connections = [connection];
store.connectedIds.add(connection.id);
await expect(store.updateConnection({ ...connection, host: "new.example.com" })).resolves.toBeUndefined();
expect(saveConnections).toHaveBeenCalledWith([expect.objectContaining({ id: connection.id, host: "new.example.com" })]);
expect(store.getConfig(connection.id)?.host).toBe("new.example.com");
expect(store.connectedIds.has(connection.id)).toBe(false);
expect(store.connectionErrors[connection.id]).toContain("settings were saved");
expect(store.connectionErrors[connection.id]).toContain("sidecar rejected config");
});
});
+35 -8
View File
@@ -432,6 +432,7 @@ type BeforeConnectHandler = (config: ConnectionConfig) => Promise<void>;
export const CONNECTION_ATTEMPT_CANCELLED_MESSAGE = "Connection attempt was cancelled";
/** Thrown when a no-save-password connection is connected without a typed password. */
export const CONNECTION_PASSWORD_REQUIRED_MESSAGE = "Password is required for this connection";
const PLUGIN_CONFIG_RECONNECT_PASSWORD_MESSAGE = "Plugin settings were saved, but the connection was disconnected because its password is not available. Reconnect manually to apply the new settings.";
function metadataDriverProfile(config?: ConnectionConfig): string | undefined {
return config?.driver_profile || config?.db_type;
@@ -4029,7 +4030,9 @@ export const useConnectionStore = defineStore("connection", () => {
if (config.save_password === false) config.password = "";
const idx = connections.value.findIndex((c) => c.id === config.id);
if (idx < 0) return;
const runtimeConfigChanged = connectionConfigFingerprint(connections.value[idx]) !== connectionConfigFingerprint(config);
const previousConfig = normalizeConnection(connections.value[idx]);
const runtimeConfigChanged = connectionConfigFingerprint(previousConfig) !== connectionConfigFingerprint(config);
const shouldReconnectPlugin = runtimeConfigChanged && previousConfig.db_type === "plugin" && config.db_type === "plugin" && connectedIds.value.has(config.id);
const nextConnections = [...connections.value];
nextConnections[idx] = config;
await persistTimeoutInheritance(config.id, config.connect_timeout_inherit === true, config.query_timeout_inherit === true);
@@ -4052,6 +4055,7 @@ export const useConnectionStore = defineStore("connection", () => {
if (node?.isExpanded) {
await reloadConnectionDatabaseChildren(config.id);
}
if (shouldReconnectPlugin) await reconnectPluginConnectionAfterConfigUpdate(config);
}
async function updateRedisKeyGrouping(connectionId: string, grouping: import("@/lib/redis/redisKeyGrouping").RedisKeyGrouping) {
@@ -4767,7 +4771,7 @@ export const useConnectionStore = defineStore("connection", () => {
invalidateConnectionMetadataLifetime(connectionId, database);
}
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean } = {}) {
async function ensureConnected(connectionId: string, options: { activate?: boolean; verifyHealth?: boolean; forceReconnect?: boolean; allowPasswordPrompt?: boolean } = {}) {
if (!options.forceReconnect && connectedIds.value.has(connectionId)) {
// Pure navigation can safely trust the existing connected state. Its
// destination will perform the real API request, while blocking here on
@@ -4824,6 +4828,7 @@ export const useConnectionStore = defineStore("connection", () => {
// in-flight dedup above keeps its exact microtask cadence; only await the
// interactive prompt when the connection actually needs a typed password.
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) {
if (options.allowPasswordPrompt === false) throw new Error(CONNECTION_PASSWORD_REQUIRED_MESSAGE);
const prompted = await ensureConnectionPassword(config);
config = prompted.config;
rememberPassword = prompted.rememberPassword;
@@ -4886,6 +4891,31 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
async function canReconnectPluginConnectionWithoutPrompt(config: ConnectionConfig): Promise<boolean> {
if (!connectionNeedsPasswordPrompt(config)) return true;
if (!(await pluginConnectionPasswordPromptNeeded(config))) return true;
return hasSessionCredential(config.id);
}
async function reconnectPluginConnectionAfterConfigUpdate(config: ConnectionConfig): Promise<void> {
if (!(await canReconnectPluginConnectionWithoutPrompt(config))) {
try {
await startDisconnectRequest(config.id);
setConnectionError(config.id, PLUGIN_CONFIG_RECONNECT_PASSWORD_MESSAGE);
} catch (error) {
setConnectionError(config.id, `Plugin settings were saved, but DBX could not disconnect the stale plugin connection: ${connectionErrorMessage(error)}. Reconnect manually to apply the new settings.`);
}
return;
}
try {
await ensureConnected(config.id, { activate: false, forceReconnect: true, allowPasswordPrompt: false });
} catch (error) {
connectedIds.value.delete(config.id);
setConnectionError(config.id, `Plugin settings were saved, but automatic reconnection failed: ${connectionErrorMessage(error)}`);
}
}
/**
* Re-push an already-open plugin connection's config (credentials included)
* to its sidecar through the same connection/connect path used when opening
@@ -4908,18 +4938,15 @@ export const useConnectionStore = defineStore("connection", () => {
// plugin's first `ready`. The 2s in-memory TTL dies with the frontend, so
// every realistic reload path still re-pushes.
if (hasRecentConnectionHealthCheck(connectionId)) return;
if (connectionNeedsPasswordPrompt(config) && (await pluginConnectionPasswordPromptNeeded(config)) && !(await hasSessionCredential(connectionId))) return;
await ensureConnected(connectionId, { activate: false, forceReconnect: true });
if (!(await canReconnectPluginConnectionWithoutPrompt(config))) return;
await ensureConnected(connectionId, { activate: false, forceReconnect: true, allowPasswordPrompt: false });
}
/**
* Explicit, user-triggered reconnect of a plugin connection (the plugin's own
* "reconnect" button). Unlike repushPluginConnection — the silent background
* heal — this runs the full connect flow and MAY show the interactive
* password prompt, which is appropriate for a deliberate user action. Editing
* a connection drops it from connectedIds without notifying plugins, so the
* sidecar's config goes stale; this is the plugin's way to request a fresh
* connection/connect with the updated config.
* password prompt, which is appropriate for a deliberate user action.
*/
async function reopenPluginConnection(connectionId: string, pluginId: string): Promise<void> {
const config = getConfig(connectionId);