diff --git a/frontend/src/app/components/connect-db/connect-db.component.css b/frontend/src/app/components/connect-db/connect-db.component.css index 84d40bfb7..b7c73b85c 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.css +++ b/frontend/src/app/components/connect-db/connect-db.component.css @@ -205,19 +205,32 @@ padding: 8px 12px; } -.connectForm__connectionString { +.connectForm__connectionStringContainer { + background-color: var(--info-background-color); + border: 1px solid var(--color-infoPalette-500); + border-radius: 8px; + padding: 16px; + margin-bottom: 16px; +} + +.connectForm__connectionStringHeader { display: flex; - align-items: flex-start; - gap: 12px; + align-items: center; + gap: 8px; + margin-bottom: 6px; + color: var(--color-infoPalette-500); } -.connectForm__connectionString ::ng-deep .mat-mdc-text-field-wrapper { - padding-right: 80px; +.connectForm__connectionStringIcon { + font-size: 20px; + width: 20px; + height: 20px; } -.connectForm__connectionString button { - margin-top: 4px; - margin-left: -80px; +.connectForm__connectionStringDescription { + margin: 0 0 12px 0; + font-size: 11px; + opacity: 0.6; } .agent-token { @@ -231,3 +244,48 @@ margin-top: 4px; margin-left: 12px; } + +.connectForm__connectionStringParsed { + display: flex; + align-items: flex-start; + gap: 6px; + margin-top: 8px !important; + color: var(--color-successPalette-500); + font-size: 12px; +} + +.connectForm__connectionStringParsed-icon { + font-size: 16px; + width: 16px; + height: 16px; + flex-shrink: 0; + margin-top: 1px; +} + +.connectForm__connectionStringParsed-label { + font-weight: 500; + margin-right: 4px; +} + +.connectForm__connectionStringParsed-value { + word-break: break-all; + opacity: 0.8; +} + +.connectForm__connectionStringOverrideNote { + display: flex; + align-items: center; + gap: 6px; + margin-top: -4px !important; + margin-bottom: 0 !important; + color: var(--color-infoPalette-500); + font-size: 13px; + font-weight: 500; +} + +.connectForm__connectionStringOverrideNote-icon { + font-size: 16px; + width: 16px; + height: 16px; + flex-shrink: 0; +} diff --git a/frontend/src/app/components/connect-db/connect-db.component.html b/frontend/src/app/components/connect-db/connect-db.component.html index 554d27df9..96df5d63d 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.html +++ b/frontend/src/app/components/connect-db/connect-db.component.html @@ -72,7 +72,14 @@

@if (db.connectionType === 'direct' && !db.id) { -
+
+
+ link + Quick connect with a connection string +
+

+ Paste your database connection URI below to automatically fill in all the credentials fields. The string is not stored — only the parsed values are used. +

Connection string placeholder="e.g. postgresql://user:password@host:5432/dbname" connectionStringValidator [disabled]="submitting" - [(ngModel)]="connectionString"> - Paste your database connection URI to auto-fill credentials + [(ngModel)]="connectionString" + (ngModelChange)="onConnectionStringChange(connectionStringInput)"> @if (connectionStringInput.errors?.invalidConnectionStringFormat) { Invalid format. Expected: scheme://user:password@host:port/database } @@ -93,13 +100,21 @@

Failed to parse connection string } - + @if (parsedConnectionString) { +

+ check_circle + + Parsed: + {{ parsedConnectionString }} + +

+ } + @if (fieldsOverridden) { +

+ info + Fields are now the source of truth — paste a new connection string to override them +

+ }

} @@ -111,7 +126,8 @@

submitting: submitting, accessLevel: accessLevel, masterKey: masterKey, - readonly: !!((accessLevel === 'readonly' || db.isTestConnection) && db.id) + readonly: !!((accessLevel === 'readonly' || db.isTestConnection) && db.id), + autoFilledFields: autoFilledFields }" [ndcDynamicOutputs]="credentialsFormOutputs" [ndcDynamicAttributes]="credentialsFormAttributes" diff --git a/frontend/src/app/components/connect-db/connect-db.component.spec.ts b/frontend/src/app/components/connect-db/connect-db.component.spec.ts index 45a646c69..4a1f8b651 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.spec.ts +++ b/frontend/src/app/components/connect-db/connect-db.component.spec.ts @@ -262,4 +262,153 @@ describe('ConnectDBComponent', () => { }, }); }); + + describe('Connection string functionality', () => { + it('should parse a PostgreSQL connection string and populate db fields', () => { + component.connectionString = 'postgresql://myuser:mypass@db.example.com:5432/mydb'; + component.onConnectionStringChange(null); + + expect(component.db.type).toBe(DBtype.Postgres); + expect(component.db.host).toBe('db.example.com'); + expect(component.db.port).toBe('5432'); + expect(component.db.username).toBe('myuser'); + expect(component.db.password).toBe('mypass'); + expect(component.db.database).toBe('mydb'); + }); + + it('should parse a MySQL connection string and populate db fields', () => { + component.connectionString = 'mysql://root:secret@localhost:3306/app_db'; + component.onConnectionStringChange(null); + + expect(component.db.type).toBe(DBtype.MySQL); + expect(component.db.host).toBe('localhost'); + expect(component.db.port).toBe('3306'); + expect(component.db.username).toBe('root'); + expect(component.db.password).toBe('secret'); + expect(component.db.database).toBe('app_db'); + }); + + it('should parse a MongoDB connection string with authSource option', () => { + component.connectionString = 'mongodb://admin:pass123@mongo.host.com:27017/mydb?authSource=admin'; + component.onConnectionStringChange(null); + + expect(component.db.type).toBe(DBtype.Mongo); + expect(component.db.host).toBe('mongo.host.com'); + expect(component.db.port).toBe('27017'); + expect(component.db.username).toBe('admin'); + expect(component.db.password).toBe('pass123'); + expect(component.db.database).toBe('mydb'); + expect(component.db.authSource).toBe('admin'); + expect(component.autoFilledFields.has('authSource')).toBe(true); + }); + + it('should set autoFilledFields for all parsed fields', () => { + component.connectionString = 'postgresql://user:pass@host:5432/db'; + component.onConnectionStringChange(null); + + expect(component.autoFilledFields.has('host')).toBe(true); + expect(component.autoFilledFields.has('port')).toBe(true); + expect(component.autoFilledFields.has('username')).toBe(true); + expect(component.autoFilledFields.has('password')).toBe(true); + expect(component.autoFilledFields.has('database')).toBe(true); + }); + + it('should set ssl to true when sslmode=require is in connection string', () => { + component.connectionString = 'postgresql://user:pass@host:5432/db?sslmode=require'; + component.onConnectionStringChange(null); + + expect(component.db.ssl).toBe(true); + }); + + it('should set schema when schema option is present', () => { + component.connectionString = 'postgresql://user:pass@host:5432/db?schema=my_schema'; + component.onConnectionStringChange(null); + + expect(component.db.schema).toBe('my_schema'); + expect(component.autoFilledFields.has('schema')).toBe(true); + }); + + it('should reset fieldsOverridden to false after parsing', () => { + component.fieldsOverridden = true; + component.connectionString = 'postgresql://user:pass@host:5432/db'; + component.onConnectionStringChange(null); + + expect(component.fieldsOverridden).toBe(false); + }); + + it('should not modify db fields when connection string is empty', () => { + const originalType = component.db.type; + const originalHost = component.db.host; + + component.connectionString = ' '; + component.onConnectionStringChange(null); + + expect(component.db.type).toBe(originalType); + expect(component.db.host).toBe(originalHost); + }); + + it('should not modify db fields when connection string is invalid', () => { + const originalType = component.db.type; + const originalHost = component.db.host; + + component.connectionString = 'not-a-valid-connection-string'; + component.onConnectionStringChange(null); + + expect(component.db.type).toBe(originalType); + expect(component.db.host).toBe(originalHost); + }); + + it('should use default port when port is not specified in connection string', () => { + component.connectionString = 'postgresql://user:pass@host/db'; + component.onConnectionStringChange(null); + + expect(component.db.port).toBe('5432'); + }); + + it('should handle URL-encoded username and password', () => { + component.connectionString = 'postgresql://my%40user:p%40ss%23word@host:5432/db'; + component.onConnectionStringChange(null); + + expect(component.db.username).toBe('my@user'); + expect(component.db.password).toBe('p@ss#word'); + }); + + it('should clear the connection string after successful parsing', () => { + vi.useFakeTimers(); + component.connectionString = 'postgresql://user:pass@host:5432/db'; + component.onConnectionStringChange(null); + + vi.advanceTimersByTime(300); + expect(component.connectionString).toBe(''); + vi.useRealTimers(); + }); + + it('should store parsed connection string in parsedConnectionString', () => { + const connStr = 'postgresql://user:pass@host:5432/db'; + component.connectionString = connStr; + component.onConnectionStringChange(null); + + expect(component.parsedConnectionString).toBe(connStr); + }); + }); + + describe('clearAutoFilledField', () => { + it('should remove the field from autoFilledFields', () => { + component.autoFilledFields = new Set(['host', 'port', 'username']); + component.clearAutoFilledField('host'); + + expect(component.autoFilledFields.has('host')).toBe(false); + expect(component.autoFilledFields.has('port')).toBe(true); + expect(component.autoFilledFields.has('username')).toBe(true); + }); + + it('should set fieldsOverridden to true', () => { + component.autoFilledFields = new Set(['host']); + component.fieldsOverridden = false; + + component.clearAutoFilledField('host'); + + expect(component.fieldsOverridden).toBe(true); + }); + }); }); diff --git a/frontend/src/app/components/connect-db/connect-db.component.ts b/frontend/src/app/components/connect-db/connect-db.component.ts index 22b0c0f56..c6c7a53c0 100644 --- a/frontend/src/app/components/connect-db/connect-db.component.ts +++ b/frontend/src/app/components/connect-db/connect-db.component.ts @@ -91,6 +91,9 @@ export class ConnectDBComponent implements OnInit { }; public connectionString: string = ''; + public parsedConnectionString: string = ''; + public autoFilledFields: Set = new Set(); + public fieldsOverridden: boolean = false; public credentialsFormMap: Record> = { [DBtype.MySQL]: MysqlCredentialsFormComponent, @@ -136,6 +139,7 @@ export class ConnectDBComponent implements OnInit { public credentialsFormOutputs: Record = { switchToAgent: () => this.switchToAgent(), masterKeyChange: (key: string) => this.handleMasterKeyChange(key), + fieldChange: (field: string) => this.clearAutoFilledField(field), }; public credentialsFormAttributes: Record = { class: 'credentials-fieldset' }; @@ -468,7 +472,7 @@ export class ConnectDBComponent implements OnInit { this.masterKey = newMasterKey; } - applyConnectionString() { + onConnectionStringChange(_input: any) { if (!this.connectionString.trim()) { return; } @@ -476,6 +480,9 @@ export class ConnectDBComponent implements OnInit { try { const parsed = parseConnectionString(this.connectionString); + this.parsedConnectionString = this.connectionString; + this.autoFilledFields = new Set(['host', 'port', 'username', 'password', 'database']); + this.fieldsOverridden = false; this.db.type = parsed.dbType; this.db.host = parsed.host; this.db.port = parsed.port; @@ -485,21 +492,28 @@ export class ConnectDBComponent implements OnInit { if (parsed.authSource) { this.db.authSource = parsed.authSource; + this.autoFilledFields.add('authSource'); } if (parsed.schema) { this.db.schema = parsed.schema; + this.autoFilledFields.add('schema'); } if (parsed.ssl) { this.db.ssl = true; } - this.connectionString = ''; - this._notifications.showSuccessSnackbar('Connection string parsed successfully'); + setTimeout(() => this.connectionString = '', 300); } catch (_e) { // Validation directive handles error display } } + clearAutoFilledField(field: string): void { + this.autoFilledFields = new Set(this.autoFilledFields); + this.autoFilledFields.delete(field); + this.fieldsOverridden = true; + } + getProvider() { let provider: string = null; if (this.db.host.endsWith('.amazonaws.com')) provider = 'amazon'; diff --git a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html b/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html index 732669bed..b8c0dded4 100644 --- a/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html +++ b/frontend/src/app/components/connect-db/db-connection-ip-access-dialog/db-connection-ip-access-dialog.component.html @@ -143,4 +143,3 @@

Give access to our IP address

I've given access - diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css index 9f76e0077..e80bcba87 100644 --- a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css +++ b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.css @@ -47,3 +47,35 @@ .checkbox-line { margin-bottom: 16px; } + +@media (prefers-color-scheme: light) { + .autofilled ::ng-deep .mdc-notched-outline__leading, + .autofilled ::ng-deep .mdc-notched-outline__notch, + .autofilled ::ng-deep .mdc-notched-outline__trailing { + border-color: var(--color-successPalette-500) !important; + } + + .autofilled ::ng-deep .mat-mdc-floating-label { + color: var(--color-successPalette-700); + } + + .autofilled ::ng-deep .mat-mdc-text-field-wrapper { + background-color: var(--success-background-color); + } +} + +@media (prefers-color-scheme: dark) { + .autofilled ::ng-deep .mdc-notched-outline__leading, + .autofilled ::ng-deep .mdc-notched-outline__notch, + .autofilled ::ng-deep .mdc-notched-outline__trailing { + border-color: var(--color-successDarkPalette-500) !important; + } + + .autofilled ::ng-deep .mat-mdc-floating-label { + color: var(--color-successDarkPalette-300); + } + + .autofilled ::ng-deep .mat-mdc-text-field-wrapper { + background-color: color-mix(in hsl, var(--color-successDarkPalette-500), transparent 90%); + } +} diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts index de9dda864..ccb21c467 100644 --- a/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts +++ b/frontend/src/app/components/connect-db/db-credentials-forms/base-credentials-form/base-credentials-form.component.ts @@ -13,13 +13,19 @@ export class BaseCredentialsFormComponent { @Input() submitting: boolean; @Input() masterKey: string; @Input() accessLevel: string; + @Input() autoFilledFields: Set = new Set(); @Output() switchToAgent = new EventEmitter(); @Output() masterKeyChange = new EventEmitter(); + @Output() fieldChange = new EventEmitter(); public tunnelingServiceLink = 'https://docs.rocketadmin.com/Create%20connections/Direct%20connection/create_pinggy'; handleMasterKeyChange(newMasterKey: string): void { this.masterKeyChange.emit(newMasterKey); } + + clearAutoFilled(field: string): void { + this.fieldChange.emit(field); + } } diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html index 11eefefb4..685f4eefd 100644 --- a/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html +++ b/frontend/src/app/components/connect-db/db-credentials-forms/cassandra-credentials-form/cassandra-credentials-form.component.html @@ -1,10 +1,11 @@ - + Hostname Hostname is invalid. - + Port Port should not be empty. - + Username Username should not be empty. - + Password Password needed due to hostname/port change. - + Database name + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Email should not be empty --> - + Database Name + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Email should not be empty --> - + Database Name Name should not be empty - + Schema + Hostname Hostname must start with "https://" - + Username Username should not be empty - + Password + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password - + Username Username should not be empty - + Database name diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html index 9476bdbf6..769f4d381 100644 --- a/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html +++ b/frontend/src/app/components/connect-db/db-credentials-forms/mongodb-credentials-form/mongodb-credentials-form.component.html @@ -1,10 +1,11 @@ - + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Password needed due to hostname/port change - + Database name Name should not be empty - + Authentication database + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Password needed due to hostname/port change - + Database name Name should not be empty - + Schema + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Email should not be empty --> - + Database Name + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Password needed due to hostname/port change - + Database Name Name should not be empty - + Schema diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html index 1c843311a..ed2371b1a 100644 --- a/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html +++ b/frontend/src/app/components/connect-db/db-credentials-forms/postgres-credentials-form/postgres-credentials-form.component.html @@ -1,10 +1,11 @@ - + Hostname Hostname is invalid - + Port Port should not be empty - + Username Username should not be empty - + Password Password needed due to hostname/port change - + Database Name Name should not be empty - + Schema diff --git a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html index 547e5d96c..53a458f60 100644 --- a/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html +++ b/frontend/src/app/components/connect-db/db-credentials-forms/redis-credentials-form/redis-credentials-form.component.html @@ -1,10 +1,11 @@ - + Hostname Hostname is invalid - + Port Port should not be empty - + Password - + Username Username should not be empty - + Database name diff --git a/frontend/src/app/components/dashboard/dashboard.component.css b/frontend/src/app/components/dashboard/dashboard.component.css index 17145b767..082580985 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.css +++ b/frontend/src/app/components/dashboard/dashboard.component.css @@ -314,3 +314,32 @@ .sidenav-content__toggle-button { display: none !important; } + +.schema-editor-panel { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 0; + background: var(--mat-sidenav-content-background-color); + border-top: 1px solid rgba(0, 0, 0, 0.12); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12); + overflow: hidden; + transition: height 0.3s ease; + z-index: 100; +} + +.schema-editor-panel--open { + height: 380px; +} + +.table-preview-content--panel-open { + padding-bottom: 380px; +} + +@media (prefers-color-scheme: dark) { + .schema-editor-panel { + border-top-color: rgba(255, 255, 255, 0.12); + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.4); + } +} diff --git a/frontend/src/app/components/dashboard/dashboard.component.html b/frontend/src/app/components/dashboard/dashboard.component.html index 184bba354..1db0ed844 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.html +++ b/frontend/src/app/components/dashboard/dashboard.component.html @@ -1,4 +1,4 @@ - +
@@ -21,14 +21,12 @@
- -

Rocketadmin can not find any tables

-

- Rocketadmin don't have access to connection tables or there aren't any. - Please grant us access to required tables or create new table. - You can create a table with SQL editor -

-
+ + @@ -89,11 +87,12 @@

Rocketadmin can not find any tables

[connectionID]="connectionID" [selectedTable]="selectedTableName" [uiSettings]="uiSettings" - (expandSidebar)="toggleSideBar()"> + (expandSidebar)="toggleSideBar()" + (editStructure)="onEditStructure()"> -
+
@@ -130,14 +129,24 @@

Rocketadmin can not find any tables

(applyFilter)="applyFilter($event)">
- - +
+ + +
diff --git a/frontend/src/app/components/dashboard/dashboard.component.ts b/frontend/src/app/components/dashboard/dashboard.component.ts index a2ca9e898..cb63df8f9 100644 --- a/frontend/src/app/components/dashboard/dashboard.component.ts +++ b/frontend/src/app/components/dashboard/dashboard.component.ts @@ -37,6 +37,7 @@ import { DbTableFiltersDialogComponent } from './db-table-view/db-table-filters- import { DbTableRowViewComponent } from './db-table-view/db-table-row-view/db-table-row-view.component'; import { DbTableViewComponent } from './db-table-view/db-table-view.component'; import { TablesDataSource } from './db-tables-data-source'; +import { DbGenerateSchemaComponent } from './db-generate-schema/db-generate-schema.component'; import { DbTablesListComponent } from './db-tables-list/db-tables-list.component'; interface DataToActivateActions { @@ -55,6 +56,7 @@ interface DataToActivateActions { MatIconModule, MatDialogModule, MatSidenavModule, + DbGenerateSchemaComponent, DbTablesListComponent, DbTableViewComponent, DbTableAiPanelComponent, @@ -101,6 +103,7 @@ export class DashboardComponent implements OnInit, OnDestroy { public uiSettings: ConnectionSettingsUI; public tableFolders: any[] = []; public isConfiguring: boolean = false; + public showSchemaEditor: boolean = false; constructor( private _connections: ConnectionsService, @@ -447,4 +450,17 @@ export class DashboardComponent implements OnInit, OnDestroy { this.shownTableTitles = !this.shownTableTitles; this._uiSettings.updateConnectionSetting(this.connectionID, 'shownTableTitles', this.shownTableTitles); } + + onEditStructure() { + this.showSchemaEditor = true; + } + + onSchemaApplied() { + setTimeout(() => { + this.noTablesError = false; + this.showSchemaEditor = false; + this.loading = true; + this.getData(); + }, 1500); + } } diff --git a/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.css b/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.css new file mode 100644 index 000000000..344a74b11 --- /dev/null +++ b/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.css @@ -0,0 +1,357 @@ +:host { + display: flex; + height: 100%; +} + +.schema-chat { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; + position: relative; +} + +.schema-chat__close-button { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; +} + +/* Welcome */ + +.schema-chat__welcome { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + gap: 16px; + padding: 24px; + max-width: 500px; + margin: 0 auto; +} + +.schema-chat__welcome-icon { + width: 48px !important; + height: 48px !important; + animation: rocket-float 3s ease-in-out infinite; +} + +@keyframes rocket-float { + 0%, 100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-2px) rotate(2deg); } +} + +.schema-chat__welcome-title { + font-size: 16px; + font-weight: 500; + color: rgba(0, 0, 0, 0.7); + margin: 0; + text-align: center; +} + +.schema-chat__welcome-subtitle { + font-size: 14px; + color: rgba(0, 0, 0, 0.5); + margin: 0; + text-align: center; +} + +@media (prefers-color-scheme: dark) { + .schema-chat__welcome-title { + color: rgba(255, 255, 255, 0.7); + } + .schema-chat__welcome-subtitle { + color: rgba(255, 255, 255, 0.5); + } +} + +.schema-chat__suggestions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; + margin-top: 8px; +} + +.suggestion-chip { + display: inline-flex; + align-items: center; + background-color: var(--mat-sidenav-content-background-color); + border: 1px solid #d3d3d3; + border-radius: 16px; + padding: 6px 14px; + font-size: 13px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.suggestion-chip:hover { + background-color: var(--color-primaryPalette-50); +} + +@media (prefers-color-scheme: dark) { + .suggestion-chip { + border-color: #4a4a4a; + color: rgba(255, 255, 255, 0.9); + } + .suggestion-chip:hover { + background-color: rgba(255, 255, 255, 0.08); + } +} + +/* Messages */ + +.schema-chat__messages { + flex: 1; + overflow-y: auto; + width: 100%; + max-width: 700px; + padding: 24px 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.schema-chat__message { + border-radius: 8px; + padding: 12px 16px; + max-width: 90%; + line-height: 1.5; + white-space: pre-wrap; +} + +.schema-chat__message--user { + align-self: flex-end; + background-color: #f0f4f8; +} + +@media (prefers-color-scheme: dark) { + .schema-chat__message--user { + background-color: #2d3748; + } +} + +.schema-chat__message--ai { + align-self: flex-start; + background-color: rgba(99, 132, 255, 0.06); + max-width: 100%; +} + +@media (prefers-color-scheme: dark) { + .schema-chat__message--ai { + background-color: rgba(99, 132, 255, 0.1); + } +} + +.schema-chat__message-text { + white-space: pre-wrap; +} + +.schema-chat__message--error { + align-self: flex-start; + display: flex; + align-items: flex-start; + gap: 8px; + max-width: 100%; +} + +@media (prefers-color-scheme: light) { + .schema-chat__message--error { + background-color: var(--color-warnPalette-100); + color: var(--color-warnPalette-100-contrast); + } +} + +@media (prefers-color-scheme: dark) { + .schema-chat__message--error { + background-color: var(--color-warnDarkPalette-200); + color: var(--color-warnDarkPalette-200-contrast); + } +} + +.schema-chat__error-icon { + font-size: 20px; + height: 20px; + width: 20px; + flex-shrink: 0; + margin-top: 2px; +} + +/* Changes */ + +.schema-chat__changes { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 12px; + max-height: 400px; + overflow-y: auto; +} + +.schema-chat__change-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + padding: 10px 12px; + background: rgba(0, 0, 0, 0.02); +} + +@media (prefers-color-scheme: dark) { + .schema-chat__change-card { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.04); + } +} + +.schema-chat__change-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.schema-chat__change-type { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.3px; + padding: 2px 6px; + border-radius: 3px; + background: var(--color-primaryPalette-500); + color: white; +} + +.schema-chat__change-table { + font-weight: 500; + font-size: 13px; +} + +.schema-chat__change-sql { + background: rgba(0, 0, 0, 0.04); + border-radius: 4px; + padding: 10px 12px; + font-size: 12px; + font-family: monospace; + overflow-x: auto; + white-space: pre-wrap; + margin: 0; +} + +@media (prefers-color-scheme: dark) { + .schema-chat__change-sql { + background: rgba(255, 255, 255, 0.06); + } +} + +.schema-chat__rollback { + margin-top: 6px; +} + +.schema-chat__rollback summary { + cursor: pointer; + font-size: 12px; + opacity: 0.6; + margin-bottom: 4px; +} + +.schema-chat__actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.schema-chat__actions button mat-icon { + margin-right: 4px; + font-size: 18px; + height: 18px; + width: 18px; +} + +/* Loading */ + +.schema-chat__loading { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; +} + +.schema-chat__loading-text { + font-size: 13px; + color: #9ca3af; +} + +.schema-chat__loading-dots { + display: flex; + gap: 6px; + align-items: center; +} + +.schema-chat__loading-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #9ca3af; + animation: loading-bounce 1.4s ease-in-out infinite; +} + +.schema-chat__loading-dot:nth-child(1) { animation-delay: 0s; } +.schema-chat__loading-dot:nth-child(2) { animation-delay: 0.15s; } +.schema-chat__loading-dot:nth-child(3) { animation-delay: 0.3s; } + +@keyframes loading-bounce { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-8px); } +} + +/* Input form */ + +.schema-chat__form { + width: 100%; + max-width: 700px; + padding: 0 16px 16px; + flex-shrink: 0; +} + +.schema-chat__input-field { + width: 100%; +} + +.schema-chat__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__leading { + border-radius: 24px 0 0 24px; + width: 24px; +} + +.schema-chat__input-field ::ng-deep .mdc-text-field--outlined .mdc-notched-outline__trailing { + border-radius: 0 24px 24px 0; +} + +.schema-chat__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__leading, +.schema-chat__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__trailing { + border-color: #6384ff !important; + border-width: 2px !important; +} + +.schema-chat__input-field ::ng-deep .mdc-text-field--focused .mdc-notched-outline__notch { + border-color: #6384ff !important; + border-width: 2px 0 !important; +} + +.schema-chat__input-field ::ng-deep .mat-mdc-form-field-subscript-wrapper { + display: none; +} + +.schema-chat__input-field ::ng-deep .mat-mdc-text-field-wrapper { + padding-bottom: 0 !important; +} + +.schema-chat__input-field textarea { + resize: none; +} + +.schema-chat__send-button { + color: #6384ff !important; +} diff --git a/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.html b/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.html new file mode 100644 index 000000000..5a85af2bf --- /dev/null +++ b/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.html @@ -0,0 +1,148 @@ +
+ @if (showClose) { + + } + + @if (messages().length === 0) { +
+ + @if (showClose) { +

Edit database structure with AI

+

Describe the changes you want to make and AI will generate the SQL for you.

+ } @else { +

No tables found — let AI create them

+

Describe the database you need and AI will generate the schema for you.

+ } + +
+ @if (showClose) { + + + + } @else { + + + + } +
+
+ } + + + @if (messages().length) { +
+ @for (message of messages(); track $index) { + @if (message.role === 'user') { +
+ {{ message.text }} +
+ } + @if (message.role === 'ai') { +
+ {{ message.text }} + + @if (message.changes?.length) { +
+ @for (change of message.changes; track change.id) { +
+
+ {{ change.changeType }} + {{ change.targetTableName }} +
+
{{ change.forwardSql }}
+ @if (change.rollbackSql) { +
+ Rollback SQL +
{{ change.rollbackSql }}
+
+ } +
+ } +
+ } +
+ } + @if (message.role === 'error') { +
+ error_outline + {{ message.text }} +
+ } + } + + @if (submitting()) { +
+ Generating schema +
+ + + +
+
+ } +
+ } + + + @if (!applied()) { +
+ @if (pendingBatch()) { +
+ + +
+ } @else { +
+ + {{showClose ? 'Describe the changes you need...' : 'Describe the database you need...'}} + + + +
+ } +
+ } +
diff --git a/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.ts b/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.ts new file mode 100644 index 000000000..d00f65eff --- /dev/null +++ b/frontend/src/app/components/dashboard/db-generate-schema/db-generate-schema.component.ts @@ -0,0 +1,142 @@ +import { Component, EventEmitter, Input, Output, signal, inject, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { TableSchemaService, SchemaChangeResponse } from 'src/app/services/table-schema.service'; + +interface ChatMessage { + role: 'user' | 'ai' | 'error'; + text: string; + changes?: SchemaChangeResponse[]; + batchId?: string; +} + +@Component({ + selector: 'app-db-generate-schema', + templateUrl: './db-generate-schema.component.html', + styleUrls: ['./db-generate-schema.component.css'], + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + ], +}) +export class DbGenerateSchemaComponent { + @Input() connectionID: string; + @Input() showClose: boolean = false; + @Output() schemaApplied = new EventEmitter(); + @Output() closeEditor = new EventEmitter(); + + private _tableSchema = inject(TableSchemaService); + + protected messages = signal([]); + protected userPrompt = signal(''); + protected submitting = signal(false); + protected applying = signal(false); + protected applied = signal(false); + + protected pendingBatch = computed(() => { + const msgs = this.messages(); + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].batchId && msgs[i].changes?.length) return msgs[i]; + } + return null; + }); + + async onSubmit() { + const prompt = this.userPrompt().trim(); + if (!prompt || this.submitting()) return; + + this.messages.update(msgs => [...msgs, { role: 'user', text: prompt }]); + this.userPrompt.set(''); + this.submitting.set(true); + + try { + const result = await this._tableSchema.generateSchemaChange(this.connectionID, prompt); + if (result && result.changes.length > 0) { + const summary = result.changes.map(c => `**${c.changeType}** \`${c.targetTableName}\`${c.aiSummary ? ' — ' + c.aiSummary : ''}`).join('\n'); + this.messages.update(msgs => [...msgs, { + role: 'ai', + text: `I've generated ${result.changes.length} change(s) for your database:\n\n${summary}\n\nReview the SQL below and approve or reject.`, + changes: result.changes, + batchId: result.batchId, + }]); + } else { + this.messages.update(msgs => [...msgs, { + role: 'ai', + text: 'I could not generate any schema changes for that prompt. Could you describe your database in more detail?', + }]); + } + } catch (err: unknown) { + const error = err as { error?: { message?: string }; message?: string }; + this.messages.update(msgs => [...msgs, { + role: 'error', + text: error?.error?.message || error?.message || 'Failed to generate schema changes.', + }]); + } finally { + this.submitting.set(false); + } + } + + async onApprove() { + const batch = this.pendingBatch(); + if (!batch?.batchId || this.applying()) return; + + this.applying.set(true); + + try { + const result = await this._tableSchema.approveBatch(batch.batchId, true); + if (result) { + const failed = result.changes.filter(c => c.status === 'failed'); + if (failed.length > 0) { + this.messages.update(msgs => [...msgs, { + role: 'error', + text: `${failed.length} change(s) failed: ${failed.map(c => c.executionError).join('; ')}`, + }]); + } else { + this.applied.set(true); + this.messages.update(msgs => [...msgs, { + role: 'ai', + text: 'All changes applied successfully! Your tables have been created. Reloading...', + }]); + this.schemaApplied.emit(); + } + } + } catch (err: unknown) { + const error = err as { error?: { message?: string }; message?: string }; + this.messages.update(msgs => [...msgs, { + role: 'error', + text: error?.error?.message || error?.message || 'Failed to apply schema changes.', + }]); + } finally { + this.applying.set(false); + } + } + + async onReject() { + const batch = this.pendingBatch(); + if (!batch?.batchId) return; + + await this._tableSchema.rejectBatch(batch.batchId); + this.messages.update(msgs => msgs.map(m => + m === batch ? { ...m, batchId: undefined } : m + ).concat({ + role: 'ai', + text: 'Changes rejected. Feel free to describe what you need differently.', + })); + } + + onKeydown(event: KeyboardEvent) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.onSubmit(); + } + } + +} diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css index c0d0821fe..d96a7a8ff 100644 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css +++ b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.css @@ -445,7 +445,7 @@ .expanded-container.has-custom-folders .add-folder-button-container { order: 4; /* After folders section */ position: sticky; - bottom: 0; + bottom: 40px; background-color: #ffffff; padding: 8px 16px; z-index: 10; @@ -459,6 +459,50 @@ } } +/* Edit structure button container */ +.edit-structure-button-container { + order: 5; /* Always at the very bottom */ + position: sticky; + bottom: 0; + background-color: #ffffff; + padding: 8px 16px; + z-index: 10; + margin-left: 16px; + margin-right: 16px; + margin-top: 4px; + margin-bottom: 8px; + width: calc(100% - 32px); + box-sizing: border-box; +} + +.expanded-container.has-custom-folders .edit-structure-button-container { + padding: 0 16px 8px; + margin-top: 0; + margin-left: 0; + margin-right: 0; + width: 100%; +} + +.edit-structure-button { + width: 100%; +} + +.edit-structure-button mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +@media (prefers-color-scheme: dark) { + .edit-structure-button-container { + background-color: #303030; + } + + .expanded-container.has-custom-folders .edit-structure-button-container { + background-color: #303030; + } +} + .search-input ::ng-deep * { background-color: transparent !important; } diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html index c9270f948..b5b5a6c4d 100644 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html +++ b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.html @@ -61,6 +61,13 @@
+ +
+ construction +
+ +
+ +
+
diff --git a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts index 214853121..2a34b892c 100644 --- a/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts +++ b/frontend/src/app/components/dashboard/db-tables-list/db-tables-list.component.ts @@ -75,6 +75,7 @@ export class DbTablesListComponent implements OnInit, OnChanges { @Input() uiSettings: any; @Output() expandSidebar = new EventEmitter(); + @Output() editStructure = new EventEmitter(); private _connections = inject(ConnectionsService); protected canEditConnection = () => this._connections.canEditConnection(); diff --git a/frontend/src/app/services/connections.service.spec.ts b/frontend/src/app/services/connections.service.spec.ts index 724415c0e..8ba7be0f0 100644 --- a/frontend/src/app/services/connections.service.spec.ts +++ b/frontend/src/app/services/connections.service.spec.ts @@ -686,7 +686,7 @@ describe('ConnectionsService', () => { accentedPalette: '#654321', warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', diff --git a/frontend/src/app/services/connections.service.ts b/frontend/src/app/services/connections.service.ts index 9113cff42..3d6e1d777 100644 --- a/frontend/src/app/services/connections.service.ts +++ b/frontend/src/app/services/connections.service.ts @@ -197,7 +197,7 @@ export class ConnectionsService { accentedPalette: res.connectionProperties.secondary_color, warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', @@ -211,7 +211,7 @@ export class ConnectionsService { accentedPalette: '#2563eb', warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', @@ -228,7 +228,7 @@ export class ConnectionsService { accentedPalette: '#2563eb', warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', @@ -334,7 +334,7 @@ export class ConnectionsService { accentedPalette: res.connectionProperties.secondary_color, warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', @@ -348,7 +348,7 @@ export class ConnectionsService { accentedPalette: '#2563eb', warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', @@ -529,7 +529,7 @@ export class ConnectionsService { accentedPalette: res.secondary_color, warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc', diff --git a/frontend/src/app/services/table-schema.service.ts b/frontend/src/app/services/table-schema.service.ts new file mode 100644 index 000000000..db9ef4835 --- /dev/null +++ b/frontend/src/app/services/table-schema.service.ts @@ -0,0 +1,50 @@ +import { Injectable, inject } from '@angular/core'; +import { ApiService } from './api.service'; + +export interface SchemaChangeResponse { + id: string; + connectionId: string; + batchId: string | null; + orderInBatch: number; + forwardSql: string; + rollbackSql: string | null; + userModifiedSql: string | null; + status: string; + changeType: string; + targetTableName: string; + databaseType: string; + executionError: string | null; + isReversible: boolean; + userPrompt: string; + aiSummary: string | null; + aiReasoning: string | null; + createdAt: string; + appliedAt: string | null; + rolledBackAt: string | null; +} + +export interface SchemaChangeBatchResponse { + batchId: string; + changes: SchemaChangeResponse[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class TableSchemaService { + private _api = inject(ApiService); + + async generateSchemaChange(connectionId: string, userPrompt: string): Promise { + return this._api.post(`/table-schema/${connectionId}/generate`, { userPrompt }); + } + + async approveBatch(batchId: string, confirmedDestructive?: boolean): Promise { + return this._api.post(`/table-schema/batch/${batchId}/approve`, { + confirmedDestructive, + }); + } + + async rejectBatch(batchId: string): Promise { + return this._api.post(`/table-schema/batch/${batchId}/reject`); + } +} diff --git a/frontend/src/custom-theme.scss b/frontend/src/custom-theme.scss index 0f2970e55..769da09d0 100644 --- a/frontend/src/custom-theme.scss +++ b/frontend/src/custom-theme.scss @@ -31,7 +31,7 @@ html { --error-background-color: color-mix(in hsl, var(--color-warnPalette-500), transparent 95%); --warning-background-color: color-mix(in hsl, var(--color-warningPalette-500), transparent 95%); --info-background-color: color-mix(in hsl, var(--color-infoPalette-500), transparent 95%); - --success-background-color: color-mix(in hsl, var(--color-successPalette-500), transparent 95%); + --success-background-color: color-mix(in hsl, var(--color-successPalette-500), transparent 97%); --alternative-background-color: color-mix(in hsl, var(--color-alternativePalette-500), transparent 95%); } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 133d97093..bd2363977 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -51,7 +51,7 @@ const colorConfig: IColorConfig = { warnDarkPalette: '#E53935', warningPalette: '#f79008', infoPalette: '#296ee9', - successPalette: '#1b5e20', + successPalette: '#008027', alternativePalette: '#6d28d9', successDarkPalette: '#4caf50', alternativeDarkPalette: '#c084fc',