diff --git a/package-lock.json b/package-lock.json index eef1596..6bc5b9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.3", "@xterm/addon-fit": "^0.10.0", @@ -112,6 +113,20 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@codemirror/lang-yaml": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", diff --git a/package.json b/package.json index 3cd5c47..005ce5c 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:run": "vitest run" }, "dependencies": { + "@codemirror/lang-sql": "^6.10.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/theme-one-dark": "^6.1.3", "@xterm/addon-fit": "^0.10.0", diff --git a/src/components/database/BackupRestorePanel.vue b/src/components/database/BackupRestorePanel.vue new file mode 100644 index 0000000..f95745d --- /dev/null +++ b/src/components/database/BackupRestorePanel.vue @@ -0,0 +1,276 @@ + + + + + diff --git a/src/components/database/ConnectionStatus.vue b/src/components/database/ConnectionStatus.vue new file mode 100644 index 0000000..b44af30 --- /dev/null +++ b/src/components/database/ConnectionStatus.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/src/components/database/DataExportModal.vue b/src/components/database/DataExportModal.vue new file mode 100644 index 0000000..110200c --- /dev/null +++ b/src/components/database/DataExportModal.vue @@ -0,0 +1,460 @@ + + + + + diff --git a/src/components/database/DatabaseDetails.vue b/src/components/database/DatabaseDetails.vue new file mode 100644 index 0000000..8d54ac3 --- /dev/null +++ b/src/components/database/DatabaseDetails.vue @@ -0,0 +1,579 @@ + + + + + diff --git a/src/components/database/DatabaseHeader.vue b/src/components/database/DatabaseHeader.vue new file mode 100644 index 0000000..f3a48d1 --- /dev/null +++ b/src/components/database/DatabaseHeader.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/src/components/database/DatabaseSidebar.vue b/src/components/database/DatabaseSidebar.vue new file mode 100644 index 0000000..28e1e49 --- /dev/null +++ b/src/components/database/DatabaseSidebar.vue @@ -0,0 +1,387 @@ + + + + + diff --git a/src/components/database/QueryHistory.vue b/src/components/database/QueryHistory.vue new file mode 100644 index 0000000..9e83ec4 --- /dev/null +++ b/src/components/database/QueryHistory.vue @@ -0,0 +1,573 @@ + + + + + diff --git a/src/components/database/QueryResults.vue b/src/components/database/QueryResults.vue new file mode 100644 index 0000000..7930e96 --- /dev/null +++ b/src/components/database/QueryResults.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/src/components/database/ServerOverview.vue b/src/components/database/ServerOverview.vue new file mode 100644 index 0000000..6a0a8dc --- /dev/null +++ b/src/components/database/ServerOverview.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/src/components/database/SqlEditor.vue b/src/components/database/SqlEditor.vue new file mode 100644 index 0000000..f438900 --- /dev/null +++ b/src/components/database/SqlEditor.vue @@ -0,0 +1,240 @@ + + + + + diff --git a/src/components/database/TableDataGrid.vue b/src/components/database/TableDataGrid.vue new file mode 100644 index 0000000..c6cf422 --- /dev/null +++ b/src/components/database/TableDataGrid.vue @@ -0,0 +1,323 @@ + + + + + diff --git a/src/components/database/TableList.vue b/src/components/database/TableList.vue new file mode 100644 index 0000000..881abe8 --- /dev/null +++ b/src/components/database/TableList.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/src/components/database/TableSchemaView.vue b/src/components/database/TableSchemaView.vue new file mode 100644 index 0000000..d273eb1 --- /dev/null +++ b/src/components/database/TableSchemaView.vue @@ -0,0 +1,477 @@ + + + + + diff --git a/src/services/api.ts b/src/services/api.ts index 6efcc21..6cd9f8f 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -452,6 +452,12 @@ export const databasesApi = { database, query, }), + describeTable: (config: DatabaseConnectionConfig, database: string, table: string) => + apiClient.post("/databases/tables/schema", { + ...config, + database, + table, + }), }; export interface QueryResult { @@ -460,6 +466,27 @@ export interface QueryResult { count: number; } +export interface ColumnSchema { + name: string; + type: string; + nullable: boolean; + default: any; + key: string; + extra: string; +} + +export interface IndexSchema { + name: string; + columns: string[]; + unique: boolean; + primary: boolean; +} + +export interface TableSchema { + columns: ColumnSchema[]; + indexes: IndexSchema[]; +} + export interface InfraService { name: string; type: string; diff --git a/src/stores/database.test.ts b/src/stores/database.test.ts new file mode 100644 index 0000000..16d9b8c --- /dev/null +++ b/src/stores/database.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { setActivePinia, createPinia } from "pinia"; +import { useDatabaseStore } from "./database"; + +describe("Database Store", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe("Initial state", () => { + it("has correct initial values", () => { + const store = useDatabaseStore(); + expect(store.queryHistory.size).toBe(0); + expect(store.connectionStates.size).toBe(0); + }); + }); + + describe("Query History", () => { + it("adds query to history", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT * FROM users", + database: "test_db", + success: true, + rowCount: 10, + executionTime: 50, + }); + + const history = store.getHistory(connectionId); + expect(history).toHaveLength(1); + expect(history[0].query).toBe("SELECT * FROM users"); + expect(history[0].database).toBe("test_db"); + expect(history[0].success).toBe(true); + expect(history[0].rowCount).toBe(10); + }); + + it("limits history to 100 items", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + for (let i = 0; i < 110; i++) { + store.addToHistory(connectionId, { + query: `SELECT ${i}`, + database: "test_db", + success: true, + }); + } + + const history = store.getHistory(connectionId); + expect(history).toHaveLength(100); + expect(history[0].query).toBe("SELECT 109"); + }); + + it("returns empty array for unknown connection", () => { + const store = useDatabaseStore(); + expect(store.getHistory("unknown")).toEqual([]); + }); + + it("toggles favorite status", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT * FROM users", + database: "test_db", + success: true, + }); + + const history = store.getHistory(connectionId); + const itemId = history[0].id; + + expect(history[0].favorite).toBeFalsy(); + + store.toggleFavorite(connectionId, itemId); + expect(store.getHistory(connectionId)[0].favorite).toBe(true); + + store.toggleFavorite(connectionId, itemId); + expect(store.getHistory(connectionId)[0].favorite).toBe(false); + }); + + it("returns favorites only", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT 1", + database: "test_db", + success: true, + }); + store.addToHistory(connectionId, { + query: "SELECT 2", + database: "test_db", + success: true, + }); + + const history = store.getHistory(connectionId); + store.toggleFavorite(connectionId, history[0].id); + + const favorites = store.getFavorites(connectionId); + expect(favorites).toHaveLength(1); + expect(favorites[0].query).toBe("SELECT 2"); + }); + + it("searches history by query text", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT * FROM users", + database: "test_db", + success: true, + }); + store.addToHistory(connectionId, { + query: "SELECT * FROM orders", + database: "test_db", + success: true, + }); + + const results = store.searchHistory(connectionId, "users"); + expect(results).toHaveLength(1); + expect(results[0].query).toContain("users"); + }); + + it("searches history by database name", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT 1", + database: "prod_db", + success: true, + }); + store.addToHistory(connectionId, { + query: "SELECT 2", + database: "test_db", + success: true, + }); + + const results = store.searchHistory(connectionId, "prod"); + expect(results).toHaveLength(1); + expect(results[0].database).toBe("prod_db"); + }); + + it("removes item from history", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT 1", + database: "test_db", + success: true, + }); + store.addToHistory(connectionId, { + query: "SELECT 2", + database: "test_db", + success: true, + }); + + const history = store.getHistory(connectionId); + expect(history).toHaveLength(2); + + store.removeFromHistory(connectionId, history[0].id); + expect(store.getHistory(connectionId)).toHaveLength(1); + }); + + it("clears all history for connection", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT 1", + database: "test_db", + success: true, + }); + store.addToHistory(connectionId, { + query: "SELECT 2", + database: "test_db", + success: true, + }); + + store.clearHistory(connectionId); + expect(store.getHistory(connectionId)).toHaveLength(0); + }); + }); + + describe("Connection State", () => { + it("sets connection state", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.setConnectionState(connectionId, { + status: "connected", + latency: 50, + lastPing: Date.now(), + }); + + const state = store.getConnectionState(connectionId); + expect(state?.status).toBe("connected"); + expect(state?.latency).toBe(50); + }); + + it("returns default state for unknown connection", () => { + const store = useDatabaseStore(); + const state = store.getConnectionState("unknown"); + expect(state.status).toBe("disconnected"); + expect(state.lastPing).toBeNull(); + expect(state.latency).toBeNull(); + }); + }); + + describe("LocalStorage Persistence", () => { + it("saves history to localStorage", () => { + const store = useDatabaseStore(); + const connectionId = "conn-1"; + + store.addToHistory(connectionId, { + query: "SELECT * FROM users", + database: "test_db", + success: true, + }); + + // addToHistory automatically saves to localStorage + const stored = localStorage.getItem(`db_query_history_${connectionId}`); + expect(stored).not.toBeNull(); + + const parsed = JSON.parse(stored!); + expect(parsed).toHaveLength(1); + expect(parsed[0].query).toBe("SELECT * FROM users"); + }); + + it("loads history from localStorage", () => { + const connectionId = "conn-1"; + const mockHistory = [ + { + id: "test-id", + query: "SELECT 1", + database: "test_db", + timestamp: Date.now(), + success: true, + }, + ]; + + localStorage.setItem(`db_query_history_${connectionId}`, JSON.stringify(mockHistory)); + + const store = useDatabaseStore(); + store.loadHistoryFromStorage(connectionId); + + const history = store.getHistory(connectionId); + expect(history).toHaveLength(1); + expect(history[0].query).toBe("SELECT 1"); + }); + + it("handles corrupted localStorage gracefully", () => { + const connectionId = "conn-1"; + localStorage.setItem(`db_query_history_${connectionId}`, "invalid json"); + + const store = useDatabaseStore(); + store.loadHistoryFromStorage(connectionId); + + expect(store.getHistory(connectionId)).toEqual([]); + }); + }); +}); diff --git a/src/stores/database.ts b/src/stores/database.ts new file mode 100644 index 0000000..9bc034d --- /dev/null +++ b/src/stores/database.ts @@ -0,0 +1,168 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export interface QueryHistoryItem { + id: string; + query: string; + database: string; + timestamp: number; + rowCount?: number; + executionTime?: number; + success: boolean; + error?: string; + favorite?: boolean; +} + +export interface ConnectionState { + status: "connected" | "disconnected" | "error"; + lastPing: number | null; + latency: number | null; + error?: string; +} + +const HISTORY_STORAGE_KEY = "db_query_history"; +const FAVORITES_STORAGE_KEY = "db_query_favorites"; +const MAX_HISTORY_ITEMS = 100; + +export const useDatabaseStore = defineStore("database", () => { + const connectionStates = ref>(new Map()); + const queryHistory = ref>(new Map()); + const favoriteQueries = ref>(new Map()); + + function getConnectionState(connectionId: string): ConnectionState { + return ( + connectionStates.value.get(connectionId) || { + status: "disconnected", + lastPing: null, + latency: null, + } + ); + } + + function setConnectionState(connectionId: string, state: Partial) { + const current = getConnectionState(connectionId); + connectionStates.value.set(connectionId, { ...current, ...state }); + } + + function loadHistoryFromStorage(connectionId: string) { + try { + const stored = localStorage.getItem(`${HISTORY_STORAGE_KEY}_${connectionId}`); + if (stored) { + queryHistory.value.set(connectionId, JSON.parse(stored)); + } + const favorites = localStorage.getItem(`${FAVORITES_STORAGE_KEY}_${connectionId}`); + if (favorites) { + favoriteQueries.value.set(connectionId, JSON.parse(favorites)); + } + } catch { + queryHistory.value.set(connectionId, []); + favoriteQueries.value.set(connectionId, []); + } + } + + function saveHistoryToStorage(connectionId: string) { + const history = queryHistory.value.get(connectionId) || []; + localStorage.setItem(`${HISTORY_STORAGE_KEY}_${connectionId}`, JSON.stringify(history.slice(0, MAX_HISTORY_ITEMS))); + } + + function saveFavoritesToStorage(connectionId: string) { + const favorites = favoriteQueries.value.get(connectionId) || []; + localStorage.setItem(`${FAVORITES_STORAGE_KEY}_${connectionId}`, JSON.stringify(favorites)); + } + + function addToHistory(connectionId: string, item: Omit) { + const history = queryHistory.value.get(connectionId) || []; + const newItem: QueryHistoryItem = { + ...item, + id: `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: Date.now(), + }; + history.unshift(newItem); + queryHistory.value.set(connectionId, history.slice(0, MAX_HISTORY_ITEMS)); + saveHistoryToStorage(connectionId); + return newItem; + } + + function getHistory(connectionId: string): QueryHistoryItem[] { + if (!queryHistory.value.has(connectionId)) { + loadHistoryFromStorage(connectionId); + } + return queryHistory.value.get(connectionId) || []; + } + + function getFavorites(connectionId: string): QueryHistoryItem[] { + if (!favoriteQueries.value.has(connectionId)) { + loadHistoryFromStorage(connectionId); + } + return favoriteQueries.value.get(connectionId) || []; + } + + function toggleFavorite(connectionId: string, itemId: string) { + const history = queryHistory.value.get(connectionId) || []; + const favorites = favoriteQueries.value.get(connectionId) || []; + + const historyItem = history.find((h) => h.id === itemId); + if (!historyItem) return; + + const existingIndex = favorites.findIndex((f) => f.id === itemId); + if (existingIndex >= 0) { + favorites.splice(existingIndex, 1); + historyItem.favorite = false; + } else { + historyItem.favorite = true; + favorites.unshift({ ...historyItem }); + } + + favoriteQueries.value.set(connectionId, favorites); + saveFavoritesToStorage(connectionId); + saveHistoryToStorage(connectionId); + } + + function removeFromHistory(connectionId: string, itemId: string) { + const history = queryHistory.value.get(connectionId) || []; + const index = history.findIndex((h) => h.id === itemId); + if (index >= 0) { + history.splice(index, 1); + queryHistory.value.set(connectionId, history); + saveHistoryToStorage(connectionId); + } + + const favorites = favoriteQueries.value.get(connectionId) || []; + const favIndex = favorites.findIndex((f) => f.id === itemId); + if (favIndex >= 0) { + favorites.splice(favIndex, 1); + favoriteQueries.value.set(connectionId, favorites); + saveFavoritesToStorage(connectionId); + } + } + + function clearHistory(connectionId: string) { + queryHistory.value.set(connectionId, []); + localStorage.removeItem(`${HISTORY_STORAGE_KEY}_${connectionId}`); + } + + function searchHistory(connectionId: string, searchTerm: string): QueryHistoryItem[] { + const history = getHistory(connectionId); + if (!searchTerm) return history; + const term = searchTerm.toLowerCase(); + return history.filter( + (item) => item.query.toLowerCase().includes(term) || item.database.toLowerCase().includes(term), + ); + } + + return { + connectionStates, + queryHistory, + favoriteQueries, + getConnectionState, + setConnectionState, + loadHistoryFromStorage, + addToHistory, + getHistory, + getFavorites, + toggleFavorite, + removeFromHistory, + clearHistory, + searchHistory, + }; +}); diff --git a/src/views/DatabaseManagerView.vue b/src/views/DatabaseManagerView.vue index ed0a194..4f4536e 100644 --- a/src/views/DatabaseManagerView.vue +++ b/src/views/DatabaseManagerView.vue @@ -1,52 +1,16 @@