feat(vector): add qdrant and milvus support
This commit is contained in:
1 parent
fffdcb9db1
commit
e27d34a34a
45 files changed
+1116
-29
No files matched your search
@@ -407,6 +407,8 @@ const driverProfiles: Record<
|
||||
label: "Elasticsearch",
|
||||
icon: "elasticsearch",
|
||||
},
|
||||
qdrant: { type: "qdrant", port: 6333, user: "", label: "Qdrant", icon: "qdrant" },
|
||||
milvus: { type: "milvus", port: 19530, user: "root", label: "Milvus", icon: "milvus" },
|
||||
mariadb: { type: "mysql", port: 3306, user: "root", label: "MariaDB", icon: "mariadb" },
|
||||
tidb: { type: "mysql", port: 4000, user: "root", label: "TiDB", icon: "tidb" },
|
||||
oceanbase: { type: "mysql", port: 2881, user: "root", label: "OceanBase", icon: "oceanbase" },
|
||||
@@ -870,6 +872,8 @@ const iconTypeMap: Record<string, string> = {
|
||||
sqlserver: "sqlserver",
|
||||
oracle: "oracle",
|
||||
elasticsearch: "elasticsearch",
|
||||
qdrant: "qdrant",
|
||||
milvus: "milvus",
|
||||
mariadb: "mariadb",
|
||||
tidb: "tidb",
|
||||
oceanbase: "oceanbase",
|
||||
@@ -933,6 +937,8 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
{ value: "sqlserver", label: "SQL Server" },
|
||||
{ value: "elasticsearch", label: "Elasticsearch" },
|
||||
{ value: "qdrant", label: "Qdrant" },
|
||||
{ value: "milvus", label: "Milvus" },
|
||||
{ value: "dm", label: "DM (Dameng)" },
|
||||
{ value: "opengauss", label: "openGauss" },
|
||||
{ value: "turso", label: "Turso" },
|
||||
@@ -1034,7 +1040,7 @@ const sqliteExtensionPaths = computed({
|
||||
form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value);
|
||||
},
|
||||
});
|
||||
const tlsCapableDatabaseTypes = new Set<DatabaseType>(["mysql", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "redis", "etcd", "clickhouse", "elasticsearch", "influxdb"]);
|
||||
const tlsCapableDatabaseTypes = new Set<DatabaseType>(["mysql", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "redis", "etcd", "clickhouse", "elasticsearch", "qdrant", "milvus", "influxdb"]);
|
||||
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
|
||||
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse");
|
||||
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch");
|
||||
|
||||
@@ -114,7 +114,7 @@ const showModified = ref(true);
|
||||
|
||||
let syncPlanRequestId = 0;
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(connection.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd"].includes(connection.db_type)));
|
||||
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
|
||||
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
|
||||
const filteredSourceTables = computed(() => {
|
||||
|
||||
@@ -65,7 +65,7 @@ const exportCancelled = ref(false);
|
||||
const pendingPrefillTable = ref("");
|
||||
const pendingPrefillTables = ref<string[]>([]);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd"].includes(c.db_type)));
|
||||
|
||||
const canExport = computed(() => connectionId.value && database.value && schema.value && !loadingTables.value && !tableError.value && (tables.value.length === 0 || selectedTables.value.length > 0) && (includeStructure.value || includeData.value || includeObjects.value) && !isExporting.value);
|
||||
|
||||
|
||||
@@ -76,6 +76,8 @@ const assetIcons: Record<string, string> = {
|
||||
xugu: "xugu.png",
|
||||
iotdb: "iotdb",
|
||||
etcd: "etcd",
|
||||
qdrant: "qdrant",
|
||||
milvus: "milvus.png",
|
||||
mq: "pulsar",
|
||||
pulsar: "pulsar",
|
||||
iris: "iris.png",
|
||||
|
||||
@@ -216,7 +216,7 @@ function tabColorStyle(tab: QueryTab) {
|
||||
}
|
||||
|
||||
function tabIconClass(tab: QueryTab) {
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "vector" || tab.mode === "redis" || tab.mode === "objects" || tab.mode === "structure") return "text-emerald-600 dark:text-emerald-400";
|
||||
return "text-blue-600 dark:text-blue-400";
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@ const openTabMenuItems = computed(() =>
|
||||
|
||||
function tabMenuIcon(tab: QueryTab) {
|
||||
if (tab.mode === "data" || tab.mode === "mongo" || tab.mode === "redis") return Table2;
|
||||
if (tab.mode === "vector") return TableProperties;
|
||||
if (tab.mode === "etcd") return KeyRound;
|
||||
if (tab.mode === "objects") return TableProperties;
|
||||
if (tab.mode === "structure") return PencilRuler;
|
||||
@@ -326,6 +327,7 @@ function activateTab(tabId: string) {
|
||||
>
|
||||
<span class="shrink-0" :class="tabIconClass(tab)">
|
||||
<Table2 v-if="tab.mode === 'data' || tab.mode === 'mongo' || tab.mode === 'redis'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'vector'" class="h-3.5 w-3.5" />
|
||||
<KeyRound v-else-if="tab.mode === 'etcd'" class="h-3.5 w-3.5" />
|
||||
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
|
||||
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
|
||||
|
||||
@@ -36,6 +36,7 @@ const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||||
const DocumentBrowser = defineAsyncComponent(() => import("@/components/document/DocumentBrowser.vue"));
|
||||
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
|
||||
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
|
||||
@@ -993,6 +994,13 @@ defineExpose({ focusSearch, refreshData, handleModRTarget });
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Vector mode: Qdrant and Milvus collections -->
|
||||
<template v-else-if="activeTab.mode === 'vector'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<VectorBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :database-type="activeEffectiveDatabaseType" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mq'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :read-only="activeConnection?.read_only ?? false" />
|
||||
|
||||
@@ -61,7 +61,7 @@ const activeConnectionValue = computed(() => props.activeConnection?.id || "");
|
||||
const activeSchemaValue = computed(() => props.activeTab.schema || "");
|
||||
const supportsExplain = computed(() => {
|
||||
const dbType = props.activeConnection?.db_type;
|
||||
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "etcd";
|
||||
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd";
|
||||
});
|
||||
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
|
||||
const hasDefaultDatabaseOption = computed(() => activeDatabaseOptions.value.includes(""));
|
||||
@@ -250,7 +250,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
|
||||
</template>
|
||||
</SearchableSelect>
|
||||
</div>
|
||||
<div v-if="activeConnection?.db_type !== 'elasticsearch' && !isSingleDb" class="flex items-center gap-1">
|
||||
<div v-if="activeConnection?.db_type !== 'elasticsearch' && activeConnection?.db_type !== 'qdrant' && activeConnection?.db_type !== 'milvus' && !isSingleDb" class="flex items-center gap-1">
|
||||
<Database class="h-3.5 w-3.5 shrink-0" />
|
||||
<SearchableSelect
|
||||
:model-value="activeDatabaseValue"
|
||||
|
||||
@@ -83,7 +83,7 @@ const SEARCH_SCOPE_TO_NODE_TYPES: Record<SearchScope, TreeNodeType[]> = {
|
||||
connection: ["connection"],
|
||||
database: ["database", "redis-db", "mq-tenant", "mongo-db"],
|
||||
schema: ["schema"],
|
||||
table: ["table", "mongo-collection", "elasticsearch-index"],
|
||||
table: ["table", "mongo-collection", "vector-collection", "elasticsearch-index"],
|
||||
view: ["view"],
|
||||
};
|
||||
|
||||
@@ -336,6 +336,8 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
|
||||
await store.loadMongoDatabases(connId);
|
||||
} else if (config.db_type === "elasticsearch") {
|
||||
await store.loadElasticsearchIndices(connId);
|
||||
} else if (config.db_type === "qdrant" || config.db_type === "milvus") {
|
||||
await store.loadVectorCollections(connId);
|
||||
} else if (config.db_type === "mq") {
|
||||
await store.loadMqTenants(connId, loadOptions);
|
||||
} else {
|
||||
|
||||
@@ -247,6 +247,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
return { icon: Database, colorClass: "text-yellow-500" };
|
||||
case "mongo-collection":
|
||||
return { icon: Table, colorClass: "text-green-400" };
|
||||
case "vector-collection":
|
||||
return { icon: TableProperties, colorClass: "text-cyan-400" };
|
||||
case "elasticsearch-index":
|
||||
return { icon: Table, colorClass: "text-emerald-400" };
|
||||
case "procedure":
|
||||
@@ -283,7 +285,7 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null {
|
||||
}
|
||||
|
||||
const groupTypes: Set<TreeNodeType> = new Set(["group-columns", "group-indexes", "group-fkeys", "group-triggers", "group-tables", "group-views", "group-materialized-views", "group-procedures", "group-functions", "group-sequences", "group-packages", "group-partitions"]);
|
||||
const pinnableTypes: Set<TreeNodeType> = new Set(["connection-group", "database", "linked-server", "linked-server-catalog", "linked-server-schema", "schema", "table", "view", "materialized_view", "redis-db", "mongo-db", "mongo-collection", "elasticsearch-index"]);
|
||||
const pinnableTypes: Set<TreeNodeType> = new Set(["connection-group", "database", "linked-server", "linked-server-catalog", "linked-server-schema", "schema", "table", "view", "materialized_view", "redis-db", "mongo-db", "mongo-collection", "vector-collection", "elasticsearch-index"]);
|
||||
|
||||
function isGroupLabel(node: TreeNode): boolean {
|
||||
return groupTypes.has(node.type);
|
||||
@@ -299,7 +301,7 @@ function displayLabel(node: TreeNode): string {
|
||||
}
|
||||
|
||||
function visibleLabel(node: TreeNode): string {
|
||||
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "elasticsearch-index") {
|
||||
if (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "vector-collection" || node.type === "elasticsearch-index") {
|
||||
return sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes);
|
||||
}
|
||||
return displayLabel(node);
|
||||
@@ -335,6 +337,8 @@ function connectionTooltipScheme(config: Pick<ConnectionConfig, "db_type" | "ssl
|
||||
case "sqlserver":
|
||||
return "mssql";
|
||||
case "elasticsearch":
|
||||
case "qdrant":
|
||||
case "milvus":
|
||||
case "rqlite":
|
||||
case "turso":
|
||||
case "mq":
|
||||
@@ -455,6 +459,8 @@ async function toggle() {
|
||||
await connectionStore.loadMongoDatabases(node.connectionId);
|
||||
} else if (config?.db_type === "elasticsearch") {
|
||||
await connectionStore.loadElasticsearchIndices(node.connectionId);
|
||||
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus") {
|
||||
await connectionStore.loadVectorCollections(node.connectionId);
|
||||
} else if (config?.db_type === "mq") {
|
||||
await connectionStore.loadMqTenants(node.connectionId);
|
||||
} else {
|
||||
@@ -479,6 +485,9 @@ async function toggle() {
|
||||
} else if (node.type === "elasticsearch-index" && node.connectionId) {
|
||||
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "mongo");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
} else if (node.type === "vector-collection" && node.connectionId) {
|
||||
const tab = queryStore.createTab(node.connectionId, node.database || "default", node.label, "vector");
|
||||
queryStore.updateSql(tab, node.label);
|
||||
} else if (node.type === "database" && node.connectionId && hasTreeNodeDatabaseContext(node)) {
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
|
||||
@@ -2722,7 +2731,9 @@ const hasTypeMenu = computed(() => {
|
||||
return t === "connection" || t === "database" || t === "schema" || t === "table" || t === "view" || t === "column" || t === "procedure" || t === "function" || t === "package" || t === "package-body" || isGroupLabel(props.node);
|
||||
});
|
||||
const columnComment = computed(() => (!settingsStore.editorSettings.sidebarHideTableComments && props.node.type === "column" && props.node.meta && "comment" in props.node.meta ? (props.node.meta as any).comment : null));
|
||||
const tableComment = computed(() => (!settingsStore.editorSettings.sidebarHideTableComments && (props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection" || props.node.type === "elasticsearch-index") && props.node.comment ? props.node.comment : null));
|
||||
const tableComment = computed(() =>
|
||||
!settingsStore.editorSettings.sidebarHideTableComments && (props.node.type === "table" || props.node.type === "view" || props.node.type === "mongo-collection" || props.node.type === "vector-collection" || props.node.type === "elasticsearch-index") && props.node.comment ? props.node.comment : null,
|
||||
);
|
||||
const paddingLeft = computed(() => treeItemPaddingLeft(props.depth));
|
||||
const isConnected = computed(() => props.node.type === "connection" && !!props.node.connectionId && connectionStore.connectedIds.has(props.node.connectionId));
|
||||
const isConnectionReadonly = computed(() => props.node.type === "connection" && !!props.node.connectionId && (connectionStore.getConfig(props.node.connectionId)?.read_only ?? false));
|
||||
@@ -2735,7 +2746,7 @@ const nodeIconClass = computed(() => {
|
||||
const canConfigureVisibleDatabases = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return false;
|
||||
const dbType = connectionStore.getConfig(props.node.connectionId)?.db_type;
|
||||
return dbType !== "elasticsearch" && dbType !== "etcd" && dbType !== "mq";
|
||||
return dbType !== "elasticsearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "etcd" && dbType !== "mq";
|
||||
});
|
||||
const canCopyFinalProxyPort = computed(() => {
|
||||
if (props.node.type !== "connection" || !props.node.connectionId) return false;
|
||||
@@ -3325,7 +3336,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (node.type === "elasticsearch-index") {
|
||||
if (node.type === "elasticsearch-index" || node.type === "vector-collection") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.viewData"), action: toggle, icon: TableProperties });
|
||||
|
||||
@@ -49,7 +49,7 @@ const terminalStatus = ref<SqlFileStatus | "idle">("idle");
|
||||
const terminalError = ref("");
|
||||
const refreshedTarget = ref(false);
|
||||
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "etcd"].includes(c.db_type)));
|
||||
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "qdrant", "milvus", "etcd"].includes(c.db_type)));
|
||||
|
||||
const selectedConnection = computed(() => sqlConnections.value.find((c) => c.id === connectionId.value));
|
||||
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, defineAsyncComponent } from "vue";
|
||||
import { Play, RefreshCcw, RotateCcw, Save, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import * as api from "@/lib/api";
|
||||
import { uuid } from "@/lib/utils";
|
||||
import type { DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
|
||||
const { t } = useI18n();
|
||||
|
||||
type VectorOperationMode = "browse" | "upsert" | "delete";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
database: string;
|
||||
collection: string;
|
||||
databaseType?: DatabaseType;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const executionId = ref("");
|
||||
const elapsedSeconds = ref("0.0");
|
||||
const error = ref("");
|
||||
const statusMessage = ref("");
|
||||
const result = ref<QueryResult>(emptyResult());
|
||||
const operationMode = ref<VectorOperationMode>("browse");
|
||||
const requestText = ref(defaultRequestText(props.databaseType, props.database, props.collection, operationMode.value));
|
||||
let loadingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const productLabel = computed(() => (props.databaseType === "milvus" ? "Milvus" : "Qdrant"));
|
||||
const collectionLabel = computed(() => props.collection || t("vector.collectionFallback"));
|
||||
const executeLabel = computed(() => (operationMode.value === "browse" ? t("vector.run") : t("vector.apply")));
|
||||
const operationIcon = computed(() => (operationMode.value === "delete" ? Trash2 : operationMode.value === "upsert" ? Save : Play));
|
||||
|
||||
watch(
|
||||
() => [props.databaseType, props.database, props.collection] as const,
|
||||
([databaseType, database, collection]) => {
|
||||
requestText.value = defaultRequestText(databaseType, database, collection, operationMode.value);
|
||||
result.value = emptyResult();
|
||||
error.value = "";
|
||||
statusMessage.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
function emptyResult(): QueryResult {
|
||||
return {
|
||||
columns: [],
|
||||
column_types: [],
|
||||
column_sortables: [],
|
||||
rows: [],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function pathSegment(value: string): string {
|
||||
return encodeURIComponent(value || "collection");
|
||||
}
|
||||
|
||||
function defaultRequestText(databaseType: DatabaseType | undefined, database: string, collection: string, mode: VectorOperationMode): string {
|
||||
if (databaseType === "milvus") {
|
||||
const body =
|
||||
mode === "delete"
|
||||
? {
|
||||
dbName: database || "default",
|
||||
collectionName: collection,
|
||||
filter: "id in [1]",
|
||||
}
|
||||
: mode === "upsert"
|
||||
? {
|
||||
dbName: database || "default",
|
||||
collectionName: collection,
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
vector: [0.1, 0.2, 0.3, 0.4],
|
||||
title: "updated vector",
|
||||
kind: "demo",
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
dbName: database || "default",
|
||||
collectionName: collection,
|
||||
filter: "",
|
||||
limit: 100,
|
||||
outputFields: ["*"],
|
||||
};
|
||||
const endpoint = mode === "delete" ? "delete" : mode === "upsert" ? "upsert" : "query";
|
||||
return `POST /v2/vectordb/entities/${endpoint}\n${JSON.stringify(body, null, 2)}`;
|
||||
}
|
||||
const collectionPath = pathSegment(collection);
|
||||
if (mode === "delete") {
|
||||
return `POST /collections/${collectionPath}/points/delete?wait=true\n${JSON.stringify({ points: [1] }, null, 2)}`;
|
||||
}
|
||||
if (mode === "upsert") {
|
||||
return `PUT /collections/${collectionPath}/points?wait=true\n${JSON.stringify(
|
||||
{
|
||||
points: [
|
||||
{
|
||||
id: 1,
|
||||
vector: [0.1, 0.2, 0.3, 0.4],
|
||||
payload: {
|
||||
title: "updated vector",
|
||||
kind: "demo",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}`;
|
||||
}
|
||||
return `POST /collections/${collectionPath}/points/scroll\n${JSON.stringify({ limit: 100, with_payload: true, with_vector: false }, null, 2)}`;
|
||||
}
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
const startedAt = Date.now();
|
||||
elapsedSeconds.value = "0.0";
|
||||
loadingTimer = setInterval(() => {
|
||||
elapsedSeconds.value = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (loadingTimer) clearInterval(loadingTimer);
|
||||
loadingTimer = undefined;
|
||||
}
|
||||
|
||||
function firstResult(results: QueryResult[]): QueryResult {
|
||||
return results.find((item) => item.columns.length > 0) ?? results[0] ?? emptyResult();
|
||||
}
|
||||
|
||||
async function executeRequestText(text: string): Promise<QueryResult> {
|
||||
const results = await api.executeMulti(props.connectionId, props.database || "default", text, undefined, executionId.value);
|
||||
return firstResult(results);
|
||||
}
|
||||
|
||||
async function refreshResult() {
|
||||
if (loading.value) return;
|
||||
const id = uuid();
|
||||
executionId.value = id;
|
||||
loading.value = true;
|
||||
cancelling.value = false;
|
||||
error.value = "";
|
||||
statusMessage.value = "";
|
||||
startTimer();
|
||||
try {
|
||||
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse");
|
||||
const nextResult = await executeRequestText(browseText);
|
||||
if (executionId.value === id) result.value = nextResult;
|
||||
} catch (e: unknown) {
|
||||
if (executionId.value === id) error.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
if (executionId.value === id) {
|
||||
loading.value = false;
|
||||
stopTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runRequest() {
|
||||
if (loading.value) return;
|
||||
const id = uuid();
|
||||
executionId.value = id;
|
||||
loading.value = true;
|
||||
cancelling.value = false;
|
||||
error.value = "";
|
||||
statusMessage.value = "";
|
||||
startTimer();
|
||||
try {
|
||||
const nextResult = await executeRequestText(requestText.value);
|
||||
if (executionId.value !== id) return;
|
||||
if (operationMode.value === "browse") {
|
||||
result.value = nextResult;
|
||||
} else {
|
||||
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse");
|
||||
result.value = await executeRequestText(browseText);
|
||||
statusMessage.value = t("vector.operationSuccess");
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (executionId.value === id) error.value = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
if (executionId.value === id) {
|
||||
loading.value = false;
|
||||
stopTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelRequest() {
|
||||
if (!executionId.value) return;
|
||||
cancelling.value = true;
|
||||
await api.cancelQuery(executionId.value).catch(() => false);
|
||||
}
|
||||
|
||||
function resetRequest() {
|
||||
requestText.value = defaultRequestText(props.databaseType, props.database, props.collection, operationMode.value);
|
||||
}
|
||||
|
||||
function setOperationMode(mode: VectorOperationMode) {
|
||||
if (operationMode.value === mode) return;
|
||||
operationMode.value = mode;
|
||||
resetRequest();
|
||||
error.value = "";
|
||||
statusMessage.value = "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex shrink-0 items-center justify-between gap-3 border-b px-3 py-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate text-sm font-semibold">{{ collectionLabel }}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">{{ t("vector.productCollection", { product: productLabel }) }}</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<div class="mr-1 flex h-7 overflow-hidden rounded-md border bg-muted/30 p-0.5">
|
||||
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'browse' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('browse')">{{ t("vector.browse") }}</button>
|
||||
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'upsert' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('upsert')">{{ t("vector.upsert") }}</button>
|
||||
<button type="button" class="h-6 px-2 text-xs transition-colors" :class="operationMode === 'delete' ? 'rounded bg-background font-medium shadow-sm' : 'text-muted-foreground hover:text-foreground'" :disabled="loading" @click="setOperationMode('delete')">{{ t("vector.delete") }}</button>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2" :disabled="loading" @click="resetRequest">
|
||||
<RotateCcw class="h-3.5 w-3.5" />
|
||||
{{ t("vector.reset") }}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="h-7 gap-1.5 px-2" :disabled="loading" @click="refreshResult">
|
||||
<RefreshCcw class="h-3.5 w-3.5" />
|
||||
{{ t("vector.refresh") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 gap-1.5 px-2" :disabled="loading || !requestText.trim()" @click="runRequest">
|
||||
<component :is="operationIcon" class="h-3.5 w-3.5" />
|
||||
{{ executeLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-rows-[minmax(9rem,15rem)_1fr]">
|
||||
<div class="min-h-0 border-b">
|
||||
<textarea v-model="requestText" class="dbx-editor-font-family h-full w-full resize-none bg-background px-3 py-2 text-xs leading-5 outline-none" :aria-label="t('vector.requestEditor')" spellcheck="false" autocomplete="off" autocapitalize="off" autocorrect="off" />
|
||||
</div>
|
||||
<div class="min-h-0">
|
||||
<ErrorBanner v-if="error" :message="error" copy-mode="label" dismissible @dismiss="error = ''" />
|
||||
<div v-else-if="statusMessage" class="border-b bg-emerald-50 px-3 py-1.5 text-xs text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-300">{{ statusMessage }}</div>
|
||||
<QueryLoadingState v-if="loading && result.columns.length === 0" class="h-full" label-key="editor.fetching" :elapsed-seconds="elapsedSeconds" show-cancel :cancel-disabled="!executionId || cancelling" :cancelling="cancelling" @cancel="cancelRequest" />
|
||||
<DataGrid v-else class="h-full" :result="result" context="results" :sql="requestText" :loading="loading" @reload="refreshResult" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user