@@ -666,6 +456,10 @@ export class ConnectionManagementPanel {
function addConnection() {
vscode.postMessage({ command: 'addConnection' });
}
+
+ function editConnection(id) {
+ vscode.postMessage({ command: 'edit', id: id });
+ }
function refreshConnections() {
vscode.postMessage({ command: 'refresh' });
@@ -673,92 +467,83 @@ export class ConnectionManagementPanel {
function testConnection(id) {
const btn = document.querySelector(\`[data-test-id="\${id}"]\`);
- const result = document.getElementById(\`test-result-\${id}\`);
-
- btn.classList.add('loading');
+ const originalText = btn.innerHTML;
btn.textContent = 'Testing...';
- result.style.display = 'none';
+ btn.disabled = true;
- vscode.postMessage({
- command: 'test',
- id: id
- });
- }
-
- // Add event delegation for delete buttons
- document.addEventListener('click', function(event) {
- const deleteBtn = event.target.closest('.btn-delete');
- if (deleteBtn) {
- const id = deleteBtn.getAttribute('data-connection-id');
- const name = deleteBtn.getAttribute('data-connection-name');
-
- if (id) {
- // Show custom confirmation
- const card = deleteBtn.closest('.connection-card');
- const existingConfirm = card.querySelector('.delete-confirmation');
-
- if (existingConfirm) {
- existingConfirm.remove();
- return;
- }
-
- const confirmDiv = document.createElement('div');
- confirmDiv.className = 'delete-confirmation';
- confirmDiv.innerHTML = \`
-
-
Delete "\${name}"?
-
-
-
-
-
- \`;
-
- card.querySelector('.card-actions').appendChild(confirmDiv);
-
- confirmDiv.querySelector('.btn-confirm-yes').addEventListener('click', function() {
- vscode.postMessage({
- command: 'delete',
- id: id
- });
- });
-
- confirmDiv.querySelector('.btn-confirm-no').addEventListener('click', function() {
- confirmDiv.remove();
- });
- }
- }
- });
+ vscode.postMessage({ command: 'test', id: id });
+
+ // Store original text
+ btn.setAttribute('data-original-text', originalText);
+ }
+
+ function showDeleteConfirm(id) {
+ const card = document.querySelector(\`[data-card-id="\${id}"]\`);
+ if (card.querySelector('.delete-confirm-overlay')) return;
+
+ const overlay = document.createElement('div');
+ overlay.className = 'delete-confirm-overlay';
+ overlay.innerHTML = \`
+
Delete this connection?
+
+
+
+
+ \`;
+ card.appendChild(overlay);
+ }
+
+ function deleteConnection(id) {
+ vscode.postMessage({ command: 'delete', id: id });
+ }
window.addEventListener('message', event => {
const message = event.data;
if (message.type === 'testSuccess') {
const btn = document.querySelector(\`[data-test-id="\${message.id}"]\`);
- const result = document.getElementById(\`test-result-\${message.id}\`);
+ const card = document.querySelector(\`[data-card-id="\${message.id}"]\`);
- btn.classList.remove('loading');
- btn.textContent = '✓ Test';
+ btn.innerHTML = 'Scan ✓';
+ btn.disabled = false;
- result.className = 'test-result success';
- result.textContent = '✓ Connection successful!';
- result.style.display = 'block';
+ showNotification(card, 'Connection successful!', 'success');
setTimeout(() => {
- result.style.display = 'none';
- }, 5000);
+ const original = btn.getAttribute('data-original-text');
+ if(original) btn.innerHTML = original;
+ }, 3000);
+
} else if (message.type === 'testError') {
- const btn = document.querySelector(\`[data-test-id="\${message.id}"]\`);
- const result = document.getElementById(\`test-result-\${message.id}\`);
+ const btn = document.querySelector(\`[data-test-id="\${message.id}"]\`);
+ const card = document.querySelector(\`[data-card-id="\${message.id}"]\`);
- btn.classList.remove('loading');
- btn.textContent = '✗ Test';
+ btn.innerHTML = 'Error ✕';
+ btn.disabled = false;
- result.className = 'test-result error';
- result.textContent = \`✗ \${message.error}\`;
- result.style.display = 'block';
+ showNotification(card, message.error, 'error');
+ setTimeout(() => {
+ const original = btn.getAttribute('data-original-text');
+ if(original) btn.innerHTML = original;
+ }, 3000);
}
});
+
+ function showNotification(card, text, type) {
+ const existing = card.querySelector('.test-result-overlay');
+ if(existing) existing.remove();
+
+ const el = document.createElement('div');
+ el.className = \`test-result-overlay \${type}\`;
+ el.textContent = text;
+ card.appendChild(el);
+ el.style.display = 'block';
+
+ setTimeout(() => {
+ el.style.opacity = '0';
+ setTimeout(() => el.remove(), 300);
+ }, 4000);
+ }
`;
@@ -766,66 +551,53 @@ export class ConnectionManagementPanel {
private _getConnectionCardHtml(conn: ConnectionInfo & { hasPassword: boolean }): string {
const connectionString = this._buildConnectionString(conn);
- const authBadge = conn.hasPassword || conn.username
- ? '
✓ Auth'
- : '
No Auth';
+ const authStatus = conn.hasPassword || conn.username
+ ? 'Auth ✓'
+ : 'No Auth';
+
+ // Escaping helper
+ const escape = (s: string | undefined) => this._escapeHtml(s || '');
return `
-
+
`;
}
private _buildConnectionString(conn: ConnectionInfo): string {
diff --git a/src/dashboard/DashboardHtml.ts b/src/dashboard/DashboardHtml.ts
index 899ce9f..a0fc708 100644
--- a/src/dashboard/DashboardHtml.ts
+++ b/src/dashboard/DashboardHtml.ts
@@ -292,6 +292,32 @@ export function getHtmlForWebview(stats: DashboardStats) {
z-index: 100;
overflow-y: auto;
}
+ /* Media Queries */
+ @media (max-width: 768px) {
+ body {
+ padding: 24px;
+ }
+ .header {
+ flex-direction: column;
+ gap: 16px;
+ }
+ .header-controls {
+ width: 100%;
+ justify-content: flex-start;
+ }
+ .grid {
+ gap: 16px;
+ margin-bottom: 32px;
+ }
+ .charts-grid {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ }
+ .chart-container {
+ padding: 16px;
+ height: 300px;
+ }
+ }
.back-link {
color: var(--secondary-text);
cursor: pointer;
diff --git a/src/extension.ts b/src/extension.ts
index dd9789d..696370f 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -73,8 +73,8 @@ export async function activate(context: vscode.ExtensionContext) {
const commands = [
{
command: 'postgres-explorer.addConnection',
- callback: () => {
- ConnectionFormPanel.show(context.extensionUri, context);
+ callback: (connection?: any) => {
+ ConnectionFormPanel.show(context.extensionUri, context, connection);
}
},
{
diff --git a/src/providers/DatabaseTreeProvider.ts b/src/providers/DatabaseTreeProvider.ts
index 4d149fc..5dc794b 100644
--- a/src/providers/DatabaseTreeProvider.ts
+++ b/src/providers/DatabaseTreeProvider.ts
@@ -66,10 +66,10 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider c.id === element.connectionId);
@@ -97,11 +97,6 @@ export class DatabaseTreeProvider implements vscode.TreeDataProvider {
// Format as PostgreSQL array literal: '{1,2,3}'
const arrayStr = val.map(v => {
if (v === null) return 'NULL';
- if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`;
+ if (typeof v === 'string') return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return String(v);
}).join(',');
return `'{${arrayStr}}'`;
@@ -1927,7 +1927,7 @@ export const activate: ActivationFunction = context => {
const arr = JSON.parse(val);
const arrayStr = arr.map((v: any) => {
if (v === null) return 'NULL';
- if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`;
+ if (typeof v === 'string') return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return String(v);
}).join(',');
return `'{${arrayStr}}'`;
@@ -1957,7 +1957,7 @@ export const activate: ActivationFunction = context => {
if (Array.isArray(val)) {
const arrayStr = val.map(v => {
if (v === null) return 'NULL';
- if (typeof v === 'string') return `"${v.replace(/"/g, '\\"')}"`;
+ if (typeof v === 'string') return `"${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
return String(v);
}).join(',');
return `'{${arrayStr}}'`;
diff --git a/src/services/ConnectionManager.ts b/src/services/ConnectionManager.ts
index c130f2a..9d593f6 100644
--- a/src/services/ConnectionManager.ts
+++ b/src/services/ConnectionManager.ts
@@ -1,6 +1,7 @@
import { Client } from 'pg';
import { ConnectionConfig } from '../common/types';
import { SecretStorageService } from './SecretStorageService';
+import { SSHService } from './SSHService';
export class ConnectionManager {
private static instance: ConnectionManager;
@@ -32,14 +33,30 @@ export class ConnectionManager {
// If username is provided but password is not found in storage, it might still work for some auth methods
}
- const client = new Client({
- host: config.host,
- port: config.port,
+ const clientConfig: any = {
user: config.username || undefined,
password: password || undefined,
database: config.database || 'postgres',
connectionTimeoutMillis: 5000
- });
+ };
+
+ if (config.ssh && config.ssh.enabled) {
+ try {
+ const stream = await SSHService.getInstance().createStream(
+ config.ssh,
+ config.host,
+ config.port
+ );
+ clientConfig.stream = stream;
+ } catch (err: any) {
+ throw new Error(`SSH Connection failed: ${err.message}`);
+ }
+ } else {
+ clientConfig.host = config.host;
+ clientConfig.port = config.port;
+ }
+
+ const client = new Client(clientConfig);
await client.connect();
this.connections.set(key, client);
@@ -54,7 +71,7 @@ export class ConnectionManager {
public async closeConnection(config: ConnectionConfig): Promise {
const key = this.getConnectionKey(config);
const client = this.connections.get(key);
-
+
if (client) {
try {
await client.end();
@@ -67,14 +84,14 @@ export class ConnectionManager {
public async closeAllConnectionsById(connectionId: string): Promise {
const keysToClose: string[] = [];
-
+
// Find all connections with this ID
for (const key of this.connections.keys()) {
if (key.startsWith(`${connectionId}:`)) {
keysToClose.push(key);
}
}
-
+
// Close all found connections
for (const key of keysToClose) {
const client = this.connections.get(key);
@@ -87,7 +104,7 @@ export class ConnectionManager {
}
}
}
-
+
console.log(`Closed ${keysToClose.length} connections for ID: ${connectionId}`);
}
diff --git a/src/services/SSHService.ts b/src/services/SSHService.ts
new file mode 100644
index 0000000..9625393
--- /dev/null
+++ b/src/services/SSHService.ts
@@ -0,0 +1,79 @@
+import { Client, ClientChannel } from 'ssh2';
+import * as fs from 'fs';
+import { Stream } from 'stream';
+
+export interface SSHConfig {
+ host: string;
+ port: number;
+ username: string;
+ privateKeyPath?: string;
+ password?: string;
+}
+
+export class SSHService {
+ private static instance: SSHService;
+
+ private constructor() { }
+
+ public static getInstance(): SSHService {
+ if (!SSHService.instance) {
+ SSHService.instance = new SSHService();
+ }
+ return SSHService.instance;
+ }
+
+ public createStream(sshConfig: SSHConfig, dbHost: string, dbPort: number): Promise {
+ return new Promise((resolve, reject) => {
+ const conn = new Client();
+
+ conn.on('ready', () => {
+ // Forward traffic to database
+ conn.forwardOut(
+ '127.0.0.1', // Source IP (can be arbitrary)
+ 0, // Source Port (can be arbitrary)
+ dbHost, // Destination DB Host
+ dbPort, // Destination DB Port
+ (err, stream) => {
+ if (err) {
+ conn.end();
+ reject(err);
+ return;
+ }
+
+ // Close SSH connection when stream closes
+ stream.on('close', () => {
+ conn.end();
+ });
+
+ resolve(stream);
+ }
+ );
+ }).on('error', (err) => {
+ reject(err);
+ });
+
+ try {
+ const connectConfig: any = {
+ host: sshConfig.host,
+ port: sshConfig.port,
+ username: sshConfig.username
+ };
+
+ if (sshConfig.privateKeyPath) {
+ try {
+ connectConfig.privateKey = fs.readFileSync(sshConfig.privateKeyPath);
+ } catch (err) {
+ reject(new Error(`Failed to read private key at ${sshConfig.privateKeyPath}: ${err}`));
+ return;
+ }
+ } else if (sshConfig.password) {
+ connectConfig.password = sshConfig.password;
+ }
+
+ conn.connect(connectConfig);
+ } catch (err) {
+ reject(err);
+ }
+ });
+ }
+}
diff --git a/tsconfig.json b/tsconfig.json
index 05ec246..74921b2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,5 +1,6 @@
{
"compilerOptions": {
+ "jsx": "react",
"module": "commonjs",
"target": "ES2020",
"outDir": "out",