From ab89dcaa67e4c61bd1399e0aab80426d082349e4 Mon Sep 17 00:00:00 2001 From: Matthew Rathbone Date: Fri, 6 Mar 2026 14:34:16 -0600 Subject: [PATCH] fix: Trino listTables returns empty and SSL connections not supported (#3947, #3695) - listTables(null) no longer returns []. Uses buildSchemaFilter() to optionally filter by schema, matching the PostgreSQL driver pattern. - SSL/TLS connections now work. Certificate files (CA, cert, key) are read and passed to trino-client. When no certs are provided, rejectUnauthorized defaults to false (trust server cert). - Removed server:ssl from Trino disabledFeatures so the SSL toggle appears in the connection form. - URL parser now auto-enables ssl for https:// URLs. - Added integration tests for listTables and SSL (with HTTPS-enabled Trino testcontainer). - Added unit tests for SSL config propagation. - Updated Trino connection docs (EN + ES) with SSL section. --- .../backend/lib/db/clients/trino.ts | 36 ++++- .../common/appdb/models/saved_connection.ts | 4 + apps/studio/src/lib/db/clients/index.ts | 1 - .../integration/lib/db/clients/trino.spec.ts | 85 +++++++++-- .../lib/db/clients/trino/container.ts | 118 ++++++++++++++- .../tests/unit/lib/db/clients/trino.spec.ts | 141 ++++++++++++++++++ docs/user_guide/connecting/trino.es.md | 14 +- docs/user_guide/connecting/trino.md | 14 +- 8 files changed, 391 insertions(+), 22 deletions(-) create mode 100644 apps/studio/tests/unit/lib/db/clients/trino.spec.ts diff --git a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts index e182c6f70..bef34e2ba 100644 --- a/apps/studio/src-commercial/backend/lib/db/clients/trino.ts +++ b/apps/studio/src-commercial/backend/lib/db/clients/trino.ts @@ -1,10 +1,12 @@ import rawLog from "@bksLogger" +import { readFileSync } from "fs" import { IDbConnectionDatabase } from "@/lib/db/types" import { Trino as TrinoNodeClient, BasicAuth, QueryResult, - ConnectionOptions as TrinoConnectionOptions + ConnectionOptions as TrinoConnectionOptions, + SecureContextOptions } from 'trino-client' import { BaseQueryResult, @@ -41,6 +43,7 @@ import { createCancelablePromise, joinFilters } from "@/common/utils" +import { buildSchemaFilter } from "@/lib/db/clients/utils" import { AlterTableSpec, TableKey @@ -109,8 +112,30 @@ export class TrinoClient extends BasicDatabaseClient { catalog: this.database.database } - // TODO: Add ssl using SecureContextOptions (https://trinodb.github.io/trino-js-client/types/ConnectionOptions.html) - + if (this.server.config.ssl) { + const sslOptions: SecureContextOptions = {} + + if (this.server.config.sslCaFile) { + sslOptions.ca = readFileSync(this.server.config.sslCaFile) + } + + if (this.server.config.sslCertFile) { + sslOptions.cert = readFileSync(this.server.config.sslCertFile) + } + + if (this.server.config.sslKeyFile) { + sslOptions.key = readFileSync(this.server.config.sslKeyFile) + } + + if (!sslOptions.key && !sslOptions.ca && !sslOptions.cert) { + sslOptions.rejectUnauthorized = false + } else { + sslOptions.rejectUnauthorized = this.server.config.sslRejectUnauthorized + } + + connectionObj.ssl = sslOptions + } + if ((this.server.config.user != null && this.server.config.user !== '') || (this.server.config.password != null && this.server.config.password !== '')) { connectionObj.auth = new BasicAuth(this.server.config.user, this.server.config.password) } @@ -279,8 +304,9 @@ export class TrinoClient extends BasicDatabaseClient { async listTables(filter?: FilterOptions): Promise { log.info('filters in listTables', filter) - if (!filter) return [] - const sql = `select * from ${this.db}.information_schema.tables` + const schemaFilter = buildSchemaFilter(filter, 'table_schema') + const whereClause = schemaFilter ? `WHERE ${schemaFilter}` : '' + const sql = `select * from ${this.db}.information_schema.tables ${whereClause}` const result = await this.driverExecuteSingle(sql) return result.rows.map((row) => ({ diff --git a/apps/studio/src/common/appdb/models/saved_connection.ts b/apps/studio/src/common/appdb/models/saved_connection.ts index 023a2ad83..7cb886e90 100644 --- a/apps/studio/src/common/appdb/models/saved_connection.ts +++ b/apps/studio/src/common/appdb/models/saved_connection.ts @@ -384,6 +384,10 @@ export class SavedConnection extends DbConnectionBase implements IConnection { this.ssl = true } + if (cleanedUrl.startsWith('https://')) { + this.ssl = true + } + if (parsed.params?.TrustServerCertificate && parsed.params.TrustServerCertificate === 'true') { this.trustServerCertificate = true } diff --git a/apps/studio/src/lib/db/clients/index.ts b/apps/studio/src/lib/db/clients/index.ts index 675ea17dd..107431358 100644 --- a/apps/studio/src/lib/db/clients/index.ts +++ b/apps/studio/src/lib/db/clients/index.ts @@ -215,7 +215,6 @@ export const CLIENTS: ClientConfig[] = [ topLevelEntity: 'Catalog', defaultPort: 8080, disabledFeatures: [ - 'server:ssl', 'server:socketPath', 'cancelQuery', // TODO how to do this? ], diff --git a/apps/studio/tests/integration/lib/db/clients/trino.spec.ts b/apps/studio/tests/integration/lib/db/clients/trino.spec.ts index 087ec9b4e..386a81680 100644 --- a/apps/studio/tests/integration/lib/db/clients/trino.spec.ts +++ b/apps/studio/tests/integration/lib/db/clients/trino.spec.ts @@ -1,6 +1,6 @@ import { Network} from "testcontainers" import { DBTestUtil, dbtimeout } from "../../../../lib/db" -import { PostgresTestDriver, TrinoTestDriver } from './trino/container' +import { TrinoBackingPostgresDriver, TrinoHttpDriver, TrinoHttpsDriver } from './trino/container' import { TableOrView } from "@/lib/db/models" // import { runCommonTests, runReadOnlyTests } from "./all" @@ -20,30 +20,30 @@ function testWith(dockerTag: TestVersion, socket = false, readonly = false) { beforeAll(async () => { const network = await new Network().start() - await PostgresTestDriver.start(dockerTag, socket, readonly, network) + await TrinoBackingPostgresDriver.start(dockerTag, socket, readonly, network) - dbfeeder = new DBTestUtil(PostgresTestDriver.config, "banana", PostgresTestDriver.utilOptions) + dbfeeder = new DBTestUtil(TrinoBackingPostgresDriver.config, "banana", TrinoBackingPostgresDriver.utilOptions) await dbfeeder.setupdb() // now set up the trino container - await TrinoTestDriver.start(dockerTag, readonly, network) + await TrinoHttpDriver.start(dockerTag, readonly, network) - util = new DBTestUtil(TrinoTestDriver.config, 'postgresql', { dialect: 'trino' }) + util = new DBTestUtil(TrinoHttpDriver.config, 'postgresql', { dialect: 'trino' }) await util.connection.connect() // Trino uses catalogs instead of databases, access PostgreSQL through 'postgresql' catalog - // util = new DBTestUtil(TrinoTestDriver.config, "postgresql", TrinoTestDriver.utilOptions) + // util = new DBTestUtil(TrinoHttpDriver.config, "postgresql", TrinoHttpDriver.utilOptions) // await util.setupdb() }) afterAll(async () => { - // await util.disconnect() - await TrinoTestDriver.stop() - await dbfeeder.disconnect() if (util.connection) { await util.connection.disconnect() } + await TrinoHttpDriver.stop() + await dbfeeder.disconnect() + await TrinoBackingPostgresDriver.stop() }) describe("Read Operations", () => { @@ -57,9 +57,14 @@ function testWith(dockerTag: TestVersion, socket = false, readonly = false) { expect(tableNames).toContain('jobs') }) - it("List tables should return empty array when filter is undefined", async () => { - const tables: TableOrView[] = await util.connection.listTables() - expect(tables).toEqual([]) + it("List tables should return tables when filter is null (bug #3947)", async () => { + const tables: TableOrView[] = await util.connection.listTables(null) + expect(tables.length).toBeGreaterThanOrEqual(3) + }) + + it("List tables should only return tables matching the schema filter", async () => { + const tables: TableOrView[] = await util.connection.listTables({ schema: 'public' }) + tables.forEach(t => expect(t.schema).toBe('public')) }) it("List table columns should work", async () => { @@ -257,6 +262,62 @@ function testWith(dockerTag: TestVersion, socket = false, readonly = false) { TEST_VERSIONS.forEach(({ version, socket, readonly }) => testWith(version, socket, readonly)) +describe('Trino SSL connection (bug #3695)', () => { + jest.setTimeout(dbtimeout) + + // Separate instance to avoid mutating the shared TrinoBackingPostgresDriver singleton + const sslPgDriver = Object.create(TrinoBackingPostgresDriver, { + container: { value: null, writable: true }, + config: { value: null, writable: true }, + utilOptions: { value: null, writable: true }, + }) + let dbfeeder: DBTestUtil + let util: DBTestUtil + + beforeAll(async () => { + const network = await new Network().start() + + await sslPgDriver.start('latest', false, false, network) + + dbfeeder = new DBTestUtil(sslPgDriver.config, "banana", sslPgDriver.utilOptions) + await dbfeeder.setupdb() + + await TrinoHttpsDriver.start('latest', false, network) + + util = new DBTestUtil(TrinoHttpsDriver.config, 'postgresql', { dialect: 'trino' }) + await util.connection.connect() + }) + + afterAll(async () => { + if (util?.connection) { + await util.connection.disconnect() + } + await TrinoHttpsDriver.stop() + await dbfeeder.disconnect() + await sslPgDriver.container?.stop() + }) + + it("Should connect to Trino over HTTPS with ssl=true and CA cert", async () => { + const version = await util.connection.versionString() + expect(version).toBeDefined() + expect(typeof version).toBe('string') + expect(version.length).toBeGreaterThan(0) + }) + + it("Should be able to list tables over SSL", async () => { + const tables: TableOrView[] = await util.connection.listTables({ schema: 'public' }) + const tableNames = tables.map(t => t.name) + expect(tables.length).toBeGreaterThanOrEqual(3) + expect(tableNames).toContain('people') + }) + + it("Should be able to query data over SSL", async () => { + const result = await util.connection.selectTop('people', 0, 10, [], [], 'public', []) + expect(result.result).toBeDefined() + expect(Array.isArray(result.result)).toBe(true) + }) +}) + // Additional util.connection tests describe('Trino util.Connection Edge Cases', () => { jest.setTimeout(dbtimeout) diff --git a/apps/studio/tests/integration/lib/db/clients/trino/container.ts b/apps/studio/tests/integration/lib/db/clients/trino/container.ts index 845964a14..3c4265956 100644 --- a/apps/studio/tests/integration/lib/db/clients/trino/container.ts +++ b/apps/studio/tests/integration/lib/db/clients/trino/container.ts @@ -4,9 +4,10 @@ import { GenericContainer, Wait, StartedTestContainer } from "testcontainers"; import path from 'path' import os from 'os' import fs from 'fs' +import { execSync } from 'child_process' import { IDbConnectionServerConfig } from "@/lib/db/types"; -export const PostgresTestDriver = { +export const TrinoBackingPostgresDriver = { container: null, utilOptions: null, config: null, @@ -87,7 +88,7 @@ export const PostgresTestDriver = { } } -export const TrinoTestDriver = { +export const TrinoHttpDriver = { container: null as StartedTestContainer | null, pgContainer: null as StartedTestContainer | null, utilOptions: null as Options | null, @@ -146,3 +147,116 @@ connection-password=example`; await this.container?.stop() } } + +function generateTrinoSslFiles(tempDir: string) { + const certDir = path.join(tempDir, 'certs') + fs.mkdirSync(certDir, { recursive: true }) + + const keyFile = path.join(certDir, 'trino.key') + const certFile = path.join(certDir, 'trino.crt') + const p12File = path.join(certDir, 'trino.p12') + const keystorePassword = 'trinopass' + + // Generate self-signed cert and private key + execSync( + `openssl req -x509 -newkey rsa:2048 -keyout ${keyFile} -out ${certFile} ` + + `-days 1 -nodes -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1"`, + { stdio: 'pipe' } + ) + + // Convert to PKCS12 keystore for Trino's Java TLS + execSync( + `openssl pkcs12 -export -in ${certFile} -inkey ${keyFile} ` + + `-out ${p12File} -passout pass:${keystorePassword}`, + { stdio: 'pipe' } + ) + + return { keyFile, certFile, p12File, keystorePassword, certDir } +} + +export const TrinoHttpsDriver = { + container: null as StartedTestContainer | null, + config: null as IDbConnectionServerConfig | null, + certFile: null as string | null, + + async start(dockerTag: string, readonly: boolean, network) { + const startupTimeout = dbtimeout * 2 + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trino-ssl-')) + + // Generate SSL certs and keystore + const ssl = generateTrinoSslFiles(tempDir) + this.certFile = ssl.certFile + + // Create catalog config for postgresql connector + const catalogDir = path.join(tempDir, 'catalog') + fs.mkdirSync(catalogDir) + fs.writeFileSync( + path.join(catalogDir, 'postgresql.properties'), + `connector.name=postgresql +connection-url=jdbc:postgresql://postgres:5432/banana +connection-user=postgres +connection-password=example` + ) + + // Generate a shared secret for internal communication + const sharedSecret = require('crypto').randomBytes(64).toString('base64') + + // Shell script to merge SSL settings into the existing config.properties, + // then start Trino. This preserves default properties like catalog.management. + const entrypointScript = path.join(tempDir, 'entrypoint.sh') + fs.writeFileSync(entrypointScript, [ + '#!/bin/bash', + 'set -e', + "sed -i '/^http-server.http.port=/d' /etc/trino/config.properties", + "sed -i '/^discovery.uri=/d' /etc/trino/config.properties", + `cat >> /etc/trino/config.properties << 'SSLEOF'`, + 'http-server.http.enabled=false', + 'http-server.https.enabled=true', + 'http-server.https.port=8443', + `http-server.https.keystore.path=/etc/trino/certs/trino.p12`, + `http-server.https.keystore.key=${ssl.keystorePassword}`, + 'discovery.uri=https://localhost:8443', + 'internal-communication.https.required=true', + `internal-communication.shared-secret=${sharedSecret}`, + 'SSLEOF', + 'exec /usr/lib/trino/bin/run-trino', + ].join('\n')) + fs.chmodSync(entrypointScript, '755') + + this.container = await new GenericContainer(`trinodb/trino:${dockerTag}`) + .withNetwork(network) + .withExposedPorts(8443) + .withBindMounts([ + { source: catalogDir, target: '/etc/trino/catalog', mode: 'ro' }, + { source: ssl.certDir, target: '/etc/trino/certs', mode: 'ro' }, + { source: entrypointScript, target: '/etc/trino/entrypoint.sh', mode: 'ro' }, + ]) + .withCommand(['/etc/trino/entrypoint.sh']) + .withWaitStrategy(Wait.forLogMessage("SERVER STARTED")) + .withStartupTimeout(startupTimeout) + .start() + + this.config = { + client: 'trino', + host: this.container.getHost(), + port: this.container.getMappedPort(8443), + user: 'test', + password: null, + osUser: 'foo', + ssh: null, + sslCaFile: ssl.certFile, + sslCertFile: null, + sslKeyFile: null, + sslRejectUnauthorized: false, + ssl: true, + domain: null, + socketPath: null, + socketPathEnabled: false, + readOnlyMode: readonly, + } + }, + + async stop() { + await this.container?.stop() + }, +} diff --git a/apps/studio/tests/unit/lib/db/clients/trino.spec.ts b/apps/studio/tests/unit/lib/db/clients/trino.spec.ts new file mode 100644 index 000000000..1d3d7ca0f --- /dev/null +++ b/apps/studio/tests/unit/lib/db/clients/trino.spec.ts @@ -0,0 +1,141 @@ +jest.mock('trino-client', () => { + const mockQuery = jest.fn().mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + yield { + data: [['1.0.0']], + columns: [{ name: '_col0', type: 'varchar' }] + } + } + }) + + return { + Trino: { + create: jest.fn().mockReturnValue({ query: mockQuery }) + }, + BasicAuth: jest.fn().mockImplementation((user, pass) => ({ type: 'basic', username: user, password: pass })), + } +}) + +import fs from 'fs' +import os from 'os' +import path from 'path' +import { Trino as TrinoNodeClient } from 'trino-client' +import { TrinoClient } from '@commercial/backend/lib/db/clients/trino' +import { IDbConnectionServer } from '@/lib/db/backendTypes' +import { IDbConnectionDatabase } from '@/lib/db/types' + +function makeServer(overrides: Partial = {}): IDbConnectionServer { + return { + db: {}, + config: { + client: 'trino', + host: 'localhost', + port: 8080, + user: 'testuser', + password: null, + readOnlyMode: false, + osUser: 'testuser', + ssh: null, + sslCaFile: null, + sslCertFile: null, + sslKeyFile: null, + sslRejectUnauthorized: false, + ssl: false, + domain: null, + socketPath: null, + socketPathEnabled: false, + ...overrides, + }, + } as IDbConnectionServer +} + +function makeDatabase(): IDbConnectionDatabase { + return { + database: 'postgresql', + connected: false, + connecting: false, + namespace: null, + } +} + +describe('TrinoClient SSL configuration (bug #3695)', () => { + let tmpDir: string + let caFile: string + let certFile: string + let keyFile: string + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trino-test-')) + caFile = path.join(tmpDir, 'ca.pem') + certFile = path.join(tmpDir, 'cert.pem') + keyFile = path.join(tmpDir, 'key.pem') + fs.writeFileSync(caFile, 'fake-ca') + fs.writeFileSync(certFile, 'fake-cert') + fs.writeFileSync(keyFile, 'fake-key') + }) + + afterAll(() => { + fs.rmSync(tmpDir, { recursive: true }) + }) + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should pass ssl options to Trino.create() when ssl is true', async () => { + const server = makeServer({ ssl: true }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeDefined() + }) + + it('should use https protocol when ssl is true', async () => { + const server = makeServer({ ssl: true }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.server).toContain('https') + }) + + it('should pass ssl cert/key/ca files when configured', async () => { + const server = makeServer({ + ssl: true, + sslCaFile: caFile, + sslCertFile: certFile, + sslKeyFile: keyFile, + }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeDefined() + expect(createCall.ssl.ca).toBeDefined() + expect(createCall.ssl.cert).toBeDefined() + expect(createCall.ssl.key).toBeDefined() + }) + + it('should set rejectUnauthorized based on config', async () => { + const server = makeServer({ + ssl: true, + sslRejectUnauthorized: false, + }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeDefined() + expect(createCall.ssl.rejectUnauthorized).toBe(false) + }) + + it('should not pass ssl options when ssl is false', async () => { + const server = makeServer({ ssl: false }) + const client = new TrinoClient(server, makeDatabase()) + await client.connect() + + const createCall = (TrinoNodeClient.create as jest.Mock).mock.calls[0][0] + expect(createCall.ssl).toBeUndefined() + }) +}) diff --git a/docs/user_guide/connecting/trino.es.md b/docs/user_guide/connecting/trino.es.md index 3555b907f..bdd1025f7 100644 --- a/docs/user_guide/connecting/trino.es.md +++ b/docs/user_guide/connecting/trino.es.md @@ -21,11 +21,22 @@ Conectarse a una base de datos Trino desde Beekeeper Studio es sencillo. Selecci Para conectarte a una base de datos Trino, necesitaras la siguiente informacion: - **Host**: La direccion IP o nombre de host de tu servidor Trino. -- **Puerto**: El puerto predeterminado es 8080, pero esto se puede personalizar si tu servidor usa un puerto diferente. +- **Puerto**: El puerto predeterminado es 8080 para HTTP, o 8443 para HTTPS. Esto se puede personalizar si tu servidor usa un puerto diferente. - **Nombre de usuario**: Tu nombre de usuario de Trino, siendo default el valor predeterminado tipico. - **Contrasena**: Tu contrasena de Trino, si aplica. - **Catalogo predeterminado (opcional)**: El catalogo al que quieres conectarte inicialmente al inicio +### Conexiones SSL / HTTPS + +Si tu coordinador Trino esta configurado con TLS/HTTPS, habilita **SSL** en el formulario de conexion. Beekeeper Studio soporta tres modos de SSL: + +1. **Confiar en el certificado del servidor** — Habilita SSL sin proporcionar archivos de certificado. Beekeeper Studio se conectara por HTTPS pero no verificara el certificado del servidor. Esta es la opcion mas sencilla y funciona con certificados autofirmados. +2. **Proporcionar un certificado CA** — Si tu servidor Trino usa un certificado firmado por una CA privada, proporciona el archivo de certificado CA. Deja "Rechazar no autorizados" desmarcado para permitir la conexion. +3. **Verificacion completa de certificados** — Proporciona el certificado CA y opcionalmente un certificado de cliente y archivo de clave, luego marca "Rechazar no autorizados" para aplicar la verificacion TLS completa. + +!!! tip + Si importas una URL de conexion que comienza con `https://`, SSL se habilitara automaticamente. + ### Probar tu conexion de Trino Antes de guardar los detalles de tu conexion, Beekeeper Studio te permite probar la conexion: @@ -40,6 +51,7 @@ Una vez que los detalles de tu conexion han sido verificados, puedes elegir guar ## Funciones soportadas +- Conexiones SSL / HTTPS (con archivos opcionales de CA, certificado de cliente y clave) - Vista de datos de tabla - Ordenamiento y filtrado de datos de tabla - Vista de estructura de tabla diff --git a/docs/user_guide/connecting/trino.md b/docs/user_guide/connecting/trino.md index 00cd413c9..8a6d28cb8 100644 --- a/docs/user_guide/connecting/trino.md +++ b/docs/user_guide/connecting/trino.md @@ -21,11 +21,22 @@ Connecting to a Trino database from Beekeeper Studio is straightforward. SElect To connect to a Trino database, you'll need the following information: - **Host**: The IP address or hostname of your Trino server. -- **Port**: The default port is 8080, but this can be customized if your server uses a different port. +- **Port**: The default port is 8080 for HTTP, or 8443 for HTTPS. This can be customized if your server uses a different port. - **Username**: Your Trino username, with default being the typical default. - **Password**: Your Trino password, if applicable. - **Default Catalog (optional)**: The catalog you want initially connected to at startup +### SSL / HTTPS Connections + +If your Trino coordinator is configured with TLS/HTTPS, enable **SSL** in the connection form. Beekeeper Studio supports three SSL modes: + +1. **Trust the server certificate** — Enable SSL without providing any certificate files. Beekeeper Studio will connect over HTTPS but will not verify the server's certificate. This is the simplest option and works with self-signed certificates. +2. **Provide a CA certificate** — If your Trino server uses a certificate signed by a private CA, provide the CA certificate file. Leave "Reject Unauthorized" unchecked to allow the connection. +3. **Full certificate verification** — Provide the CA certificate and optionally a client certificate and key file, then check "Reject Unauthorized" to enforce full TLS verification. + +!!! tip + If you import a connection URL that starts with `https://`, SSL will be enabled automatically. + ### Testing Your Trino Connection Before saving your connection details, Beekeeper Studio allows you to test the connection: @@ -40,6 +51,7 @@ Once your connection details have been verified, you can choose to save them by ## Supported Features +- SSL / HTTPS connections (with optional CA, client cert, and key files) - Table data view - Table data sorting, filtering - Table structure view