From 809a721b8be4ae3a971dfdab792f53c4e456d02b Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:56:23 +0100 Subject: [PATCH 1/7] feat(azure): add SQL to cloud explorer Preserve Cosmos DB while exposing Azure SQL servers through the shared database service.\n\nCloses #92 --- .env.example | 2 + README.md | 5 +- .../AzureDatabaseAdapter.test.ts | 20 +- .../src/adapter-azure/AzureDatabaseAdapter.ts | 15 +- .../src/adapter-azure/AzureSqlAdapter.test.ts | 126 +++++++++++++ .../api/src/adapter-azure/AzureSqlAdapter.ts | 174 ++++++++++++++++++ packages/api/src/azure.ts | 8 + packages/api/src/cloud-spi/databaseSchema.ts | 71 ++++++- packages/api/src/cloud-spi/types.ts | 9 +- packages/api/src/routes/clouds.test.ts | 8 +- .../src/components/DynamicFormRenderer.tsx | 11 +- .../src/components/DynamicResourceView.tsx | 4 +- packages/frontend/src/components/Layout.tsx | 5 +- .../frontend/src/pages/CloudExplorerPage.tsx | 2 +- packages/frontend/src/types/resource.ts | 2 +- packages/frontend/src/types/schema.ts | 7 +- 16 files changed, 443 insertions(+), 26 deletions(-) create mode 100644 packages/api/src/adapter-azure/AzureSqlAdapter.test.ts create mode 100644 packages/api/src/adapter-azure/AzureSqlAdapter.ts diff --git a/.env.example b/.env.example index 5074891..91b5317 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ FLOCI_ENDPOINT=http://localhost:4566 FLOCI_AZURE_ENDPOINT=http://localhost:4577 FLOCI_AZURE_ACCOUNT_NAME=devstoreaccount1 +FLOCI_AZURE_SUBSCRIPTION_ID=00000000-0000-0000-0000-000000000000 +FLOCI_AZURE_RESOURCE_GROUP=floci-local VITE_MOCK_MODE=false AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=test diff --git a/README.md b/README.md index 1c4bc79..498fe53 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ This table is the source of truth for the current UI surface. | Console Home | Yes | Yes | Yes | Cloud-aware overview page with runtime status and service cards. | | Cloud Explorer / Storage | Yes | Yes | Yes | Unified storage view with resource table, inspector, object browser, and schema-driven actions. | | Cloud Explorer / k8s Engine | Yes | Placeholder | Placeholder | AWS EKS list/inspect is wired. | -| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect and Azure Cosmos DB NoSQL workflows. | +| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect; Azure SQL server and Cosmos DB NoSQL workflows. | | Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. | | Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. | | Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. | @@ -105,9 +105,10 @@ Current gaps:
Database -Two different database models are currently exposed under one category: +Three different database models are currently exposed under one category: - AWS RDS: list and inspect oriented. +- Azure SQL Database: server list, create, delete, and inspect workflows. - Azure Cosmos DB NoSQL: database, container, and document workflows. Cosmos DB currently includes: diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts index a3fd45d..fc82e0d 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts @@ -1,8 +1,24 @@ import {describe, expect, test} from 'bun:test' import {AzureDatabaseAdapter} from './AzureDatabaseAdapter' +import {AzureSqlAdapter} from './AzureSqlAdapter' import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' describe('AzureDatabaseAdapter', () => { + test('combines Cosmos databases and Azure SQL servers', async () => { + const client = testClient({ + '/dbs': {_count: 1, Databases: [{id: 'cosmosdb'}]}, + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers?api-version=2021-11-01': { + value: [{name: 'app-sql', location: 'eastus', properties: {state: 'Ready'}}], + }, + }) + const adapter = new AzureDatabaseAdapter(client, new AzureSqlAdapter(client)) + + await expect(adapter.list()).resolves.toMatchObject([ + {id: 'cosmosdb', type: 'cosmos-database'}, + {id: 'sql-server:app-sql', type: 'sql-server'}, + ]) + }) + test('normalizes Cosmos databases as cloud database resources', async () => { const adapter = new AzureDatabaseAdapter(testClient({ '/dbs': { @@ -113,7 +129,7 @@ describe('AzureDatabaseAdapter', () => { }, calls)) await expect(adapter.list()).resolves.toMatchObject([{id: 'fallbackdb'}]) - expect(calls.map((call) => call.path)).toEqual(['/dbs', '/devstoreaccount1-cosmos/dbs']) + expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual(['/dbs', '/devstoreaccount1-cosmos/dbs']) }) test('falls back to named NoSQL engine path after root and default account routes fail', async () => { @@ -128,7 +144,7 @@ describe('AzureDatabaseAdapter', () => { }, calls)) await expect(adapter.list()).resolves.toMatchObject([{id: 'nosqldb'}]) - expect(calls.map((call) => call.path)).toEqual([ + expect(calls.map((call) => call.path).filter((path) => path.endsWith('/dbs'))).toEqual([ '/dbs', '/devstoreaccount1-cosmos/dbs', '/devstoreaccount1-cosmos-nosql/dbs', diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts index 0b5cd05..8cb69d5 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts @@ -10,6 +10,7 @@ import type { ResourceQuery, ServiceSchema, } from '../cloud-spi/types' +import {AzureSqlAdapter, isSqlServerResourceId} from './AzureSqlAdapter' interface CosmosListResponse { _count?: number @@ -24,7 +25,10 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { readonly cloud = 'azure' as const readonly service = 'database' as const - constructor(private readonly client: AzureRuntimeClient = azure) {} + constructor( + private readonly client: AzureRuntimeClient = azure, + private readonly sqlAdapter: AzureSqlAdapter = new AzureSqlAdapter(client), + ) {} schema(): ServiceSchema { return azureDatabaseSchema() @@ -33,15 +37,18 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { async list(query: ResourceQuery = {}): Promise { const body = await this.cosmosJson>('/dbs', {method: 'GET'}, {emptyOnNotFound: true}) const databases = body?.Databases ?? [] - return filterBySearch(databases.map(toDatabaseResource), query.search) + const cosmosResources = filterBySearch(databases.map(toDatabaseResource), query.search) + return [...cosmosResources, ...await this.sqlAdapter.list(query)] } async get(id: string): Promise { + if (isSqlServerResourceId(id)) return this.sqlAdapter.get(id) const body = await this.cosmosJson(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true}) return body ? toDatabaseResource(body) : null } async create(input: CreateResourceInput): Promise { + if (input.values.resourceType === 'sql-server') return this.sqlAdapter.create(input) const databaseName = stringValue(input.values.databaseName) if (!databaseName) throw new Error('databaseName is required') if (!isValidCosmosId(databaseName)) throw new Error('Use a valid Cosmos database name.') @@ -55,6 +62,10 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { } async delete(id: string): Promise { + if (isSqlServerResourceId(id)) { + await this.sqlAdapter.delete(id) + return + } await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'}) } diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts new file mode 100644 index 0000000..368cc4b --- /dev/null +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts @@ -0,0 +1,126 @@ +import {describe, expect, test} from 'bun:test' +import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' +import {AzureSqlAdapter} from './AzureSqlAdapter' + +const SERVERS_PATH = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers' +const API_VERSION = '?api-version=2021-11-01' + +describe('AzureSqlAdapter', () => { + test('normalizes Azure SQL servers as cloud database resources', async () => { + const adapter = new AzureSqlAdapter(testClient({ + [`${SERVERS_PATH}${API_VERSION}`]: { + value: [{ + id: `${SERVERS_PATH}/app-sql`, + name: 'app-sql', + type: 'Microsoft.Sql/servers', + location: 'eastus', + kind: 'v12.0', + tags: {environment: 'test'}, + properties: { + administratorLogin: 'sa', + version: '12.0', + state: 'Ready', + fullyQualifiedDomainName: 'localhost', + localPort: 14330, + publicNetworkAccess: 'Enabled', + }, + }], + }, + })) + + await expect(adapter.list()).resolves.toEqual([{ + id: 'sql-server:app-sql', + name: 'app-sql', + cloud: 'azure', + service: 'database', + type: 'sql-server', + region: 'eastus', + createdAt: null, + status: 'Ready', + engine: 'azure-sql', + version: '12.0', + instanceClass: 'v12.0', + metadata: { + provider: 'azure', + databaseService: 'sql', + resourceKind: 'server', + armId: `${SERVERS_PATH}/app-sql`, + serverName: 'app-sql', + administratorLogin: 'sa', + fullyQualifiedDomainName: 'localhost', + publicNetworkAccess: 'Enabled', + minimalTlsVersion: undefined, + endpoint: {address: 'localhost', port: 14330}, + tags: [{key: 'environment', value: 'test'}], + }, + }]) + }) + + test('creates an Azure SQL server through the ARM contract', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}` + const adapter = new AzureSqlAdapter(testClient({ + [serverPath]: { + id: `${SERVERS_PATH}/app-sql`, + name: 'app-sql', + location: 'uksouth', + properties: {state: 'Ready', version: '12.0'}, + }, + }, calls)) + + const resource = await adapter.create({values: { + serverName: 'app-sql', + location: 'uksouth', + administratorLogin: 'sqladmin', + administratorLoginPassword: 'StrongPassw0rd!', + }}) + + expect(resource.id).toBe('sql-server:app-sql') + expect(calls).toHaveLength(1) + expect(calls[0].path).toBe(serverPath) + expect(calls[0].init.method).toBe('PUT') + expect(JSON.parse(String(calls[0].init.body))).toEqual({ + location: 'uksouth', + properties: { + administratorLogin: 'sqladmin', + administratorLoginPassword: 'StrongPassw0rd!', + }, + }) + }) + + test('gets and deletes a SQL server by its normalized id', async () => { + const calls: Array<{path: string; init: RequestInit}> = [] + const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}` + const adapter = new AzureSqlAdapter(testClient({ + [serverPath]: {name: 'app-sql', location: 'eastus', properties: {state: 'Ready'}}, + }, calls)) + + await expect(adapter.get('sql-server:app-sql')).resolves.toMatchObject({name: 'app-sql', type: 'sql-server'}) + await adapter.delete('sql-server:app-sql') + + expect(calls.map((call) => [call.init.method, call.path])).toEqual([ + ['GET', serverPath], + ['DELETE', serverPath], + ]) + }) + + test('returns null when an Azure SQL server is not found', async () => { + const adapter = new AzureSqlAdapter(testClient({})) + await expect(adapter.get('sql-server:missing')).resolves.toBeNull() + }) +}) + +function testClient(responses: Record, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient { + return { + endpoint: 'http://localhost:4577', + accountName: 'devstoreaccount1', + async fetch(path: string, init: RequestInit, options: AzureRuntimeFetchOptions = {}) { + calls.push({path, init}) + if (!(path in responses)) { + if (options.emptyOnNotFound) return null + throw new Error(`Unexpected Azure runtime request: ${path}`) + } + return new Response(JSON.stringify(responses[path]), {status: 200, headers: {'content-type': 'application/json'}}) + }, + } +} diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.ts new file mode 100644 index 0000000..c9933c8 --- /dev/null +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.ts @@ -0,0 +1,174 @@ +import { + azure, + azureResourceGroup, + type AzureRuntimeClient, + azureSubscriptionId, +} from '../azure' +import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import type { + CloudResource, + CloudServiceAdapter, + CreateResourceInput, + ResourceQuery, + ServiceSchema, +} from '../cloud-spi/types' + +const API_VERSION = '2021-11-01' +const RESOURCE_ID_PREFIX = 'sql-server:' + +interface AzureSqlListResponse { + value?: AzureSqlRecord[] +} + +interface AzureSqlRecord { + id?: string + name?: string + type?: string + location?: string + kind?: string + tags?: Record + properties?: Record +} + +export class AzureSqlAdapter implements CloudServiceAdapter { + readonly cloud = 'azure' as const + readonly service = 'database' as const + + constructor(private readonly client: AzureRuntimeClient = azure) {} + + schema(): ServiceSchema { + return azureDatabaseSchema() + } + + async list(query: ResourceQuery = {}): Promise { + const body = await this.sqlJson(sqlServersPath(), {method: 'GET'}, true) + return filterBySearch((body?.value ?? []).map(toSqlServerResource), query.search) + } + + async get(id: string): Promise { + const serverName = sqlServerName(id) + const body = await this.sqlJson(sqlServerPath(serverName), {method: 'GET'}, true) + return body ? toSqlServerResource(body) : null + } + + async create(input: CreateResourceInput): Promise { + const serverName = stringValue(input.values.serverName) + const location = stringValue(input.values.location) || 'eastus' + const administratorLogin = stringValue(input.values.administratorLogin) + const administratorLoginPassword = stringValue(input.values.administratorLoginPassword) + + if (!serverName) throw new Error('serverName is required') + if (!isValidServerName(serverName)) throw new Error('Use a valid Azure SQL server name.') + if (!administratorLogin) throw new Error('administratorLogin is required') + if (!administratorLoginPassword) throw new Error('administratorLoginPassword is required') + + const body = await this.sqlJson(sqlServerPath(serverName), { + method: 'PUT', + body: JSON.stringify({ + location, + properties: {administratorLogin, administratorLoginPassword}, + }), + }) + if (!body) throw new Error('Azure SQL server creation returned an empty response') + return toSqlServerResource(body) + } + + async delete(id: string): Promise { + await this.sqlFetch(sqlServerPath(sqlServerName(id)), {method: 'DELETE'}) + } + + private sqlFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + return this.client.fetch(withApiVersion(path), { + ...init, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + ...(init.headers ?? {}), + }, + }, {emptyOnNotFound}) + } + + private async sqlJson(path: string, init: RequestInit, emptyOnNotFound = false): Promise { + const response = await this.sqlFetch(path, init, emptyOnNotFound) + if (!response || response.status === 204) return null + return await response.json() as T + } +} + +export function isSqlServerResourceId(id: string): boolean { + return id.startsWith(RESOURCE_ID_PREFIX) +} + +function sqlServersPath(): string { + return `/subscriptions/${encodeURIComponent(azureSubscriptionId())}/resourceGroups/${encodeURIComponent(azureResourceGroup())}/providers/Microsoft.Sql/servers` +} + +function sqlServerPath(serverName: string): string { + return `${sqlServersPath()}/${encodeURIComponent(serverName)}` +} + +function withApiVersion(path: string): string { + return `${path}?api-version=${API_VERSION}` +} + +function sqlServerName(id: string): string { + const name = isSqlServerResourceId(id) ? id.slice(RESOURCE_ID_PREFIX.length) : id + if (!name) throw new Error('Azure SQL server id is required') + return name +} + +function toSqlServerResource(server: AzureSqlRecord): CloudResource { + const name = stringValue(server.name) + const properties = server.properties ?? {} + return { + id: `${RESOURCE_ID_PREFIX}${name}`, + name, + cloud: 'azure', + service: 'database', + type: 'sql-server', + region: stringValue(server.location) || null, + createdAt: null, + status: stringValue(properties.state) || null, + engine: 'azure-sql', + version: stringValue(properties.version) || null, + instanceClass: stringValue(server.kind) || null, + metadata: { + provider: 'azure', + databaseService: 'sql', + resourceKind: 'server', + armId: server.id, + serverName: name, + administratorLogin: properties.administratorLogin, + fullyQualifiedDomainName: properties.fullyQualifiedDomainName, + publicNetworkAccess: properties.publicNetworkAccess, + minimalTlsVersion: properties.minimalTlsVersion, + endpoint: { + address: properties.fullyQualifiedDomainName, + port: numberValue(properties.localPort), + }, + tags: tagList(server.tags), + }, + } +} + +function filterBySearch(resources: CloudResource[], search?: string): CloudResource[] { + const normalized = search?.trim().toLowerCase() + if (!normalized) return resources + return resources.filter((resource) => resource.name.toLowerCase().includes(normalized)) +} + +function tagList(tags?: Record): Array<{key: string; value: string}> { + return Object.entries(tags ?? {}).map(([key, value]) => ({key, value})) +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function numberValue(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +function isValidServerName(value: string): boolean { + return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) +} diff --git a/packages/api/src/azure.ts b/packages/api/src/azure.ts index b8ea2ba..456e86e 100644 --- a/packages/api/src/azure.ts +++ b/packages/api/src/azure.ts @@ -46,6 +46,14 @@ export function azureAccountName(): string { return process.env.FLOCI_AZURE_ACCOUNT_NAME ?? 'devstoreaccount1' } +export function azureSubscriptionId(): string { + return process.env.FLOCI_AZURE_SUBSCRIPTION_ID ?? '00000000-0000-0000-0000-000000000000' +} + +export function azureResourceGroup(): string { + return process.env.FLOCI_AZURE_RESOURCE_GROUP ?? 'floci-local' +} + export const azure = new AzureRestRuntimeClient() function errorMessage(error: unknown): string { diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index f02f053..7095291 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -28,13 +28,71 @@ export function azureDatabaseSchema(): ServiceSchema { return { cloud: 'azure', service: 'database', - displayName: 'Cosmos DB', + displayName: 'Azure SQL & Cosmos DB', fields: [ + { + name: 'resourceType', + label: 'Database Service', + type: 'select', + required: true, + span: true, + defaultValue: 'sql-server', + options: [ + {label: 'Azure SQL Server', value: 'sql-server'}, + {label: 'Cosmos DB Database', value: 'cosmos-database'}, + ], + }, + { + name: 'serverName', + label: 'Server Name', + type: 'text', + required: true, + group: 'Azure SQL Server', + visibleWhen: {field: 'resourceType', equals: 'sql-server'}, + validation: { + minLength: 1, + maxLength: 63, + pattern: '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$', + message: 'Use 1-63 lowercase letters, numbers, or hyphens.', + }, + }, + { + name: 'location', + label: 'Location', + type: 'text', + required: true, + defaultValue: 'eastus', + visibleWhen: {field: 'resourceType', equals: 'sql-server'}, + }, + { + name: 'administratorLogin', + label: 'Administrator Login', + type: 'text', + required: true, + defaultValue: 'sa', + visibleWhen: {field: 'resourceType', equals: 'sql-server'}, + }, + { + name: 'administratorLoginPassword', + label: 'Administrator Password', + type: 'password', + required: true, + span: true, + description: 'Required by the local Azure SQL runtime. The value is not returned by the API.', + visibleWhen: {field: 'resourceType', equals: 'sql-server'}, + validation: { + minLength: 8, + message: 'Use at least 8 characters.', + }, + }, { name: 'databaseName', label: 'Database Name', type: 'text', required: true, + group: 'Cosmos DB', + span: true, + visibleWhen: {field: 'resourceType', equals: 'cosmos-database'}, validation: { minLength: 1, maxLength: 255, @@ -46,18 +104,19 @@ export function azureDatabaseSchema(): ServiceSchema { actions: ['list', 'create', 'delete', 'inspect'], capabilities: { resourceActions: [ - {name: 'list', label: 'List databases', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'create', label: 'Create database', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'delete', label: 'Delete database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'list', label: 'List database resources', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create database resource', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete database resource', enabled: true, status: 'available', runtimeRequired: true}, {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, ], }, filters: databaseFilters, columns: [ - {name: 'name', label: 'Database'}, + {name: 'name', label: 'Name'}, + {name: 'type', label: 'Resource Type'}, {name: 'engine', label: 'Engine'}, {name: 'status', label: 'Status'}, - {name: 'createdAt', label: 'Created At'}, + {name: 'region', label: 'Region'}, ], } } diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index bf9da0d..58fba4c 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -26,7 +26,7 @@ export interface CloudStatus { error: string | null } -export type FieldType = 'text' | 'select' +export type FieldType = 'text' | 'select' | 'password' export interface FieldSchema { name: string @@ -36,6 +36,11 @@ export interface FieldSchema { description?: string group?: string span?: boolean + defaultValue?: string + visibleWhen?: { + field: string + equals: string + } validation?: { pattern?: string minLength?: number @@ -83,7 +88,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'sql-server' | 'instance' | 'image' | 'vpc' | 'lambda' | 'azure-function' | 'gcp-function' region: string | null createdAt: string | null status?: string | null diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 49296be..2ae4714 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -79,7 +79,11 @@ describe('cloud schema routes', () => { expect(res.status).toBe(200) expect(body.cloud).toBe('azure') expect(body.service).toBe('database') - expect(body.displayName).toBe('Cosmos DB') + expect(body.displayName).toBe('Azure SQL & Cosmos DB') + expect(body.fields).toEqual(expect.arrayContaining([ + expect.objectContaining({name: 'resourceType', defaultValue: 'sql-server'}), + expect.objectContaining({name: 'serverName', visibleWhen: {field: 'resourceType', equals: 'sql-server'}}), + ])) }) test('returns GCP storage schema', async () => { @@ -111,7 +115,7 @@ describe('cloud schema routes', () => { const gcpBody = await gcpRes.json() expect(azureRes.status).toBe(200) - expect(azureBody.displayName).toBe('Cosmos DB') + expect(azureBody.displayName).toBe('Azure SQL & Cosmos DB') expect(gcpRes.status).toBe(200) expect(gcpBody.displayName).toBe('Cloud SQL') }) diff --git a/packages/frontend/src/components/DynamicFormRenderer.tsx b/packages/frontend/src/components/DynamicFormRenderer.tsx index 0c718b6..69d7e75 100644 --- a/packages/frontend/src/components/DynamicFormRenderer.tsx +++ b/packages/frontend/src/components/DynamicFormRenderer.tsx @@ -22,7 +22,7 @@ export function DynamicFormRenderer({schema, isSubmitting, submitLabel = 'Create function submit(event: FormEvent) { event.preventDefault() - const nextErrors = validateValues(schema.fields, values) + const nextErrors = validateValues(visibleFields(schema.fields, values), values) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return onSubmit(values) @@ -30,7 +30,7 @@ export function DynamicFormRenderer({schema, isSubmitting, submitLabel = 'Create return (
- {schema.fields.map((field) => ( + {visibleFields(schema.fields, values).map((field) => ( { - return Object.fromEntries(fields.map((field) => [field.name, ''])) + return Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? ''])) +} + +function visibleFields(fields: FieldSchema[], values: Record): FieldSchema[] { + return fields.filter((field) => !field.visibleWhen || values[field.visibleWhen.field] === field.visibleWhen.equals) } function validateValues(fields: FieldSchema[], values: Record): Record { diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 706b77b..ad4f487 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -311,7 +311,7 @@ export function DynamicResourceView({ runtimeReachable={runtimeReachable} /> )} - {service === "database" && cloud === "azure" && ( + {service === "database" && cloud === "azure" && activeSelected?.type === "cosmos-database" && ( Cloud Services ยท {cloudLabel} {CLOUD_SERVICE_ITEMS.map((service) => { const Icon = CLOUD_SERVICE_ICONS[service.name] + const label = cloud === 'azure' && service.name === 'database' ? 'Azure SQL / Cosmos DB' : service.label const available = service.name === 'storage' || (service.name === 'secretsmanager' && cloud === 'aws') || (service.name === 'database' && (cloud === 'aws' || cloud === 'azure')) @@ -73,13 +74,13 @@ function CloudServiceNav() { || (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure')) if (service.route && available) { const target = service.route.startsWith('/') ? service.route : `/cloud-explorer/${cloud}/${service.route}` - return + return } return (
- {service.label} + {label} Soon
) diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx index 37efef0..cff389f 100644 --- a/packages/frontend/src/pages/CloudExplorerPage.tsx +++ b/packages/frontend/src/pages/CloudExplorerPage.tsx @@ -262,7 +262,7 @@ function serviceLabel(service: CloudServiceType): string { function limitationCopy(cloud: CloudProvider, service: CloudServiceType): string { if (cloud === 'aws' && service === 'storage') return 'Advanced S3 workflows such as bulk actions, version browsing, and richer object lifecycle controls still live outside the normalized surface.' if (cloud === 'azure' && service === 'storage') return 'Blob Storage is wired through the normalized contract, but advanced metadata, tags, and access-policy workflows are still limited.' - if (cloud === 'azure' && service === 'database') return 'Cosmos DB uses a richer panel below for databases, containers, items, and SQL queries; a fully normalized database model is still evolving.' + if (cloud === 'azure' && service === 'database') return 'Azure SQL servers and Cosmos DB databases share the normalized resource view. Cosmos DB adds a richer panel for containers, items, and SQL queries.' if (cloud === 'aws' && service === 'compute') return 'Compute workflows still rely on AWS-specific forms for dependent infrastructure choices such as VPC, subnet, and security group.' if (cloud === 'aws' && service === 'networking') return 'Networking uses AWS-specific operational panels because many actions require nested workflows instead of a flat generic form.' return 'This service is available through the current adapter, but the normalized contract is still expanding.' diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 71f464f..2558273 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -5,7 +5,7 @@ export interface CloudResource { name: string cloud: CloudProvider service: CloudServiceType - type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function'; + type: 'bucket' | 'container' | 'cluster' | 'db-instance' | 'cosmos-database' | 'sql-server' | 'instance' | 'image' | 'vpc' | 'lambda' | "azure-function" | 'gcp-function'; region: string | null createdAt: string | null status?: string | null diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index 06e4985..9e9d553 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -1,6 +1,6 @@ import type {CloudProvider, CloudServiceType} from './cloud' -export type FieldType = 'text' | 'select' +export type FieldType = 'text' | 'select' | 'password' export type ActionSchema = 'list' | 'create' | 'delete' | 'inspect' export type ResourceActionName = 'list' | 'create' | 'delete' | 'inspect' export type ObjectActionName = 'list' | 'upload' | 'download' | 'delete' | 'createFolder' | 'copy' @@ -23,6 +23,11 @@ export interface FieldSchema { description?: string group?: string span?: boolean + defaultValue?: string + visibleWhen?: { + field: string + equals: string + } validation?: { pattern?: string minLength?: number From 011ec982308c7da26cb483d0707af56b7aac6161 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:33:17 +0100 Subject: [PATCH 2/7] fix(azure): isolate database list failures --- .../adapter-azure/AzureDatabaseAdapter.test.ts | 16 ++++++++++++++++ .../src/adapter-azure/AzureDatabaseAdapter.ts | 17 +++++++++++++++-- .../api/src/adapter-azure/AzureSqlAdapter.ts | 3 ++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts index fc82e0d..4e51cb2 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts @@ -19,6 +19,22 @@ describe('AzureDatabaseAdapter', () => { ]) }) + test('lists SQL servers when Cosmos routes are not implemented', async () => { + const client = testClient({ + '/dbs': 'not-implemented', + '/devstoreaccount1-cosmos/dbs': 'not-implemented', + '/devstoreaccount1-cosmos-nosql/dbs': 'not-implemented', + '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers?api-version=2021-11-01': { + value: [{name: 'sql-only', location: 'eastus', properties: {state: 'Ready'}}], + }, + }) + const adapter = new AzureDatabaseAdapter(client) + + await expect(adapter.list()).resolves.toMatchObject([ + {id: 'sql-server:sql-only', type: 'sql-server'}, + ]) + }) + test('normalizes Cosmos databases as cloud database resources', async () => { const adapter = new AzureDatabaseAdapter(testClient({ '/dbs': { diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts index 8cb69d5..7608654 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts +++ b/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts @@ -35,10 +35,23 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { } async list(query: ResourceQuery = {}): Promise { + const [cosmosResult, sqlResult] = await Promise.allSettled([ + this.listCosmosDatabases(query), + this.sqlAdapter.list(query), + ]) + if (cosmosResult.status === 'rejected' && sqlResult.status === 'rejected') { + throw new Error(`Azure database listing failed. Cosmos: ${errorMessage(cosmosResult.reason)} | SQL: ${errorMessage(sqlResult.reason)}`) + } + return [ + ...(cosmosResult.status === 'fulfilled' ? cosmosResult.value : []), + ...(sqlResult.status === 'fulfilled' ? sqlResult.value : []), + ] + } + + private async listCosmosDatabases(query: ResourceQuery): Promise { const body = await this.cosmosJson>('/dbs', {method: 'GET'}, {emptyOnNotFound: true}) const databases = body?.Databases ?? [] - const cosmosResources = filterBySearch(databases.map(toDatabaseResource), query.search) - return [...cosmosResources, ...await this.sqlAdapter.list(query)] + return filterBySearch(databases.map(toDatabaseResource), query.search) } async get(id: string): Promise { diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.ts index c9933c8..f0985a7 100644 --- a/packages/api/src/adapter-azure/AzureSqlAdapter.ts +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.ts @@ -108,7 +108,8 @@ function sqlServerPath(serverName: string): string { } function withApiVersion(path: string): string { - return `${path}?api-version=${API_VERSION}` + const separator = path.includes('?') ? '&' : '?' + return `${path}${separator}api-version=${API_VERSION}` } function sqlServerName(id: string): string { From 5b8b7e906f30cd0811d8b3105a8ebad8bacbb72f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:58:14 +0100 Subject: [PATCH 3/7] refactor(azure): split Cosmos into NoSQL --- README.md | 24 ++++-- ...pter.test.ts => AzureNoSqlAdapter.test.ts} | 52 +++---------- ...atabaseAdapter.ts => AzureNoSqlAdapter.ts} | 36 ++------- packages/api/src/cloud-spi/databaseSchema.ts | 41 +--------- packages/api/src/cloud-spi/noSqlSchema.ts | 76 +++++++++++++++++++ packages/api/src/cloud-spi/types.ts | 6 +- packages/api/src/cloudProxy.ts | 6 +- packages/api/src/routes/clouds.test.ts | 75 ++++++++++++------ packages/api/src/routes/clouds.ts | 16 ++-- packages/api/src/service/CloudProxyService.ts | 36 +++++---- packages/frontend/src/api/api.ts | 44 +++++------ packages/frontend/src/api/cloudProxyClient.ts | 28 +++---- .../src/components/DynamicFormRenderer.tsx | 8 +- .../src/components/DynamicResourceView.tsx | 6 +- packages/frontend/src/components/Layout.tsx | 14 +++- .../frontend/src/pages/CloudExplorerPage.tsx | 6 +- packages/frontend/src/types/cloud.ts | 2 +- packages/frontend/src/types/schema.ts | 4 - 18 files changed, 257 insertions(+), 223 deletions(-) rename packages/api/src/adapter-azure/{AzureDatabaseAdapter.test.ts => AzureNoSqlAdapter.test.ts} (72%) rename packages/api/src/adapter-azure/{AzureDatabaseAdapter.ts => AzureNoSqlAdapter.ts} (89%) create mode 100644 packages/api/src/cloud-spi/noSqlSchema.ts diff --git a/README.md b/README.md index 498fe53..81314db 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ This table is the source of truth for the current UI surface. | Console Home | Yes | Yes | Yes | Cloud-aware overview page with runtime status and service cards. | | Cloud Explorer / Storage | Yes | Yes | Yes | Unified storage view with resource table, inspector, object browser, and schema-driven actions. | | Cloud Explorer / k8s Engine | Yes | Placeholder | Placeholder | AWS EKS list/inspect is wired. | -| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect; Azure SQL server and Cosmos DB NoSQL workflows. | +| Cloud Explorer / Database | Yes | Yes | Placeholder | AWS RDS list/inspect; Azure SQL server workflows. | +| Cloud Explorer / NoSQL | Placeholder | Yes | Placeholder | Azure Cosmos DB NoSQL database, container, document, and query workflows. | | Cloud Explorer / Compute | Yes | Placeholder | Placeholder | AWS EC2 and AMI workflows. | | Cloud Explorer / Networking | Yes | Placeholder | Placeholder | AWS VPC/networking workflows. | | Cloud Explorer / Serverless | Yes | Not exposed in navigation | Not exposed in navigation | AWS Lambda flows through the unified shell. | @@ -105,24 +106,31 @@ Current gaps:
Database -Three different database models are currently exposed under one category: +Relational database services use this category: - AWS RDS: list and inspect oriented. - Azure SQL Database: server list, create, delete, and inspect workflows. -- Azure Cosmos DB NoSQL: database, container, and document workflows. -Cosmos DB currently includes: +Current gaps: + +- No unified cross-provider relational database contract beyond the shared category shell. +- No GCP database adapter yet. + +
+ +
+NoSQL + +Azure Cosmos DB NoSQL is exposed separately from relational database services: - List, create, and delete databases. - List, create, and delete containers. - Create, edit, and delete documents/items. -- SQL query editor for documents. +- SQL query editor for Cosmos DB NoSQL documents. Current gaps: -- No unified cross-provider database contract beyond the shared category shell. -- No GCP database adapter yet. -- AWS DynamoDB is not rebuilt into the new Cloud Explorer model yet. +- AWS DynamoDB and Google Firestore are placeholders in the new Cloud Explorer model.
diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts b/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts similarity index 72% rename from packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts rename to packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts index 4e51cb2..f2be243 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureNoSqlAdapter.test.ts @@ -1,42 +1,10 @@ import {describe, expect, test} from 'bun:test' -import {AzureDatabaseAdapter} from './AzureDatabaseAdapter' -import {AzureSqlAdapter} from './AzureSqlAdapter' +import {AzureNoSqlAdapter} from './AzureNoSqlAdapter' import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' -describe('AzureDatabaseAdapter', () => { - test('combines Cosmos databases and Azure SQL servers', async () => { - const client = testClient({ - '/dbs': {_count: 1, Databases: [{id: 'cosmosdb'}]}, - '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers?api-version=2021-11-01': { - value: [{name: 'app-sql', location: 'eastus', properties: {state: 'Ready'}}], - }, - }) - const adapter = new AzureDatabaseAdapter(client, new AzureSqlAdapter(client)) - - await expect(adapter.list()).resolves.toMatchObject([ - {id: 'cosmosdb', type: 'cosmos-database'}, - {id: 'sql-server:app-sql', type: 'sql-server'}, - ]) - }) - - test('lists SQL servers when Cosmos routes are not implemented', async () => { - const client = testClient({ - '/dbs': 'not-implemented', - '/devstoreaccount1-cosmos/dbs': 'not-implemented', - '/devstoreaccount1-cosmos-nosql/dbs': 'not-implemented', - '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers?api-version=2021-11-01': { - value: [{name: 'sql-only', location: 'eastus', properties: {state: 'Ready'}}], - }, - }) - const adapter = new AzureDatabaseAdapter(client) - - await expect(adapter.list()).resolves.toMatchObject([ - {id: 'sql-server:sql-only', type: 'sql-server'}, - ]) - }) - - test('normalizes Cosmos databases as cloud database resources', async () => { - const adapter = new AzureDatabaseAdapter(testClient({ +describe('AzureNoSqlAdapter', () => { + test('normalizes Cosmos databases as cloud NoSQL resources', async () => { + const adapter = new AzureNoSqlAdapter(testClient({ '/dbs': { _count: 1, Databases: [{id: 'appdb', _rid: 'db-rid', _etag: '"etag"', _ts: 1779307200}], @@ -47,7 +15,7 @@ describe('AzureDatabaseAdapter', () => { id: 'appdb', name: 'appdb', cloud: 'azure', - service: 'database', + service: 'nosql', type: 'cosmos-database', region: null, createdAt: '2026-05-20T20:00:00.000Z', @@ -67,7 +35,7 @@ describe('AzureDatabaseAdapter', () => { }) test('lists containers and documents from the selected database', async () => { - const adapter = new AzureDatabaseAdapter(testClient({ + const adapter = new AzureNoSqlAdapter(testClient({ '/dbs/appdb/colls': { _count: 1, DocumentCollections: [{ @@ -105,7 +73,7 @@ describe('AzureDatabaseAdapter', () => { test('sends document partition key when deleting an item', async () => { const calls: Array<{path: string; init: RequestInit}> = [] - const adapter = new AzureDatabaseAdapter(testClient({ + const adapter = new AzureNoSqlAdapter(testClient({ '/dbs/appdb/colls/items/docs/item-1': null, }, calls)) @@ -118,7 +86,7 @@ describe('AzureDatabaseAdapter', () => { test('queries documents using the Cosmos SQL query endpoint', async () => { const calls: Array<{path: string; init: RequestInit}> = [] - const adapter = new AzureDatabaseAdapter(testClient({ + const adapter = new AzureNoSqlAdapter(testClient({ '/dbs/appdb/colls/items/docs': { _count: 1, Documents: [{id: 'item-1'}], @@ -136,7 +104,7 @@ describe('AzureDatabaseAdapter', () => { test('falls back to account-suffixed path when root routing is not implemented', async () => { const calls: Array<{path: string; init: RequestInit}> = [] - const adapter = new AzureDatabaseAdapter(testClient({ + const adapter = new AzureNoSqlAdapter(testClient({ '/dbs': 'not-implemented', '/devstoreaccount1-cosmos/dbs': { _count: 1, @@ -150,7 +118,7 @@ describe('AzureDatabaseAdapter', () => { test('falls back to named NoSQL engine path after root and default account routes fail', async () => { const calls: Array<{path: string; init: RequestInit}> = [] - const adapter = new AzureDatabaseAdapter(testClient({ + const adapter = new AzureNoSqlAdapter(testClient({ '/dbs': 'not-implemented', '/devstoreaccount1-cosmos/dbs': 'not-implemented', '/devstoreaccount1-cosmos-nosql/dbs': { diff --git a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts b/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts similarity index 89% rename from packages/api/src/adapter-azure/AzureDatabaseAdapter.ts rename to packages/api/src/adapter-azure/AzureNoSqlAdapter.ts index 7608654..7e777da 100644 --- a/packages/api/src/adapter-azure/AzureDatabaseAdapter.ts +++ b/packages/api/src/adapter-azure/AzureNoSqlAdapter.ts @@ -1,5 +1,5 @@ import {azure, type AzureRuntimeClient} from '../azure' -import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' import type { CloudResource, CloudServiceAdapter, @@ -10,7 +10,6 @@ import type { ResourceQuery, ServiceSchema, } from '../cloud-spi/types' -import {AzureSqlAdapter, isSqlServerResourceId} from './AzureSqlAdapter' interface CosmosListResponse { _count?: number @@ -21,47 +20,28 @@ interface CosmosListResponse { type CosmosRecord = Record -export class AzureDatabaseAdapter implements CloudServiceAdapter { +export class AzureNoSqlAdapter implements CloudServiceAdapter { readonly cloud = 'azure' as const - readonly service = 'database' as const + readonly service = 'nosql' as const - constructor( - private readonly client: AzureRuntimeClient = azure, - private readonly sqlAdapter: AzureSqlAdapter = new AzureSqlAdapter(client), - ) {} + constructor(private readonly client: AzureRuntimeClient = azure) {} schema(): ServiceSchema { - return azureDatabaseSchema() + return azureNoSqlSchema() } async list(query: ResourceQuery = {}): Promise { - const [cosmosResult, sqlResult] = await Promise.allSettled([ - this.listCosmosDatabases(query), - this.sqlAdapter.list(query), - ]) - if (cosmosResult.status === 'rejected' && sqlResult.status === 'rejected') { - throw new Error(`Azure database listing failed. Cosmos: ${errorMessage(cosmosResult.reason)} | SQL: ${errorMessage(sqlResult.reason)}`) - } - return [ - ...(cosmosResult.status === 'fulfilled' ? cosmosResult.value : []), - ...(sqlResult.status === 'fulfilled' ? sqlResult.value : []), - ] - } - - private async listCosmosDatabases(query: ResourceQuery): Promise { const body = await this.cosmosJson>('/dbs', {method: 'GET'}, {emptyOnNotFound: true}) const databases = body?.Databases ?? [] return filterBySearch(databases.map(toDatabaseResource), query.search) } async get(id: string): Promise { - if (isSqlServerResourceId(id)) return this.sqlAdapter.get(id) const body = await this.cosmosJson(`/dbs/${encodeSegment(id)}`, {method: 'GET'}, {emptyOnNotFound: true}) return body ? toDatabaseResource(body) : null } async create(input: CreateResourceInput): Promise { - if (input.values.resourceType === 'sql-server') return this.sqlAdapter.create(input) const databaseName = stringValue(input.values.databaseName) if (!databaseName) throw new Error('databaseName is required') if (!isValidCosmosId(databaseName)) throw new Error('Use a valid Cosmos database name.') @@ -75,10 +55,6 @@ export class AzureDatabaseAdapter implements CloudServiceAdapter { } async delete(id: string): Promise { - if (isSqlServerResourceId(id)) { - await this.sqlAdapter.delete(id) - return - } await this.cosmosFetch(`/dbs/${encodeSegment(id)}`, {method: 'DELETE'}) } @@ -238,7 +214,7 @@ function toDatabaseResource(database: CosmosRecord): CloudResource { id: name, name, cloud: 'azure', - service: 'database', + service: 'nosql', type: 'cosmos-database', region: null, createdAt: timestampToIso(database._ts), diff --git a/packages/api/src/cloud-spi/databaseSchema.ts b/packages/api/src/cloud-spi/databaseSchema.ts index 7095291..9bb19f0 100644 --- a/packages/api/src/cloud-spi/databaseSchema.ts +++ b/packages/api/src/cloud-spi/databaseSchema.ts @@ -28,27 +28,13 @@ export function azureDatabaseSchema(): ServiceSchema { return { cloud: 'azure', service: 'database', - displayName: 'Azure SQL & Cosmos DB', + displayName: 'Azure SQL Database', fields: [ - { - name: 'resourceType', - label: 'Database Service', - type: 'select', - required: true, - span: true, - defaultValue: 'sql-server', - options: [ - {label: 'Azure SQL Server', value: 'sql-server'}, - {label: 'Cosmos DB Database', value: 'cosmos-database'}, - ], - }, { name: 'serverName', label: 'Server Name', type: 'text', required: true, - group: 'Azure SQL Server', - visibleWhen: {field: 'resourceType', equals: 'sql-server'}, validation: { minLength: 1, maxLength: 63, @@ -62,7 +48,6 @@ export function azureDatabaseSchema(): ServiceSchema { type: 'text', required: true, defaultValue: 'eastus', - visibleWhen: {field: 'resourceType', equals: 'sql-server'}, }, { name: 'administratorLogin', @@ -70,7 +55,6 @@ export function azureDatabaseSchema(): ServiceSchema { type: 'text', required: true, defaultValue: 'sa', - visibleWhen: {field: 'resourceType', equals: 'sql-server'}, }, { name: 'administratorLoginPassword', @@ -79,41 +63,24 @@ export function azureDatabaseSchema(): ServiceSchema { required: true, span: true, description: 'Required by the local Azure SQL runtime. The value is not returned by the API.', - visibleWhen: {field: 'resourceType', equals: 'sql-server'}, validation: { minLength: 8, message: 'Use at least 8 characters.', }, }, - { - name: 'databaseName', - label: 'Database Name', - type: 'text', - required: true, - group: 'Cosmos DB', - span: true, - visibleWhen: {field: 'resourceType', equals: 'cosmos-database'}, - validation: { - minLength: 1, - maxLength: 255, - pattern: '^[A-Za-z0-9._-]+$', - message: 'Use letters, numbers, dot, underscore, or dash.', - }, - }, ], actions: ['list', 'create', 'delete', 'inspect'], capabilities: { resourceActions: [ - {name: 'list', label: 'List database resources', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'create', label: 'Create database resource', enabled: true, status: 'available', runtimeRequired: true}, - {name: 'delete', label: 'Delete database resource', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'list', label: 'List SQL servers', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create SQL server', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete SQL server', enabled: true, status: 'available', runtimeRequired: true}, {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, ], }, filters: databaseFilters, columns: [ {name: 'name', label: 'Name'}, - {name: 'type', label: 'Resource Type'}, {name: 'engine', label: 'Engine'}, {name: 'status', label: 'Status'}, {name: 'region', label: 'Region'}, diff --git a/packages/api/src/cloud-spi/noSqlSchema.ts b/packages/api/src/cloud-spi/noSqlSchema.ts new file mode 100644 index 0000000..d65fe68 --- /dev/null +++ b/packages/api/src/cloud-spi/noSqlSchema.ts @@ -0,0 +1,76 @@ +import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types' + +const noSqlFilters: FieldSchema[] = [ + {name: 'search', label: 'Search', type: 'text', required: false}, +] + +const noSqlColumns: TableColumnSchema[] = [ + {name: 'name', label: 'Name'}, + {name: 'engine', label: 'Engine'}, + {name: 'status', label: 'Status'}, + {name: 'createdAt', label: 'Created At'}, +] + +export function awsNoSqlSchema(): ServiceSchema { + return { + cloud: 'aws', + service: 'nosql', + displayName: 'Amazon DynamoDB', + fields: [], + actions: ['list', 'inspect'], + filters: noSqlFilters, + columns: noSqlColumns, + } +} + +export function azureNoSqlSchema(): ServiceSchema { + return { + cloud: 'azure', + service: 'nosql', + displayName: 'Azure Cosmos DB NoSQL', + fields: [ + { + name: 'databaseName', + label: 'Database Name', + type: 'text', + required: true, + validation: { + minLength: 1, + maxLength: 255, + pattern: '^[A-Za-z0-9._-]+$', + message: 'Use letters, numbers, dot, underscore, or dash.', + }, + }, + ], + actions: ['list', 'create', 'delete', 'inspect'], + capabilities: { + resourceActions: [ + {name: 'list', label: 'List Cosmos databases', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'create', label: 'Create Cosmos database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'delete', label: 'Delete Cosmos database', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'inspect', label: 'Inspect metadata', enabled: true, status: 'available', runtimeRequired: true}, + ], + }, + filters: noSqlFilters, + columns: noSqlColumns, + } +} + +export function gcpNoSqlSchema(): ServiceSchema { + return { + cloud: 'gcp', + service: 'nosql', + displayName: 'Google Firestore', + fields: [], + actions: ['list', 'inspect'], + filters: noSqlFilters, + columns: noSqlColumns, + } +} + +export function noSqlSchemaFor(cloud: CloudProvider): ServiceSchema | null { + if (cloud === 'aws') return awsNoSqlSchema() + if (cloud === 'azure') return azureNoSqlSchema() + if (cloud === 'gcp') return gcpNoSqlSchema() + return null +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 58fba4c..1f49797 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'serverless' | 'compute' | 'networking' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'nosql' | 'serverless' | 'compute' | 'networking' export type CloudAvailability = 'available' | 'coming_soon' @@ -37,10 +37,6 @@ export interface FieldSchema { group?: string span?: boolean defaultValue?: string - visibleWhen?: { - field: string - equals: string - } validation?: { pattern?: string minLength?: number diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index b9740c0..92807b7 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -4,7 +4,8 @@ import {AwsNetworkingAdapter} from './adapter-aws/AwsNetworkingAdapter' import {AwsDatabaseAdapter} from './adapter-aws/AwsDatabaseAdapter' import {AwsEksAdapter} from './adapter-aws/AwsEksAdapter' import {AwsStorageAdapter} from './adapter-aws/AwsStorageAdapter' -import {AzureDatabaseAdapter} from './adapter-azure/AzureDatabaseAdapter' +import {AzureNoSqlAdapter} from './adapter-azure/AzureNoSqlAdapter' +import {AzureSqlAdapter} from './adapter-azure/AzureSqlAdapter' import {AzureStorageAdapter} from './adapter-azure/AzureStorageAdapter' import {GcpStorageAdapter} from './adapter-gcp/GcpStorageAdapter' import {GcpCloudFunctionsAdapter} from './adapter-gcp/GcpCloudFunctionsAdapter' @@ -34,7 +35,8 @@ export function createCloudProxyService(accountId?: string | null): CloudProxySe new AwsNetworkingAdapter(ec2Service), new AwsServerlessAdapter(clients.lambda), new AzureStorageAdapter(), - new AzureDatabaseAdapter(), + new AzureSqlAdapter(), + new AzureNoSqlAdapter(), new GcpStorageAdapter(), new GcpCloudFunctionsAdapter(), new AzureServerlessAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 2ae4714..8462376 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -1,6 +1,7 @@ import {describe, expect, test} from 'bun:test' import {Hono} from 'hono' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' +import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema' import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' @@ -79,13 +80,27 @@ describe('cloud schema routes', () => { expect(res.status).toBe(200) expect(body.cloud).toBe('azure') expect(body.service).toBe('database') - expect(body.displayName).toBe('Azure SQL & Cosmos DB') + expect(body.displayName).toBe('Azure SQL Database') expect(body.fields).toEqual(expect.arrayContaining([ - expect.objectContaining({name: 'resourceType', defaultValue: 'sql-server'}), - expect.objectContaining({name: 'serverName', visibleWhen: {field: 'resourceType', equals: 'sql-server'}}), + expect.objectContaining({name: 'serverName'}), + expect.objectContaining({name: 'administratorLoginPassword', type: 'password'}), ])) }) + test('returns Azure NoSQL schema when the adapter is registered', async () => { + const app = appWithRoutes([mockAdapter('azure', { + service: 'nosql', + schema: azureNoSqlSchema, + })]) + const res = await app.request('/api/clouds/azure/services/nosql/schema') + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.service).toBe('nosql') + expect(body.displayName).toBe('Azure Cosmos DB NoSQL') + expect(body.fields[0].name).toBe('databaseName') + }) + test('returns GCP storage schema', async () => { const res = await appWithRoutes().request('/api/clouds/gcp/services/storage/schema') const body = await res.json() @@ -115,11 +130,21 @@ describe('cloud schema routes', () => { const gcpBody = await gcpRes.json() expect(azureRes.status).toBe(200) - expect(azureBody.displayName).toBe('Azure SQL & Cosmos DB') + expect(azureBody.displayName).toBe('Azure SQL Database') expect(gcpRes.status).toBe(200) expect(gcpBody.displayName).toBe('Cloud SQL') }) + test('returns provider NoSQL schemas without registered adapters', async () => { + const awsRes = await appWithRoutes().request('/api/clouds/aws/services/nosql/schema') + const azureRes = await appWithRoutes().request('/api/clouds/azure/services/nosql/schema') + const gcpRes = await appWithRoutes().request('/api/clouds/gcp/services/nosql/schema') + + expect((await awsRes.json()).displayName).toBe('Amazon DynamoDB') + expect((await azureRes.json()).displayName).toBe('Azure Cosmos DB NoSQL') + expect((await gcpRes.json()).displayName).toBe('Google Firestore') + }) + test('returns AWS cloud status', async () => { const res = await appWithRoutes().request('/api/clouds/aws/status') const body = await res.json() @@ -150,10 +175,10 @@ describe('cloud schema routes', () => { expect(body.objects[0].name).toBe('object.txt') }) - test('lists Cosmos containers through the cloud database adapter', async () => { + test('lists Cosmos containers through the cloud NoSQL adapter', async () => { const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, listCosmosContainers: async (databaseId: string): Promise => [{ id: 'items', name: 'items', @@ -164,7 +189,7 @@ describe('cloud schema routes', () => { }], })]) - const res = await app.request('/api/clouds/azure/services/database/resources/appdb/containers') + const res = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers') const body = await res.json() expect(res.status).toBe(200) @@ -172,16 +197,16 @@ describe('cloud schema routes', () => { expect(body[0].name).toBe('items') }) - test('creates and deletes Cosmos databases through the cloud database adapter', async () => { + test('creates and deletes Cosmos databases through the cloud NoSQL adapter', async () => { const deleted: string[] = [] const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, create: async (input: CreateResourceInput): Promise => ({ id: String(input.values.databaseName), name: String(input.values.databaseName), cloud: 'azure', - service: 'database', + service: 'nosql', type: 'cosmos-database', region: null, createdAt: null, @@ -192,12 +217,12 @@ describe('cloud schema routes', () => { }, })]) - const createRes = await app.request('/api/clouds/azure/services/database/resources', { + const createRes = await app.request('/api/clouds/azure/services/nosql/resources', { method: 'POST', body: JSON.stringify({databaseName: 'appdb'}), }) const created = await createRes.json() - const deleteRes = await app.request('/api/clouds/azure/services/database/resources/appdb', {method: 'DELETE'}) + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb', {method: 'DELETE'}) expect(createRes.status).toBe(201) expect(created.type).toBe('cosmos-database') @@ -206,11 +231,11 @@ describe('cloud schema routes', () => { expect(deleted).toEqual(['appdb']) }) - test('creates and deletes Cosmos containers through the cloud database adapter', async () => { + test('creates and deletes Cosmos containers through the cloud NoSQL adapter', async () => { const deleted: Array<{databaseId: string; containerId: string}> = [] const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, createCosmosContainer: async (databaseId: string, input: CreateResourceInput): Promise => ({ id: String(input.values.containerName), name: String(input.values.containerName), @@ -224,12 +249,12 @@ describe('cloud schema routes', () => { }, })]) - const createRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers', { + const createRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers', { method: 'POST', body: JSON.stringify({containerName: 'items', partitionKeyPath: '/category'}), }) const created = await createRes.json() - const deleteRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items', {method: 'DELETE'}) + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items', {method: 'DELETE'}) expect(createRes.status).toBe(201) expect(created.databaseId).toBe('appdb') @@ -238,11 +263,11 @@ describe('cloud schema routes', () => { expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items'}]) }) - test('upserts, deletes, and queries Cosmos items through the cloud database adapter', async () => { + test('upserts, deletes, and queries Cosmos items through the cloud NoSQL adapter', async () => { const deleted: Array<{databaseId: string; containerId: string; itemId: string; partitionKey?: string | null}> = [] const app = appWithRoutes([mockAdapter('azure', { - service: 'database', - schema: azureDatabaseSchema, + service: 'nosql', + schema: azureNoSqlSchema, upsertCosmosItem: async (databaseId: string, containerId: string, document: Record): Promise => ({ id: String(document.id), databaseId, @@ -261,17 +286,17 @@ describe('cloud schema routes', () => { }), })]) - const upsertRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items/items', { + const upsertRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items', { method: 'POST', body: JSON.stringify({id: 'item-1', category: 'demo'}), }) const upserted = await upsertRes.json() - const queryRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items/query', { + const queryRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/query', { method: 'POST', body: JSON.stringify({query: 'SELECT * FROM c'}), }) const queryBody = await queryRes.json() - const deleteRes = await app.request('/api/clouds/azure/services/database/resources/appdb/containers/items/items/item-1?partitionKey=demo', {method: 'DELETE'}) + const deleteRes = await app.request('/api/clouds/azure/services/nosql/resources/appdb/containers/items/items/item-1?partitionKey=demo', {method: 'DELETE'}) expect(upsertRes.status).toBe(201) expect(upserted.partitionKey).toBe('demo') diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 93cb831..7a89b1d 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -54,7 +54,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.get('/:cloud/services/database/resources/:id/containers', async (c) => { + app.get('/:cloud/services/nosql/resources/:id/containers', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -64,7 +64,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.post('/:cloud/services/database/resources/:id/containers', async (c) => { + app.post('/:cloud/services/nosql/resources/:id/containers', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -75,7 +75,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.delete('/:cloud/services/database/resources/:id/containers/:containerId', async (c) => { + app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -85,7 +85,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.get('/:cloud/services/database/resources/:id/containers/:containerId/items', async (c) => { + app.get('/:cloud/services/nosql/resources/:id/containers/:containerId/items', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -95,7 +95,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.post('/:cloud/services/database/resources/:id/containers/:containerId/items', async (c) => { + app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/items', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -106,7 +106,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.delete('/:cloud/services/database/resources/:id/containers/:containerId/items/:itemId', async (c) => { + app.delete('/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -116,7 +116,7 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) - app.post('/:cloud/services/database/resources/:id/containers/:containerId/query', async (c) => { + app.post('/:cloud/services/nosql/resources/:id/containers/:containerId/query', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) @@ -261,7 +261,7 @@ function isCloudProvider(value: string): value is CloudProvider { } function isServiceType(value: string): value is CloudServiceType { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'serverless' || value === 'compute' || value === 'networking' + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'nosql' || value === 'serverless' || value === 'compute' || value === 'networking' } async function withRuntime(c: Context, handler: () => Promise): Promise { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 826bf12..07d49f0 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -20,6 +20,7 @@ import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {serverlessSchemaFor} from '../cloud-spi/serverlessSchema' import {k8sSchemaFor} from '../cloud-spi/eksSchema' import {databaseSchemaFor} from '../cloud-spi/databaseSchema' +import {noSqlSchemaFor} from '../cloud-spi/noSqlSchema' import {azureEndpoint} from '../azure' import {checkGcpRuntime, gcpEndpoint} from '../gcp' @@ -55,6 +56,12 @@ export class CloudProxyService { displayName: 'Database', availability: this.registry.get(cloud, 'database') ? 'available' : 'coming_soon', }) + services.push({ + cloud, + service: 'nosql', + displayName: 'NoSQL', + availability: this.registry.get(cloud, 'nosql') ? 'available' : 'coming_soon', + }) services.push({ cloud, service: 'serverless', @@ -82,6 +89,7 @@ export class CloudProxyService { if (service === 'storage') return storageSchemaFor(cloud) if (service === 'k8s') return k8sSchemaFor(cloud) if (service === 'database') return databaseSchemaFor(cloud) + if (service === 'nosql') return noSqlSchemaFor(cloud) if (service === 'serverless') return serverlessSchemaFor(cloud) return null } @@ -200,44 +208,44 @@ async invokeResource( } async listCosmosContainers(cloud: CloudProvider, databaseId: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.listCosmosContainers) throw new Error(`Cosmos containers are not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listCosmosContainers) throw new Error(`Cosmos containers are not supported for ${cloud}/nosql`) return adapter.listCosmosContainers(databaseId) } async createCosmosContainer(cloud: CloudProvider, databaseId: string, input: CreateResourceInput): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.createCosmosContainer) throw new Error(`Cosmos container creation is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.createCosmosContainer) throw new Error(`Cosmos container creation is not supported for ${cloud}/nosql`) return adapter.createCosmosContainer(databaseId, input) } async deleteCosmosContainer(cloud: CloudProvider, databaseId: string, containerId: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.deleteCosmosContainer) throw new Error(`Cosmos container deletion is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.deleteCosmosContainer) throw new Error(`Cosmos container deletion is not supported for ${cloud}/nosql`) await adapter.deleteCosmosContainer(databaseId, containerId) } async listCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.listCosmosItems) throw new Error(`Cosmos items are not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.listCosmosItems) throw new Error(`Cosmos items are not supported for ${cloud}/nosql`) return adapter.listCosmosItems(databaseId, containerId) } async upsertCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, document: Record): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.upsertCosmosItem) throw new Error(`Cosmos item upsert is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.upsertCosmosItem) throw new Error(`Cosmos item upsert is not supported for ${cloud}/nosql`) return adapter.upsertCosmosItem(databaseId, containerId, document) } async deleteCosmosItem(cloud: CloudProvider, databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.deleteCosmosItem) throw new Error(`Cosmos item deletion is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.deleteCosmosItem) throw new Error(`Cosmos item deletion is not supported for ${cloud}/nosql`) await adapter.deleteCosmosItem(databaseId, containerId, itemId, partitionKey) } async queryCosmosItems(cloud: CloudProvider, databaseId: string, containerId: string, query: string): Promise { - const adapter = this.requireAdapter(cloud, 'database') - if (!adapter.queryCosmosItems) throw new Error(`Cosmos query is not supported for ${cloud}/database`) + const adapter = this.requireAdapter(cloud, 'nosql') + if (!adapter.queryCosmosItems) throw new Error(`Cosmos query is not supported for ${cloud}/nosql`) return adapter.queryCosmosItems(databaseId, containerId, query) } diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index a45b849..ab21b5c 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -30,18 +30,18 @@ export const apiEndpointKeys = { copy: "clouds.services.storage.objects.copy", }, }, - database: { + nosql: { cosmos: { containers: { - list: "clouds.services.database.cosmos.containers.list", - create: "clouds.services.database.cosmos.containers.create", - delete: "clouds.services.database.cosmos.containers.delete", + list: "clouds.services.nosql.cosmos.containers.list", + create: "clouds.services.nosql.cosmos.containers.create", + delete: "clouds.services.nosql.cosmos.containers.delete", }, items: { - list: "clouds.services.database.cosmos.items.list", - upsert: "clouds.services.database.cosmos.items.upsert", - delete: "clouds.services.database.cosmos.items.delete", - query: "clouds.services.database.cosmos.items.query", + list: "clouds.services.nosql.cosmos.items.list", + upsert: "clouds.services.nosql.cosmos.items.upsert", + delete: "clouds.services.nosql.cosmos.items.delete", + query: "clouds.services.nosql.cosmos.items.query", }, }, }, @@ -293,57 +293,57 @@ export const endpointRegistry: EndpointRegistry = new Map([ }, ], [ - apiEndpointKeys.clouds.database.cosmos.containers.list, + apiEndpointKeys.clouds.nosql.cosmos.containers.list, { - path: "/clouds/:cloud/services/database/resources/:id/containers", + path: "/clouds/:cloud/services/nosql/resources/:id/containers", method: "GET", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.containers.create, + apiEndpointKeys.clouds.nosql.cosmos.containers.create, { - path: "/clouds/:cloud/services/database/resources/:id/containers", + path: "/clouds/:cloud/services/nosql/resources/:id/containers", method: "POST", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.containers.delete, + apiEndpointKeys.clouds.nosql.cosmos.containers.delete, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId", method: "DELETE", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.list, + apiEndpointKeys.clouds.nosql.cosmos.items.list, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/items", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items", method: "GET", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.upsert, + apiEndpointKeys.clouds.nosql.cosmos.items.upsert, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/items", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items", method: "POST", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.delete, + apiEndpointKeys.clouds.nosql.cosmos.items.delete, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/items/:itemId", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/items/:itemId", method: "DELETE", telemetry: { service: "cloud-proxy" }, }, ], [ - apiEndpointKeys.clouds.database.cosmos.items.query, + apiEndpointKeys.clouds.nosql.cosmos.items.query, { - path: "/clouds/:cloud/services/database/resources/:id/containers/:containerId/query", + path: "/clouds/:cloud/services/nosql/resources/:id/containers/:containerId/query", method: "POST", telemetry: { service: "cloud-proxy" }, }, diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 9bd9fc7..c633da9 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -229,8 +229,8 @@ export async function listCosmosContainers( signal?: AbortSignal, ): Promise { const res = await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.containers.list, - requestOptions(cloud, "database", { signal }), + apiEndpointKeys.clouds.nosql.cosmos.containers.list, + requestOptions(cloud, "nosql", { signal }), databasePathParams(cloud, databaseId), ); return res.data; @@ -243,8 +243,8 @@ export async function createCosmosContainer( signal?: AbortSignal, ): Promise { const res = await apiClient.call>( - apiEndpointKeys.clouds.database.cosmos.containers.create, - requestOptions(cloud, "database", { signal, body: values }), + apiEndpointKeys.clouds.nosql.cosmos.containers.create, + requestOptions(cloud, "nosql", { signal, body: values }), databasePathParams(cloud, databaseId), ); return res.data; @@ -257,8 +257,8 @@ export async function deleteCosmosContainer( signal?: AbortSignal, ): Promise { await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.containers.delete, - requestOptions(cloud, "database", { signal }), + apiEndpointKeys.clouds.nosql.cosmos.containers.delete, + requestOptions(cloud, "nosql", { signal }), { ...databasePathParams(cloud, databaseId), containerId }, ); } @@ -272,8 +272,8 @@ export async function listCosmosItems( signal?: AbortSignal, ): Promise { const res = await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.items.list, - requestOptions(cloud, "database", { signal }), + apiEndpointKeys.clouds.nosql.cosmos.items.list, + requestOptions(cloud, "nosql", { signal }), { ...databasePathParams(cloud, databaseId), containerId }, ); return res.data; @@ -287,8 +287,8 @@ export async function upsertCosmosItem( signal?: AbortSignal, ): Promise { const res = await apiClient.call>( - apiEndpointKeys.clouds.database.cosmos.items.upsert, - requestOptions(cloud, "database", { signal, body: document }), + apiEndpointKeys.clouds.nosql.cosmos.items.upsert, + requestOptions(cloud, "nosql", { signal, body: document }), { ...databasePathParams(cloud, databaseId), containerId }, ); return res.data; @@ -303,8 +303,8 @@ export async function deleteCosmosItem( signal?: AbortSignal, ): Promise { await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.items.delete, - requestOptions(cloud, "database", { + apiEndpointKeys.clouds.nosql.cosmos.items.delete, + requestOptions(cloud, "nosql", { signal, params: partitionKey ? { partitionKey } : undefined, }), @@ -320,8 +320,8 @@ export async function queryCosmosItems( signal?: AbortSignal, ): Promise { const res = await apiClient.call( - apiEndpointKeys.clouds.database.cosmos.items.query, - requestOptions(cloud, "database", { signal, body: { query } }), + apiEndpointKeys.clouds.nosql.cosmos.items.query, + requestOptions(cloud, "nosql", { signal, body: { query } }), { ...databasePathParams(cloud, databaseId), containerId }, ); return res.data; diff --git a/packages/frontend/src/components/DynamicFormRenderer.tsx b/packages/frontend/src/components/DynamicFormRenderer.tsx index 69d7e75..271ac67 100644 --- a/packages/frontend/src/components/DynamicFormRenderer.tsx +++ b/packages/frontend/src/components/DynamicFormRenderer.tsx @@ -22,7 +22,7 @@ export function DynamicFormRenderer({schema, isSubmitting, submitLabel = 'Create function submit(event: FormEvent) { event.preventDefault() - const nextErrors = validateValues(visibleFields(schema.fields, values), values) + const nextErrors = validateValues(schema.fields, values) setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return onSubmit(values) @@ -30,7 +30,7 @@ export function DynamicFormRenderer({schema, isSubmitting, submitLabel = 'Create return ( - {visibleFields(schema.fields, values).map((field) => ( + {schema.fields.map((field) => ( { return Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? ''])) } -function visibleFields(fields: FieldSchema[], values: Record): FieldSchema[] { - return fields.filter((field) => !field.visibleWhen || values[field.visibleWhen.field] === field.visibleWhen.equals) -} - function validateValues(fields: FieldSchema[], values: Record): Record { const errors: Record = {} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index ad4f487..5fbcb26 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -311,7 +311,7 @@ export function DynamicResourceView({ runtimeReachable={runtimeReachable} /> )} - {service === "database" && cloud === "azure" && activeSelected?.type === "cosmos-database" && ( + {service === "nosql" && cloud === "azure" && activeSelected?.type === "cosmos-database" && ( Cloud Services ยท {cloudLabel} {CLOUD_SERVICE_ITEMS.map((service) => { const Icon = CLOUD_SERVICE_ICONS[service.name] - const label = cloud === 'azure' && service.name === 'database' ? 'Azure SQL / Cosmos DB' : service.label + const label = cloudServiceLabel(cloud, service) const available = service.name === 'storage' || (service.name === 'secretsmanager' && cloud === 'aws') || (service.name === 'database' && (cloud === 'aws' || cloud === 'azure')) + || (service.name === 'nosql' && cloud === 'azure') || ((service.name === 'k8s' || service.name === 'compute' || service.name === 'networking') && cloud === 'aws') || (service.name === 'serverless' && (cloud === 'aws' || cloud === 'azure')) if (service.route && available) { @@ -148,6 +151,15 @@ export function Layout() { ) } +function cloudServiceLabel( + cloud: 'aws' | 'azure' | 'gcp', + service: (typeof CLOUD_SERVICE_ITEMS)[number], +): string { + if (cloud === 'azure' && service.name === 'database') return 'Azure SQL Database' + if (cloud === 'azure' && service.name === 'nosql') return 'Cosmos DB NoSQL' + return service.label +} + function activeCloudFromPath(pathname: string): 'aws' | 'azure' | 'gcp' { const match = pathname.match(/^\/(?:cloud-explorer|console)\/(aws|azure|gcp)(?:\/|$)/) return (match?.[1] ?? 'aws') as 'aws' | 'azure' | 'gcp' diff --git a/packages/frontend/src/pages/CloudExplorerPage.tsx b/packages/frontend/src/pages/CloudExplorerPage.tsx index cff389f..d1bb8f0 100644 --- a/packages/frontend/src/pages/CloudExplorerPage.tsx +++ b/packages/frontend/src/pages/CloudExplorerPage.tsx @@ -102,7 +102,7 @@ function normalizeCloud(value?: string): CloudProvider | null { } function normalizeService(value?: string): CloudServiceType | null { - return value === 'storage' || value === 'k8s' || value === 'database' || value === 'compute' || value === 'networking' || value === 'serverless' ? value : null + return value === 'storage' || value === 'k8s' || value === 'database' || value === 'nosql' || value === 'compute' || value === 'networking' || value === 'serverless' ? value : null } function ServiceInfoDialog({ @@ -255,6 +255,7 @@ function connectionValue(status?: CloudStatus, loading?: boolean): string { function serviceLabel(service: CloudServiceType): string { if (service === 'k8s') return 'k8s Engine' + if (service === 'nosql') return 'NoSQL' if (service === 'serverless') return 'Serverless' return service.charAt(0).toUpperCase() + service.slice(1) } @@ -262,7 +263,8 @@ function serviceLabel(service: CloudServiceType): string { function limitationCopy(cloud: CloudProvider, service: CloudServiceType): string { if (cloud === 'aws' && service === 'storage') return 'Advanced S3 workflows such as bulk actions, version browsing, and richer object lifecycle controls still live outside the normalized surface.' if (cloud === 'azure' && service === 'storage') return 'Blob Storage is wired through the normalized contract, but advanced metadata, tags, and access-policy workflows are still limited.' - if (cloud === 'azure' && service === 'database') return 'Azure SQL servers and Cosmos DB databases share the normalized resource view. Cosmos DB adds a richer panel for containers, items, and SQL queries.' + if (cloud === 'azure' && service === 'database') return 'Azure SQL currently manages logical servers. Database creation and query tooling are not yet exposed.' + if (cloud === 'azure' && service === 'nosql') return 'Cosmos DB NoSQL supports databases, containers, documents, and SQL queries through its dedicated normalized service.' if (cloud === 'aws' && service === 'compute') return 'Compute workflows still rely on AWS-specific forms for dependent infrastructure choices such as VPC, subnet, and security group.' if (cloud === 'aws' && service === 'networking') return 'Networking uses AWS-specific operational panels because many actions require nested workflows instead of a flat generic form.' return 'This service is available through the current adapter, but the normalized contract is still expanding.' diff --git a/packages/frontend/src/types/cloud.ts b/packages/frontend/src/types/cloud.ts index 668a147..d68de47 100644 --- a/packages/frontend/src/types/cloud.ts +++ b/packages/frontend/src/types/cloud.ts @@ -1,6 +1,6 @@ export type CloudProvider = 'aws' | 'azure' | 'gcp' export type CloudAvailability = 'available' | 'coming_soon' -export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'compute' | 'networking' | 'serverless' +export type CloudServiceType = 'storage' | 'k8s' | 'database' | 'nosql' | 'compute' | 'networking' | 'serverless' export interface CloudDescriptor { id: CloudProvider diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index 9e9d553..6e27edc 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -24,10 +24,6 @@ export interface FieldSchema { group?: string span?: boolean defaultValue?: string - visibleWhen?: { - field: string - equals: string - } validation?: { pattern?: string minLength?: number From fb6dc85be5d01b588222f4e7fc6edafcae4c6dab Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:24:21 +0100 Subject: [PATCH 4/7] fix(frontend): extend Azure SQL create timeout First-time provisioning can pull a 2.29 GB SQL engine image and take several minutes.\n\nRefs floci-io/floci-az#138 --- packages/frontend/src/api/cloudProxyClient.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index c633da9..c8b0563 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -12,6 +12,8 @@ import { getAccountId } from "@/lib/accountStore"; type CloudPathParams = Record; +const AZURE_SQL_CREATE_TIMEOUT_MS = 5 * 60_000; + export async function listClouds( signal?: AbortSignal, ): Promise { @@ -93,9 +95,13 @@ export async function createCloudResource( values: Record, signal?: AbortSignal, ): Promise { + const timeout = + cloud === "azure" && service === "database" + ? AZURE_SQL_CREATE_TIMEOUT_MS + : undefined; const res = await apiClient.call>( apiEndpointKeys.clouds.resources.create, - requestOptions(cloud, service, { signal, body: values }), + requestOptions(cloud, service, { signal, body: values, timeout }), { cloud, service }, ); return res.data; @@ -336,6 +342,7 @@ function requestOptions( body?: TBody; rawBody?: BodyInit; headers?: HeadersInit; + timeout?: number; } = {}, ) { return { From 730a2d5f8e9af1c36f6bdc638c985dec449889da Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:27:18 +0100 Subject: [PATCH 5/7] fix(frontend): render structured metadata --- packages/frontend/src/components/ResourceInspector.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/frontend/src/components/ResourceInspector.tsx b/packages/frontend/src/components/ResourceInspector.tsx index e6faa21..6ab801a 100644 --- a/packages/frontend/src/components/ResourceInspector.tsx +++ b/packages/frontend/src/components/ResourceInspector.tsx @@ -349,13 +349,17 @@ function MetadataPanel({ metadata }: { metadata: Record }) { {rows.map(([key, value]) => (
{humanizeKey(key)} - {String(value)} + {formatMetadataValue(value)}
))} ); } +function formatMetadataValue(value: unknown): string { + return typeof value === "object" ? JSON.stringify(value) : String(value); +} + function humanizeKey(value: string): string { return value .replace(/([a-z])([A-Z])/g, "$1 $2") From 788aa576719dd319b78bb949505c0e9bb64d7d58 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Sat, 18 Jul 2026 21:49:06 +0100 Subject: [PATCH 6/7] feat(azure): add SQL data explorer --- packages/api/package.json | 4 +- .../src/adapter-azure/AzureSqlAdapter.test.ts | 67 ++ .../api/src/adapter-azure/AzureSqlAdapter.ts | 104 +++- .../api/src/adapter-azure/MssqlDataClient.ts | 92 +++ packages/api/src/cloud-spi/types.ts | 40 ++ packages/api/src/routes/clouds.test.ts | 63 +- packages/api/src/routes/clouds.ts | 36 +- packages/api/src/service/CloudProxyService.ts | 22 + packages/frontend/src/api/api.ts | 31 + packages/frontend/src/api/cloudProxyClient.ts | 76 ++- .../frontend/src/components/AzureSqlPanel.tsx | 418 +++++++++++++ .../src/components/DynamicResourceView.tsx | 8 + packages/frontend/src/index.css | 256 +++++++- packages/frontend/src/types/resource.ts | 36 ++ pnpm-lock.yaml | 589 ++++++++++++++++++ 15 files changed, 1834 insertions(+), 8 deletions(-) create mode 100644 packages/api/src/adapter-azure/MssqlDataClient.ts create mode 100644 packages/frontend/src/components/AzureSqlPanel.tsx diff --git a/packages/api/package.json b/packages/api/package.json index 4c60859..112df96 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -17,10 +17,12 @@ "@aws-sdk/client-s3": "^3.1076.0", "@aws-sdk/client-secrets-manager": "^3.1076.0", "dotenv": "^17.4.2", - "hono": "^4.12.27" + "hono": "^4.12.27", + "mssql": "^12.7.0" }, "devDependencies": { "@types/bun": "^1.3.2", + "@types/mssql": "^12.3.0", "@types/node": "^25.9.4", "typescript": "^6.0.3" } diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts index 368cc4b..5cb85dc 100644 --- a/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.test.ts @@ -1,6 +1,7 @@ import {describe, expect, test} from 'bun:test' import type {AzureRuntimeClient, AzureRuntimeFetchOptions} from '../azure' import {AzureSqlAdapter} from './AzureSqlAdapter' +import type {SqlDataClient, SqlDataConnection} from './MssqlDataClient' const SERVERS_PATH = '/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/floci-local/providers/Microsoft.Sql/servers' const API_VERSION = '?api-version=2021-11-01' @@ -108,8 +109,74 @@ describe('AzureSqlAdapter', () => { const adapter = new AzureSqlAdapter(testClient({})) await expect(adapter.get('sql-server:missing')).resolves.toBeNull() }) + + test('browses databases and tables and runs T-SQL through the server endpoint', async () => { + const serverPath = `${SERVERS_PATH}/app-sql${API_VERSION}` + const dataCalls: Array<{connection: SqlDataConnection; query: string}> = [] + const dataClient: SqlDataClient = { + async query(connection, query) { + dataCalls.push({connection, query}) + if (query.includes('FROM sys.databases')) { + return sqlResult([{name: 'master', state: 'ONLINE', createdAt: '2026-01-01T00:00:00.000Z', isSystem: true}]) + } + if (query.includes('FROM sys.tables')) { + return sqlResult([{schemaName: 'dbo', name: 'orders', objectType: 'table', rowCount: '42'}]) + } + return sqlResult([{orderId: 1042}], [1]) + }, + } + const adapter = new AzureSqlAdapter(testClient({ + [serverPath]: { + name: 'app-sql', + properties: { + fullyQualifiedDomainName: 'app-sql.local', + localPort: 1433, + }, + }, + }), dataClient) + const credentials = {username: 'sa', password: 'LocalDev!2026'} + + await expect(adapter.listSqlDatabases('sql-server:app-sql', credentials)).resolves.toEqual([{ + name: 'master', + state: 'ONLINE', + createdAt: '2026-01-01T00:00:00.000Z', + isSystem: true, + }]) + await expect(adapter.listSqlTables('sql-server:app-sql', {...credentials, database: 'ordersdb'})).resolves.toEqual([{ + schema: 'dbo', + name: 'orders', + type: 'table', + rowCount: 42, + }]) + await expect(adapter.querySql('sql-server:app-sql', {...credentials, database: 'ordersdb'}, 'SELECT * FROM dbo.orders')).resolves.toEqual( + sqlResult([{orderId: 1042}], [1]), + ) + + expect(dataCalls).toHaveLength(3) + expect(dataCalls[0].connection).toEqual({ + server: 'app-sql.local', + port: 1433, + database: 'master', + username: 'sa', + password: 'LocalDev!2026', + }) + expect(dataCalls[1].connection.database).toBe('ordersdb') + expect(dataCalls[2].query).toBe('SELECT * FROM dbo.orders') + }) }) +function sqlResult(rows: Array>, rowsAffected: number[] = []) { + return { + resultSets: [{ + columns: Object.keys(rows[0] ?? {}).map((name) => ({name, type: 'nvarchar'})), + rows, + truncated: false, + }], + rowsAffected, + durationMs: 1, + } +} + function testClient(responses: Record, calls: Array<{path: string; init: RequestInit}> = []): AzureRuntimeClient { return { endpoint: 'http://localhost:4577', diff --git a/packages/api/src/adapter-azure/AzureSqlAdapter.ts b/packages/api/src/adapter-azure/AzureSqlAdapter.ts index f0985a7..ab4b42d 100644 --- a/packages/api/src/adapter-azure/AzureSqlAdapter.ts +++ b/packages/api/src/adapter-azure/AzureSqlAdapter.ts @@ -11,10 +11,39 @@ import type { CreateResourceInput, ResourceQuery, ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, } from '../cloud-spi/types' +import {MssqlDataClient, type SqlDataClient, type SqlDataConnection} from './MssqlDataClient' const API_VERSION = '2021-11-01' const RESOURCE_ID_PREFIX = 'sql-server:' +const LIST_DATABASES_QUERY = ` +SELECT + name, + state_desc AS state, + create_date AS createdAt, + CAST(CASE WHEN database_id <= 4 THEN 1 ELSE 0 END AS bit) AS isSystem +FROM sys.databases +ORDER BY database_id` +const LIST_TABLES_QUERY = ` +SELECT + schemas.name AS [schemaName], + tables.name, + 'table' AS [objectType], + SUM(partitions.rows) AS [rowCount] +FROM sys.tables AS tables +INNER JOIN sys.schemas AS schemas ON schemas.schema_id = tables.schema_id +LEFT JOIN sys.partitions AS partitions + ON partitions.object_id = tables.object_id AND partitions.index_id IN (0, 1) +GROUP BY schemas.name, tables.name +UNION ALL +SELECT schemas.name, views.name, 'view', NULL +FROM sys.views AS views +INNER JOIN sys.schemas AS schemas ON schemas.schema_id = views.schema_id +ORDER BY [schemaName], name` interface AzureSqlListResponse { value?: AzureSqlRecord[] @@ -34,7 +63,10 @@ export class AzureSqlAdapter implements CloudServiceAdapter { readonly cloud = 'azure' as const readonly service = 'database' as const - constructor(private readonly client: AzureRuntimeClient = azure) {} + constructor( + private readonly client: AzureRuntimeClient = azure, + private readonly dataClient: SqlDataClient = new MssqlDataClient(), + ) {} schema(): ServiceSchema { return azureDatabaseSchema() @@ -77,6 +109,41 @@ export class AzureSqlAdapter implements CloudServiceAdapter { await this.sqlFetch(sqlServerPath(sqlServerName(id)), {method: 'DELETE'}) } + async listSqlDatabases(serverId: string, connection: SqlConnectionInput): Promise { + const result = await this.querySql(serverId, {...connection, database: 'master'}, LIST_DATABASES_QUERY) + return (result.resultSets[0]?.rows ?? []).flatMap((row) => { + const name = stringValue(row.name) + if (!name) return [] + return [{ + name, + state: stringValue(row.state) || 'UNKNOWN', + createdAt: stringValue(row.createdAt) || null, + isSystem: row.isSystem === true || row.isSystem === 1, + }] + }) + } + + async listSqlTables(serverId: string, connection: SqlConnectionInput): Promise { + const result = await this.querySql(serverId, connection, LIST_TABLES_QUERY) + return (result.resultSets[0]?.rows ?? []).flatMap((row) => { + const schema = stringValue(row.schemaName) + const name = stringValue(row.name) + if (!schema || !name) return [] + return [{ + schema, + name, + type: row.objectType === 'view' ? 'view' as const : 'table' as const, + rowCount: integerValue(row.rowCount), + }] + }) + } + + async querySql(serverId: string, connection: SqlConnectionInput, query: string): Promise { + const dataConnection = await this.dataConnection(serverId, connection) + if (!query.trim()) throw new Error('SQL query is required') + return this.dataClient.query(dataConnection, query) + } + private sqlFetch(path: string, init: RequestInit, emptyOnNotFound = false): Promise { return this.client.fetch(withApiVersion(path), { ...init, @@ -93,6 +160,24 @@ export class AzureSqlAdapter implements CloudServiceAdapter { if (!response || response.status === 204) return null return await response.json() as T } + + private async dataConnection(serverId: string, input: SqlConnectionInput): Promise { + const resource = await this.get(serverId) + if (!resource) throw new Error('Azure SQL server not found') + + const endpoint = recordValue(resource.metadata.endpoint) + const server = stringValue(endpoint?.address) + const port = numberValue(endpoint?.port) + const username = rawStringValue(input.username) + const password = rawStringValue(input.password) + const database = stringValue(input.database) || 'master' + + if (!server || port === null) throw new Error('Azure SQL server endpoint is not available') + if (!username) throw new Error('SQL username is required') + if (!password) throw new Error('SQL password is required') + + return {server, port, database, username, password} + } } export function isSqlServerResourceId(id: string): boolean { @@ -166,10 +251,27 @@ function stringValue(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } +function rawStringValue(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +function recordValue(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null +} + function numberValue(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } +function integerValue(value: unknown): number | null { + if (typeof value === 'number' && Number.isSafeInteger(value)) return value + if (typeof value !== 'string' || !/^\d+$/.test(value)) return null + const parsed = Number(value) + return Number.isSafeInteger(parsed) ? parsed : null +} + function isValidServerName(value: string): boolean { return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value) } diff --git a/packages/api/src/adapter-azure/MssqlDataClient.ts b/packages/api/src/adapter-azure/MssqlDataClient.ts new file mode 100644 index 0000000..0a82f6a --- /dev/null +++ b/packages/api/src/adapter-azure/MssqlDataClient.ts @@ -0,0 +1,92 @@ +import sql from 'mssql' +import type {config as MssqlConfig, IRecordSet} from 'mssql' +import type {SqlColumn, SqlQueryResult, SqlResultSet} from '../cloud-spi/types' + +const MAX_ROWS_PER_RESULT_SET = 500 + +export interface SqlDataConnection { + server: string + port: number + database: string + username: string + password: string +} + +export interface SqlDataClient { + query(connection: SqlDataConnection, query: string): Promise +} + +export class MssqlDataClient implements SqlDataClient { + async query(connection: SqlDataConnection, query: string): Promise { + const pool = new sql.ConnectionPool(connectionConfig(connection)) + const startedAt = performance.now() + + try { + await pool.connect() + const result = await pool.request().query>(query) + return { + resultSets: result.recordsets.map(toResultSet), + rowsAffected: result.rowsAffected, + durationMs: Math.round(performance.now() - startedAt), + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown SQL Server error' + throw new Error(`Azure SQL data request failed: ${message}`, {cause: error}) + } finally { + await pool.close().catch(() => undefined) + } + } +} + +function connectionConfig(connection: SqlDataConnection): MssqlConfig { + return { + user: connection.username, + password: connection.password, + server: connection.server, + port: connection.port, + database: connection.database, + connectionTimeout: 10_000, + requestTimeout: 30_000, + pool: {min: 0, max: 1, idleTimeoutMillis: 5_000}, + options: { + encrypt: false, + trustServerCertificate: true, + appName: 'Floci UI', + }, + } +} + +function toResultSet(recordset: IRecordSet>): SqlResultSet { + return { + columns: Object.values(recordset.columns) + .sort((left, right) => left.index - right.index) + .map((column): SqlColumn => ({ + name: column.name, + type: sqlTypeName(column.type), + })), + rows: recordset.slice(0, MAX_ROWS_PER_RESULT_SET).map(normalizeRow), + truncated: recordset.length > MAX_ROWS_PER_RESULT_SET, + } +} + +function sqlTypeName(type: unknown): string { + if (type && (typeof type === 'object' || typeof type === 'function') && 'declaration' in type) { + const declaration = (type as {declaration?: unknown}).declaration + if (typeof declaration === 'string') return declaration + } + return 'unknown' +} + +function normalizeRow(row: Record): Record { + return Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeValue(value)])) +} + +function normalizeValue(value: unknown): unknown { + if (value === null || value === undefined) return null + if (typeof value === 'bigint') return value.toString() + if (value instanceof Date) return value.toISOString() + if (Buffer.isBuffer(value)) return value.toString('base64') + if (Array.isArray(value)) return value.map(normalizeValue) + if (typeof value === 'object') return normalizeRow(value as Record) + return value +} diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 1f49797..47f165c 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -138,6 +138,43 @@ export interface CosmosQueryResult { count: number } +export interface SqlConnectionInput { + username: string + password: string + database?: string +} + +export interface SqlDatabase { + name: string + state: string + createdAt: string | null + isSystem: boolean +} + +export interface SqlTable { + schema: string + name: string + type: 'table' | 'view' + rowCount: number | null +} + +export interface SqlColumn { + name: string + type: string +} + +export interface SqlResultSet { + columns: SqlColumn[] + rows: Array> + truncated: boolean +} + +export interface SqlQueryResult { + resultSets: SqlResultSet[] + rowsAffected: number[] + durationMs: number +} + export interface ResourceQuery { search?: string } @@ -173,4 +210,7 @@ export interface CloudServiceAdapter { upsertCosmosItem?(databaseId: string, containerId: string, document: Record): Promise deleteCosmosItem?(databaseId: string, containerId: string, itemId: string, partitionKey?: string | null): Promise queryCosmosItems?(databaseId: string, containerId: string, query: string): Promise + listSqlDatabases?(serverId: string, connection: SqlConnectionInput): Promise + listSqlTables?(serverId: string, connection: SqlConnectionInput): Promise + querySql?(serverId: string, connection: SqlConnectionInput, query: string): Promise } diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 8462376..5b51d7d 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -3,7 +3,18 @@ import {Hono} from 'hono' import {azureDatabaseSchema} from '../cloud-spi/databaseSchema' import {azureNoSqlSchema} from '../cloud-spi/noSqlSchema' import {awsStorageSchema, azureStorageSchema} from '../cloud-spi/storageSchema' -import type {CloudResource, CloudServiceAdapter, CosmosContainer, CosmosItem, CosmosQueryResult, CreateResourceInput} from '../cloud-spi/types' +import type { + CloudResource, + CloudServiceAdapter, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + CreateResourceInput, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from '../cloud-spi/types' import {CloudAdapterRegistry} from '../registry/CloudAdapterRegistry' import {CloudProxyService} from '../service/CloudProxyService' import {createCloudRoutes} from './clouds' @@ -306,6 +317,56 @@ describe('cloud schema routes', () => { expect(deleted).toEqual([{databaseId: 'appdb', containerId: 'items', itemId: 'item-1', partitionKey: 'demo'}]) }) + test('browses Azure SQL databases and tables and executes queries', async () => { + const calls: Array<{operation: string; serverId: string; connection: SqlConnectionInput; query?: string}> = [] + const app = appWithRoutes([mockAdapter('azure', { + service: 'database', + schema: azureDatabaseSchema, + listSqlDatabases: async (serverId, connection): Promise => { + calls.push({operation: 'databases', serverId, connection}) + return [{name: 'ordersdb', state: 'ONLINE', createdAt: null, isSystem: false}] + }, + listSqlTables: async (serverId, connection): Promise => { + calls.push({operation: 'tables', serverId, connection}) + return [{schema: 'dbo', name: 'orders', type: 'table', rowCount: 1}] + }, + querySql: async (serverId, connection, query): Promise => { + calls.push({operation: 'query', serverId, connection, query}) + return { + resultSets: [{ + columns: [{name: 'orderId', type: 'int'}], + rows: [{orderId: 1042}], + truncated: false, + }], + rowsAffected: [1], + durationMs: 4, + } + }, + })]) + const credentials = {username: 'sa', password: 'LocalDev!2026'} + const databasesRes = await app.request('/api/clouds/azure/services/database/resources/app-sql/sql/databases', { + method: 'POST', + body: JSON.stringify(credentials), + }) + const tablesRes = await app.request('/api/clouds/azure/services/database/resources/app-sql/sql/tables', { + method: 'POST', + body: JSON.stringify({...credentials, database: 'ordersdb'}), + }) + const queryRes = await app.request('/api/clouds/azure/services/database/resources/app-sql/sql/query', { + method: 'POST', + body: JSON.stringify({...credentials, database: 'ordersdb', query: 'SELECT * FROM dbo.orders'}), + }) + + expect(await databasesRes.json()).toEqual([{name: 'ordersdb', state: 'ONLINE', createdAt: null, isSystem: false}]) + expect(await tablesRes.json()).toEqual([{schema: 'dbo', name: 'orders', type: 'table', rowCount: 1}]) + expect(await queryRes.json()).toMatchObject({resultSets: [{rows: [{orderId: 1042}]}], rowsAffected: [1]}) + expect(calls).toEqual([ + {operation: 'databases', serverId: 'app-sql', connection: credentials}, + {operation: 'tables', serverId: 'app-sql', connection: {...credentials, database: 'ordersdb'}}, + {operation: 'query', serverId: 'app-sql', connection: {...credentials, database: 'ordersdb'}, query: 'SELECT * FROM dbo.orders'}, + ]) + }) + test('normalizes runtime unavailable errors', async () => { const app = appWithRoutes([ mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 7a89b1d..19cae3b 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -1,6 +1,6 @@ import {Hono} from 'hono' import type {Context} from 'hono' -import type {CloudProvider, CloudServiceType} from '../cloud-spi/types' +import type {CloudProvider, CloudServiceType, SqlConnectionInput} from '../cloud-spi/types' import {serviceForAccount} from '../cloudProxy' import {CloudProxyService} from '../service/CloudProxyService' @@ -127,6 +127,40 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/database/resources/:id/sql/databases', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const connection = await c.req.json() + const databases = await svc(c).listSqlDatabases(cloud, c.req.param('id'), connection) + return c.json(databases) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/tables', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const connection = await c.req.json() + const tables = await svc(c).listSqlTables(cloud, c.req.param('id'), connection) + return c.json(tables) + }) + }) + + app.post('/:cloud/services/database/resources/:id/sql/query', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json() + const {query = '', ...connection} = body + const result = await svc(c).querySql(cloud, c.req.param('id'), connection, query) + return c.json(result) + }) + }) + app.get('/:cloud/services/:service/resources/:id', async (c) => { const cloud = c.req.param('cloud') as CloudProvider const serviceType = c.req.param('service') as CloudServiceType diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 07d49f0..bff259b 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -12,6 +12,10 @@ import type { ResourceQuery, ServerlessInvokeResult, ServiceSchema, + SqlConnectionInput, + SqlDatabase, + SqlQueryResult, + SqlTable, StorageObjectDownload, StorageObjectList, } from '../cloud-spi/types' @@ -249,6 +253,24 @@ async invokeResource( return adapter.queryCosmosItems(databaseId, containerId, query) } + async listSqlDatabases(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listSqlDatabases) throw new Error(`SQL database browsing is not supported for ${cloud}/database`) + return adapter.listSqlDatabases(serverId, connection) + } + + async listSqlTables(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.listSqlTables) throw new Error(`SQL table browsing is not supported for ${cloud}/database`) + return adapter.listSqlTables(serverId, connection) + } + + async querySql(cloud: CloudProvider, serverId: string, connection: SqlConnectionInput, query: string): Promise { + const adapter = this.requireAdapter(cloud, 'database') + if (!adapter.querySql) throw new Error(`SQL query is not supported for ${cloud}/database`) + return adapter.querySql(serverId, connection, query) + } + private requireAdapter(cloud: CloudProvider, service: CloudServiceType) { const adapter = this.registry.get(cloud, service) if (!adapter) throw new Error(`No adapter registered for ${cloud}/${service}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index ab21b5c..cb67c20 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -45,6 +45,13 @@ export const apiEndpointKeys = { }, }, }, + database: { + sql: { + databases: "clouds.services.database.sql.databases.list", + tables: "clouds.services.database.sql.tables.list", + query: "clouds.services.database.sql.query", + }, + }, }, aws: { eks: { @@ -348,6 +355,30 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "cloud-proxy" }, }, ], + [ + apiEndpointKeys.clouds.database.sql.databases, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/databases", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.tables, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/tables", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], + [ + apiEndpointKeys.clouds.database.sql.query, + { + path: "/clouds/:cloud/services/database/resources/:id/sql/query", + method: "POST", + telemetry: { service: "cloud-proxy" }, + }, + ], // AWS EKS [ diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index c8b0563..00f8137 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -6,13 +6,24 @@ import type { CloudServiceType, CloudStatus, } from "@/types/cloud"; -import type { CloudResource, CosmosContainer, CosmosItem, CosmosQueryResult, StorageObjectList } from "@/types/resource"; +import type { + CloudResource, + CosmosContainer, + CosmosItem, + CosmosQueryResult, + SqlCredentials, + SqlDatabase, + SqlQueryResult, + SqlTable, + StorageObjectList, +} from "@/types/resource"; import type { ServiceSchema } from "@/types/schema"; import { getAccountId } from "@/lib/accountStore"; type CloudPathParams = Record; const AZURE_SQL_CREATE_TIMEOUT_MS = 5 * 60_000; +const SQL_DATA_TIMEOUT_MS = 45_000; export async function listClouds( signal?: AbortSignal, @@ -333,6 +344,69 @@ export async function queryCosmosItems( return res.data; } +export async function listSqlDatabases( + cloud: CloudProvider, + serverId: string, + credentials: SqlCredentials, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.database.sql.databases, + requestOptions(cloud, "database", { + signal, + body: credentials, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function listSqlTables( + cloud: CloudProvider, + serverId: string, + database: string, + credentials: SqlCredentials, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + SqlTable[], + SqlCredentials & { database: string } + >( + apiEndpointKeys.clouds.database.sql.tables, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, database }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + +export async function querySql( + cloud: CloudProvider, + serverId: string, + database: string, + credentials: SqlCredentials, + query: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call< + SqlQueryResult, + SqlCredentials & { database: string; query: string } + >( + apiEndpointKeys.clouds.database.sql.query, + requestOptions(cloud, "database", { + signal, + body: { ...credentials, database, query }, + timeout: SQL_DATA_TIMEOUT_MS, + }), + { cloud, id: serverId }, + ); + return res.data; +} + function requestOptions( cloud: CloudProvider, service: string, diff --git a/packages/frontend/src/components/AzureSqlPanel.tsx b/packages/frontend/src/components/AzureSqlPanel.tsx new file mode 100644 index 0000000..bd41f5f --- /dev/null +++ b/packages/frontend/src/components/AzureSqlPanel.tsx @@ -0,0 +1,418 @@ +import { FormEvent, useEffect, useState } from "react"; +import { Code2, Database, Play, Plug, RefreshCw, Table2, Unplug } from "lucide-react"; +import { useMutation } from "@tanstack/react-query"; +import { + listSqlDatabases, + listSqlTables, + querySql, +} from "@/api/cloudProxyClient"; +import type { CloudProvider } from "@/types/cloud"; +import type { + CloudResource, + SqlCredentials, + SqlDatabase, + SqlQueryResult, + SqlTable, +} from "@/types/resource"; + +const DEFAULT_QUERY = `SELECT + DB_NAME() AS database_name, + @@SERVERNAME AS server_name, + @@VERSION AS version;`; + +interface AzureSqlPanelProps { + cloud: CloudProvider; + resource?: CloudResource; + runtimeReachable: boolean; +} + +export function AzureSqlPanel({ + cloud, + resource, + runtimeReachable, +}: AzureSqlPanelProps) { + const serverId = resource?.name; + const administratorLogin = metadataString( + resource?.metadata.administratorLogin, + ); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [connected, setConnected] = useState(false); + const [databases, setDatabases] = useState([]); + const [selectedDatabase, setSelectedDatabase] = useState("master"); + const [tables, setTables] = useState([]); + const [selectedTable, setSelectedTable] = useState(); + const [queryText, setQueryText] = useState(DEFAULT_QUERY); + + const credentials: SqlCredentials = { username, password }; + + const tablesMut = useMutation({ + mutationFn: (database: string) => + listSqlTables(cloud, serverId ?? "", database, credentials), + onSuccess: setTables, + }); + + const databasesMut = useMutation({ + mutationFn: () => listSqlDatabases(cloud, serverId ?? "", credentials), + onSuccess: (items) => { + setDatabases(items); + setConnected(true); + const database = items.some((item) => item.name === selectedDatabase) + ? selectedDatabase + : (items[0]?.name ?? "master"); + setSelectedDatabase(database); + tablesMut.mutate(database); + }, + }); + + const queryMut = useMutation({ + mutationFn: ({ database, query }: { database: string; query: string }) => + querySql(cloud, serverId ?? "", database, credentials, query), + }); + + useEffect(() => { + setUsername(administratorLogin); + setPassword(""); + setConnected(false); + setDatabases([]); + setSelectedDatabase("master"); + setTables([]); + setSelectedTable(undefined); + setQueryText(DEFAULT_QUERY); + }, [administratorLogin, cloud, serverId]); + + if (cloud !== "azure") return null; + + if (!serverId) { + return ( +
+
+

Select an Azure SQL server

+

Databases, tables, rows, and T-SQL queries load after connection.

+
+
+ ); + } + + function connect(event: FormEvent) { + event.preventDefault(); + if (!username || !password) return; + tablesMut.reset(); + queryMut.reset(); + databasesMut.mutate(); + } + + function disconnect() { + setConnected(false); + setPassword(""); + setDatabases([]); + setTables([]); + setSelectedTable(undefined); + databasesMut.reset(); + tablesMut.reset(); + queryMut.reset(); + } + + function selectDatabase(database: SqlDatabase) { + setSelectedDatabase(database.name); + setSelectedTable(undefined); + setTables([]); + queryMut.reset(); + tablesMut.mutate(database.name); + } + + function previewTable(table: SqlTable) { + const query = `SELECT TOP (100) * FROM ${quoteIdentifier(table.schema)}.${quoteIdentifier(table.name)};`; + setSelectedTable(table); + setQueryText(query); + queryMut.mutate({ database: selectedDatabase, query }); + } + + function runQuery() { + if (!queryText.trim()) return; + queryMut.mutate({ database: selectedDatabase, query: queryText }); + } + + const connectionError = + databasesMut.error instanceof Error ? databasesMut.error.message : null; + + return ( +
+ +
+ + + SQL DATA PLANE + {resource.name} + {metadataString(resource.metadata.fullyQualifiedDomainName)} + +
+ + + {connected ? ( + + ) : ( + + )} + + Credentials remain in this page only and are sent to the local API per request. + + {connectionError &&
{connectionError}
} + + + {!connected ? ( +
+

Connect to browse SQL data

+

Use the SQL Server login configured for this local engine.

+
+ ) : ( +
+
+ databasesMut.mutate()} + > + + + } + /> +
+ {databases.map((database) => ( + + ))} +
+
+ +
+ tablesMut.mutate(selectedDatabase)} + > + + + } + /> + {tablesMut.error instanceof Error && ( +
{tablesMut.error.message}
+ )} +
+ {tablesMut.isPending &&
Loading objects
} + {!tablesMut.isPending && tables.length === 0 && ( +
No user tables or views
+ )} + {tables.map((table) => ( + + ))} +
+
+ +
+ +
+