feat(solr): add Apache Solr connection support

This commit is contained in:
Terry Yao authored and GitHub committed 2026-09-21 18:07:44 +08:00
1 parent 7b407ccbfc
commit 3687a9cbfb
75 files changed
+5596 -82

No files matched your search

+2 -2
View File
@@ -3207,7 +3207,7 @@ async function handleQuickOpenSelect(item: any) {
await connectionStore.loadConsulRoot(item.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(item.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch" || config?.db_type === "solr") {
await connectionStore.openElasticsearchConnectionTree(item.connectionId);
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
await connectionStore.loadVectorCollections(item.connectionId);
@@ -3234,7 +3234,7 @@ async function handleQuickOpenSelect(item: any) {
await connectionStore.loadConsulRoot(item.connectionId);
} else if (config?.db_type === "mongodb") {
await connectionStore.loadMongoDatabases(item.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch" || config?.db_type === "solr") {
await connectionStore.openElasticsearchConnectionTree(item.connectionId);
} else if (config?.db_type === "qdrant" || config?.db_type === "milvus" || config?.db_type === "weaviate" || config?.db_type === "chromadb") {
await connectionStore.loadVectorCollections(item.connectionId);
@@ -3241,6 +3241,7 @@ const tlsCapableDatabaseTypes = new Set<DatabaseType>([
"elasticsearch",
"easysearch",
"meilisearch",
"solr",
"hbase",
"qdrant",
"milvus",
@@ -4569,12 +4570,12 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
throw new Error(t("connection.etcdClientCertPairRequired"));
}
} else if (form.value.db_type !== "consul" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch") {
} else if (form.value.db_type !== "consul" && config.db_type !== "elasticsearch" && config.db_type !== "easysearch" && config.db_type !== "solr") {
config.etcd_endpoints = undefined;
config.client_cert_path = undefined;
config.client_key_path = undefined;
}
if (config.db_type === "elasticsearch" || config.db_type === "easysearch") {
if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "solr") {
config.client_cert_path = config.client_cert_path?.trim() || "";
config.client_key_path = config.client_key_path?.trim() || "";
if ((config.client_cert_path && !config.client_key_path) || (!config.client_cert_path && config.client_key_path)) {
@@ -4591,7 +4592,8 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi
config.db_type !== "victoriametrics" &&
config.db_type !== "zookeeper" &&
config.db_type !== "elasticsearch" &&
config.db_type !== "easysearch"
config.db_type !== "easysearch" &&
config.db_type !== "solr"
) {
config.ca_cert_path = undefined;
} else {
@@ -8115,7 +8117,7 @@ function openExternalUrl(url: string) {
</div>
</div>
<div v-if="form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<div v-if="form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'solr' && form.db_type !== 'spanner'" class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
</div>
@@ -114,7 +114,7 @@ let initializingPrefillGeneration = 0;
let componentUnmounted = false;
let shownSessionError = "";
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
const sqlConnections = computed(() => store.connections.filter((connection) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(connection.db_type)));
const selectedSourceTableNames = computed(() => sourceTables.value.filter((table) => selectedSourceTables.value.has(table)));
const isBatchCompare = computed(() => selectedSourceTableNames.value.length > 1);
// Bridge the shared TableMultiSelect `string[]` v-model with the Set-based selection store.
@@ -84,7 +84,7 @@ const targetSchemas = ref<string[]>([]);
const sourceDbVersion = ref<string | null>(null);
const targetDbVersion = ref<string | null>(null);
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((c: any) => !["mongodb", "redis", "elasticsearch", "easysearch", "meilisearch", "solr", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
const sourceConfig = computed(() => store.getConfig(props.sourceConnectionId));
const targetConfig = computed(() => store.getConfig(props.targetConnectionId));
@@ -71,7 +71,7 @@ import {
stringifyDocumentStoreValue,
documentStoreValueForGrid,
} from "@/lib/app/documentJsonValues";
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, formatSolrDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
import { restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserDataSnapshot } from "@/lib/tabs/documentBrowserStateCache";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
@@ -145,7 +145,7 @@ const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000;
// stores (DynamoDB/Elasticsearch) cannot resume a page without their cursor
// stacks, and infinite scroll always restarts from the first segment.
const restoredDocumentBrowserState = props.stateKey ? restoreDocumentBrowserState(props.stateKey) : undefined;
const skipBasedDocumentStore = documentStoreProviderFor(props.databaseType).kind === "mongodb" || documentStoreProviderFor(props.databaseType).kind === "meilisearch";
const skipBasedDocumentStore = ["mongodb", "meilisearch", "solr"].includes(documentStoreProviderFor(props.databaseType).kind);
const restoresSkipBasedPage = !!restoredDocumentBrowserState && !settingsStore.editorSettings.infiniteScroll && skipBasedDocumentStore;
const documents = ref<JsonRecord[]>([]);
@@ -466,6 +466,12 @@ function elasticsearchGridColumnTypesFor(columns: readonly string[]): string[] {
});
}
const elasticsearchGridColumnTypes = computed(() => elasticsearchGridColumnTypesFor(lastGridColumns.value));
const solrSchemaFields = ref<ColumnInfo[]>([]);
const solrFieldTypes = computed(() => new Map(solrSchemaFields.value.map((field) => [field.name, field.data_type])));
function solrGridColumnTypesFor(columns: readonly string[]): string[] {
return columns.map((column) => (column === "_id" ? "string" : (solrFieldTypes.value.get(column) ?? "")));
}
const solrGridColumnTypes = computed(() => solrGridColumnTypesFor(lastGridColumns.value));
const dynamodbTableDescription = ref<DynamoDbTableDescription | null>(null);
const dynamodbIndexName = ref("__table__");
const dynamodbPageCursors = ref<Array<string | undefined>>([undefined]);
@@ -536,6 +542,9 @@ const deleteDetails = computed(() => {
});
}
const displayId = mongoDocumentIdForGrid(id);
if (props.databaseType === "solr") {
return `Solr core: ${props.collection}\nDocument _id: ${String(displayId)}`;
}
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch") {
const product = props.databaseType === "easysearch" ? "Easysearch" : props.databaseType === "meilisearch" ? "Meilisearch" : "Elasticsearch";
return `${product} index: ${props.collection}\nDocument _id: ${String(displayId)}`;
@@ -608,7 +617,7 @@ function commitLoadedDocuments(nextDocuments: JsonRecord[], nextCopyDocuments: J
const gridResult = computed<QueryResult>(() => {
const docs = documents.value;
const columnTypes = documentStoreProvider.value.kind === "elasticsearch" ? elasticsearchGridColumnTypes.value : lastGridColumnTypes.value;
const columnTypes = documentStoreProvider.value.kind === "elasticsearch" ? elasticsearchGridColumnTypes.value : documentStoreProvider.value.kind === "solr" ? solrGridColumnTypes.value : lastGridColumnTypes.value;
if (!docs.length) {
return {
columns: lastGridColumns.value,
@@ -637,7 +646,7 @@ const gridResult = computed<QueryResult>(() => {
async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void): Promise<QueryResult | undefined> {
const kind = documentStoreProvider.value.kind;
if (kind !== "mongodb" && kind !== "dynamodb" && kind !== "elasticsearch") return undefined;
if (kind !== "mongodb" && kind !== "dynamodb" && kind !== "elasticsearch" && kind !== "solr") return undefined;
const connectionId = props.connectionId;
const database = props.database;
@@ -673,7 +682,7 @@ async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExporte
}
}
if ((kind === "mongodb" || kind === "elasticsearch") && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
if ((kind === "mongodb" || kind === "elasticsearch" || kind === "solr") && result.total_is_exact !== false) totalRows = Math.min(result.total, rowLimit);
onProgress?.({ rowsExported: exportedDocuments.length, totalRows });
if (kind === "dynamodb" || kind === "elasticsearch") {
@@ -705,7 +714,7 @@ async function exportAllDocumentStoreDocuments(onProgress?: (info: { rowsExporte
const result = mongoDocumentsToQueryResult(exportedDocuments, performance.now() - exportStartedAt, totalRows ?? exportedDocuments.length, exportedCopyDocuments, totalRows !== null);
if (result.columns.length === 0) result.columns = gridResult.value.columns;
result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : kind === "elasticsearch" ? elasticsearchGridColumnTypesFor(result.columns) : undefined;
result.column_types = kind === "mongodb" ? mongoDocumentGridColumnTypes(exportedDocuments, result.columns) : kind === "elasticsearch" ? elasticsearchGridColumnTypesFor(result.columns) : kind === "solr" ? solrGridColumnTypesFor(result.columns) : undefined;
result.affected_rows = exportedDocuments.length;
result.truncated = (kind === "dynamodb" || kind === "elasticsearch") && !!cursor && exportedDocuments.length >= rowLimit;
result.has_more = result.truncated;
@@ -717,10 +726,19 @@ const elasticsearchFilterFieldNames = computed(() => {
const names = [...elasticsearchMappingFields.value.map((field) => field.name), ...gridResult.value.columns, "_id", "_routing"];
return [...new Set(names.filter(Boolean))];
});
const solrFilterFieldNames = computed(() => {
const names = [...solrSchemaFields.value.map((field) => field.name), ...gridResult.value.columns, "_id"];
return [...new Set(names.filter(Boolean))];
});
const documentFilterFieldTree = computed<DocumentFieldPathNode[]>(() => {
if (documentStoreProvider.value.kind === "elasticsearch") {
return elasticsearchFieldPathTreeFromFieldNames(elasticsearchFilterFieldNames.value, elasticsearchFieldTypes.value);
}
if (documentStoreProvider.value.kind === "solr") {
// Schema fields give the filter builder a complete field list even before
// any document page has loaded; unknown Solr types stay leaf-selectable.
return elasticsearchFieldPathTreeFromFieldNames(solrFilterFieldNames.value, solrFieldTypes.value);
}
const tree = documentFieldPathTreeFromDocuments(documents.value);
if (tree.length > 0) return tree;
return gridResult.value.columns.map((column) => ({
@@ -1395,12 +1413,16 @@ async function applyDocumentStructuredFilters() {
applyFilter();
}
async function loadElasticsearchMappingFields() {
if (documentStoreProvider.value.kind !== "elasticsearch") return;
async function loadDocumentStoreSchemaFields() {
const kind = documentStoreProvider.value.kind;
if (kind !== "elasticsearch" && kind !== "solr") return;
try {
elasticsearchMappingFields.value = (await api.getColumns(props.connectionId, props.database, "", props.collection)) ?? [];
const fields = (await api.getColumns(props.connectionId, props.database, "", props.collection)) ?? [];
if (kind === "elasticsearch") elasticsearchMappingFields.value = fields;
else solrSchemaFields.value = fields;
} catch {
elasticsearchMappingFields.value = [];
if (kind === "elasticsearch") elasticsearchMappingFields.value = [];
else solrSchemaFields.value = [];
}
}
@@ -1687,9 +1709,9 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
const sourceDocument = documents.value[rowIdx];
if (!sourceDocument) continue;
const documentId = sourceDocument._id ?? id;
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, "meilisearch");
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind: "meilisearch", mode: "update" });
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "update", index: coll, id: documentId, document: writeDocument }));
const updated = buildPathIdentityUpdatedDocument(sourceDocument, dirtyCols, columns, kind === "solr" ? "solr" : "meilisearch");
const writeDocument = prepareDocumentStoreWriteDocument(updated, { kind: kind === "solr" ? "solr" : "meilisearch", mode: "update" });
stmts.push(kind === "solr" ? formatSolrDocumentOperationPreview({ action: "update", core: coll, id: documentId, document: writeDocument }) : formatMeilisearchDocumentOperationPreview({ action: "update", index: coll, id: documentId, document: writeDocument }));
}
} else {
const updateDoc = buildMongoUpdateDocument(dirtyCols, columns, documents.value[rowIdx]);
@@ -1707,7 +1729,7 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
continue;
}
if (!isEs) {
stmts.push(formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
stmts.push(kind === "solr" ? formatSolrDocumentOperationPreview({ action: "delete", core: coll, id: documents.value[rowIdx]?._id ?? id }) : formatMeilisearchDocumentOperationPreview({ action: "delete", index: coll, id: documents.value[rowIdx]?._id ?? id }));
continue;
}
const routing = documentRoutingFromGridRow(row, columns);
@@ -1730,8 +1752,8 @@ async function previewDocumentChanges(changes: DocumentGridChanges): Promise<str
}
if (!isEs) {
const idValue = idColIdx >= 0 ? newRow[idColIdx] : null;
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, "meilisearch");
stmts.push(formatMeilisearchDocumentOperationPreview({ action: id === undefined ? "insert" : "upsert", index: coll, id, document: doc }));
const id = idValue === null || idValue === undefined || idValue === "" ? undefined : parseDocumentStoreInputValue(idValue, kind === "solr" ? "solr" : "meilisearch");
stmts.push(kind === "solr" ? formatSolrDocumentOperationPreview({ action: id === undefined ? "insert" : "upsert", core: coll, id, document: doc }) : formatMeilisearchDocumentOperationPreview({ action: id === undefined ? "insert" : "upsert", index: coll, id, document: doc }));
continue;
}
const id = idColIdx >= 0 ? documentIdFromGridValue(newRow[idColIdx]) : null;
@@ -1917,7 +1939,7 @@ async function load(options: { page?: number; append?: boolean; offset?: number;
if (documentLoadExecutionId.value !== executionId) return;
if (connectionId !== props.connectionId || database !== props.database || collection !== props.collection || storeKind !== documentStoreProvider.value.kind) return;
const nextDocuments =
storeKind === "elasticsearch" && result.raw_documents?.length === result.documents.length
(storeKind === "elasticsearch" || storeKind === "solr") && result.raw_documents?.length === result.documents.length
? result.raw_documents.map((raw, index) => {
try {
return asRecord(parseJsonPreservingLargeNumbers(raw));
@@ -1997,7 +2019,7 @@ async function countExactDocumentTotal(): Promise<number | undefined> {
paginationTotal.value = totals.paginationTotal;
return exactCount;
}
const exactCount = request.storeKind === "dynamodb" ? await api.documentCountDocuments(request.connectionId, request.collection, request.filter) : await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
const exactCount = request.storeKind === "dynamodb" || request.storeKind === "solr" ? await api.documentCountDocuments(request.connectionId, request.collection, request.filter) : await api.mongoCountDocuments(request.connectionId, request.database, request.collection, request.filter, "accurate");
if (!isCurrentDocumentQueryTotalCountRequest(request)) return undefined;
if (!Number.isFinite(exactCount) || exactCount < 0) {
throw new Error("invalid count");
@@ -2633,9 +2655,9 @@ onMounted(async () => {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
await loadDynamoDbTableDescription();
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
// Schema metadata enriches the filter builder, but it must not delay the
// first page of documents when the schema endpoint is slow.
void loadDocumentStoreSchemaFields();
// A restored snapshot already holds the rows the last load produced, so a tab
// switch must not re-issue the collection query (#8679). Refresh and every
// mutation path still force a real load.
@@ -270,7 +270,7 @@ function sqlBehaviorDialect(): "mysql" | "postgres" | "sqlserver" | undefined {
function queryEditorSelectionLanguage(): "sql" | "text" {
const databaseType = props.databaseType;
return databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "victoriametrics" ? "text" : "sql";
return databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "solr" || databaseType === "victoriametrics" ? "text" : "sql";
}
const COMPLETION_REMOTE_LATENCY_BUDGET_MS = 120;
@@ -3902,7 +3902,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
setSemanticDiagnostics([]);
return;
}
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "victoriametrics") {
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "meilisearch" || props.databaseType === "solr" || props.databaseType === "victoriametrics") {
setSemanticDiagnostics([]);
return;
}
@@ -4955,7 +4955,7 @@ function localCompletionSchemasForDatabaseDisambiguation(completionContext: Retu
}
function shouldInsertSqlCompletionSpace(): boolean {
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "victoriametrics";
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "meilisearch" && props.databaseType !== "solr" && props.databaseType !== "victoriametrics";
}
// Snippet expansion normally follows from the item type; a provider can also
@@ -5196,7 +5196,7 @@ async function provideSqlCompletions(context: CompletionContext) {
if (props.databaseType === "mongodb") {
return provideMongoCompletions(currentState, position, explicit);
}
if (props.databaseType === "meilisearch") return null;
if (props.databaseType === "meilisearch" || props.databaseType === "solr") return null;
if (props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
if (!isSqlLikeCompletionStatement(fullDoc, position, sqlCompletionDialectOptions())) {
return provideElasticsearchCompletions(currentState, position, explicit);
@@ -5571,7 +5571,7 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s
if (props.databaseType === "mongodb") {
return !!(insertedText || removedText) && shouldAutoOpenMongoCompletion(fullDoc, position);
}
if (props.databaseType === "victoriametrics" || props.databaseType === "meilisearch") return false;
if (props.databaseType === "victoriametrics" || props.databaseType === "meilisearch" || props.databaseType === "solr") return false;
if (props.databaseType === "redis" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
// Preserve old character-based checks for non-SQL providers.
if (!insertedText && removedText) {
@@ -115,7 +115,7 @@ const exportElapsedText = computed(() => {
return formatDataTransferDuration((exportFinishedAt.value ?? currentTime.value) - exportStartedAt.value);
});
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
const canExport = computed(() => {
const hasContent = includeStructure.value || includeData.value || includeObjects.value;
@@ -31,6 +31,7 @@ const assetIcons: Record<string, string> = {
elasticsearch: "elasticsearch",
easysearch: "easysearch",
meilisearch: "meilisearch",
solr: "solr",
oracle: "oracle",
"oracle-10g": "oracle",
"oracle-legacy": "oracle",
@@ -135,7 +136,7 @@ const assetSrc = computed(() => {
</script>
<template>
<img v-if="assetName" :src="assetSrc" alt="" class="database-logo object-contain" :class="{ 'database-logo-light': useLightIconInDarkMode, 'database-logo-impala': normalizedType === 'impala' }" aria-hidden="true" />
<img v-if="assetName" :src="assetSrc" alt="" class="database-logo object-contain" :class="{ 'database-logo-light': useLightIconInDarkMode, 'database-logo-impala': normalizedType === 'impala', 'database-logo-solr': normalizedType === 'solr' }" aria-hidden="true" />
<Database v-else class="text-blue-400" />
</template>
@@ -152,4 +153,10 @@ const assetSrc = computed(() => {
.database-logo-impala {
transform: scale(1.55);
}
/* solr.svg 的图形撑满整个 viewBox(无内边距),其他 logo 留白约 20-25%,
统一 scale(1.35) 下视觉偏大,单独收敛到与多数 logo 一致的占幅。 */
.database-logo-solr {
transform: scale(1.02);
}
</style>
@@ -129,6 +129,7 @@ const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/Dam
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
const SolrAdmin = defineAsyncComponent(() => import("@/components/solr/SolrAdmin.vue"));
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
@@ -505,7 +506,7 @@ const activeStatementExecutionMarkers = computed(() =>
const activeElasticsearchJsonResponse = computed(() => elasticsearchJsonResponseForResult(activeEffectiveDatabaseType.value, activeResultSql.value, props.activeTab.result));
/** Whether the active result is an Elasticsearch _source table that also has a raw JSON toggle. */
const activeElasticsearchRawBody = computed(() => {
if (activeEffectiveDatabaseType.value !== "elasticsearch" && activeEffectiveDatabaseType.value !== "easysearch") return undefined;
if (activeEffectiveDatabaseType.value !== "elasticsearch" && activeEffectiveDatabaseType.value !== "easysearch" && activeEffectiveDatabaseType.value !== "solr") return undefined;
return props.activeTab.result?.elasticsearch_raw_body;
});
/** ES `_search?profile=true` body extracted from the active result, when present. */
@@ -2863,6 +2864,12 @@ defineExpose({
</div>
</template>
<template v-else-if="activeTab.mode === 'solr-admin'">
<div class="min-h-0 flex-1">
<SolrAdmin :key="activeTab.id" :connection-id="activeTab.connectionId" />
</div>
</template>
<template v-else-if="activeTab.mode === 'dolt-version-control'">
<div class="min-h-0 flex-1">
<DoltVersionControl :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :initial-branch="activeTab.workspaceBranch" />
@@ -209,6 +209,7 @@ const supportsExplain = computed(() => {
dbType !== "elasticsearch" &&
dbType !== "easysearch" &&
dbType !== "meilisearch" &&
dbType !== "solr" &&
dbType !== "qdrant" &&
dbType !== "milvus" &&
dbType !== "weaviate" &&
@@ -758,6 +759,7 @@ async function changeCatalog(selectedCatalog: string) {
activeConnection?.db_type !== 'elasticsearch' &&
activeConnection?.db_type !== 'easysearch' &&
activeConnection?.db_type !== 'meilisearch' &&
activeConnection?.db_type !== 'solr' &&
activeConnection?.db_type !== 'qdrant' &&
activeConnection?.db_type !== 'milvus' &&
activeConnection?.db_type !== 'weaviate' &&
@@ -1747,7 +1747,7 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?:
await store.loadMongoDatabases(connId);
} else if (config.db_type === "dynamodb") {
await store.loadDynamoDbTables(connId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch" || config.db_type === "solr") {
await store.loadElasticsearchIndices(connId);
} else if (config.db_type === "qdrant" || config.db_type === "milvus" || config.db_type === "weaviate" || config.db_type === "chromadb") {
await store.loadVectorCollections(connId);
@@ -967,8 +967,8 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await connectionStore.loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
// Expand: list indices (like other db types list databases).
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch" || config?.db_type === "solr") {
// Expand: list indices/cores (like other db types list databases).
await connectionStore.loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
await connectionStore.loadMilvusDatabases(node.connectionId);
@@ -1826,6 +1826,8 @@ async function openServerDashboard() {
connectionStore.activeConnectionId = node.connectionId;
if (currentDatabaseType() === "nacos") {
queryStore.openNacosDashboard(node.connectionId);
} else if (currentDatabaseType() === "solr") {
queryStore.openSolrAdmin(node.connectionId);
} else if (connectionSupportsXuguServerDashboard(connectionStore.getConfig(node.connectionId))) {
queryStore.openXuguDashboard(node.connectionId);
} else if (connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId))) {
@@ -4686,7 +4688,7 @@ const canOpenSqlFileExecution = computed(() => {
const canExportAllDatabases = computed(() => {
if (activeNode.value.type !== "connection" || !activeNode.value.connectionId) return false;
const dbType = connectionStore.getConfig(activeNode.value.connectionId)?.db_type;
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "plugin"].includes(dbType || "");
return !["redis", "mongodb", "dynamodb", "elasticsearch", "easysearch", "solr", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "plugin"].includes(dbType || "");
});
const canOpenScheduledBackups = computed(() => {
@@ -5597,7 +5599,11 @@ function buildConnectionSidebarMenu(context: SidebarMenuFactoryContext): boolean
}
if (
node.connectionId &&
(currentDatabaseType() === "nacos" || connectionSupportsXuguServerDashboard(connectionStore.getConfig(node.connectionId)) || connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId)) || connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId)))
(currentDatabaseType() === "nacos" ||
currentDatabaseType() === "solr" ||
connectionSupportsXuguServerDashboard(connectionStore.getConfig(node.connectionId)) ||
connectionSupportsServerDashboard(connectionStore.getConfig(node.connectionId)) ||
connectionSupportsPgServerDashboard(connectionStore.getConfig(node.connectionId)))
) {
items.push({ label: t("contextMenu.serverDashboard"), action: openServerDashboard, icon: Gauge });
}
File diff suppressed because it is too large. Load diff
@@ -167,7 +167,7 @@ function resetPerFileState() {
currentFileName.value = "";
}
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
const sqlConnections = computed(() => store.connections.filter((c) => !["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos"].includes(c.db_type)));
// Mirrors the core executor gate (`relational_constraint_bypass_kind` in
// sql_file_import.rs): MySQL-family types use the session-scoped
// FOREIGN_KEY_CHECKS toggle, PostgreSQL-family types use DISABLE/ENABLE
+1 -1
View File
@@ -25,7 +25,7 @@ const INITIAL_SQL_FILE_LIMIT = 20;
const CONTENT_SEARCH_DEBOUNCE_MS = 200;
const CONTENT_SEARCH_MAX_RESULTS = 500;
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos", "consul"]);
export interface QuickOpenItem {
id: string;
@@ -18,6 +18,7 @@ import { defaultViewForResult } from "@/lib/query/queryResultDefaultView";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { classifyRedisCommandSafety } from "@/lib/redis/redisCommandSafety";
import { isRedisCommentLine } from "@/lib/redis/redisCommandTokenizer";
import { isDangerousSolrRequest } from "@/lib/solr/solrRequestRisk";
import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride, type SqlExecutionSnapshot } from "@/lib/sql/sqlExecutionTarget";
import { isElasticsearchRestRequestText, parseElasticsearchRestRequestTarget, splitSqlStatementRanges, sqlStatementParameterOptionsForCompatibility } from "@/lib/sql/sqlStatementRanges";
import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters";
@@ -96,12 +97,12 @@ function isDangerousMeilisearchRequest(method: "GET" | "POST" | "PUT" | "PATCH"
}
export function isDangerousSql(sql: string, databaseType?: DatabaseType): boolean {
if (databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch") {
if (databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "solr") {
const requests = splitSqlStatementRanges(sql, databaseType)
.map((statement) => parseElasticsearchRestRequestTarget(statement.sql))
.filter((request): request is NonNullable<typeof request> => request !== null);
if (requests.length > 0) {
return requests.some((request) => (databaseType === "meilisearch" ? isDangerousMeilisearchRequest(request.method, request.path) : isDangerousElasticsearchRequest(request.method, request.path)));
return requests.some((request) => (databaseType === "meilisearch" ? isDangerousMeilisearchRequest(request.method, request.path) : databaseType === "solr" ? isDangerousSolrRequest(request.method, request.path) : isDangerousElasticsearchRequest(request.method, request.path)));
}
}
const cleaned = stripSqlComments(sql);
@@ -921,7 +922,7 @@ export function useSqlExecution(deps: {
export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig, "db_type"> | undefined, sql = ""): boolean {
if (!connection) return false;
if (connection.db_type === "meilisearch") return false;
if (connection.db_type === "meilisearch" || connection.db_type === "solr") return false;
if (connection.db_type === "elasticsearch" || connection.db_type === "easysearch") return !isElasticsearchRestRequestText(sql);
return connection.db_type !== "redis" && connection.db_type !== "mongodb" && connection.db_type !== "victoriametrics";
}
+148
View File
@@ -4209,6 +4209,154 @@ export default {
unknown: "Unknown",
},
},
solrAdmin: {
title: "Solr Admin",
refresh: "Refresh",
selectCore: "Select core",
noCore: "No core selected. Pick a core to view core-level admin pages.",
emptyBody: "Empty response body.",
optional: "optional",
sections: {
server: "Server",
core: "Core",
},
views: {
dashboard: "Dashboard",
logging: "Logging",
javaProps: "Java Properties",
threads: "Thread Dump",
coreAdmin: "Core Admin",
metrics: "Metrics",
overview: "Overview",
analysis: "Analysis",
documents: "Documents",
paramsets: "Paramsets",
files: "Files",
ping: "Ping",
plugins: "Plugins / Stats",
query: "Query",
replication: "Replication",
schema: "Schema",
segments: "Segments info",
},
dashboard: {
instance: "Instance",
start: "Start",
uptime: "Uptime",
versions: "Versions",
jvmTitle: "JVM",
runtime: "Runtime",
args: "Args",
system: "System",
physMem: "Physical Memory",
swap: "Swap Space",
fd: "File Descriptor Count",
jvmMemory: "JVM-Memory",
outdatedWarning: "This Solr release is more than a year old. Consider upgrading.",
mode: "Mode",
solr_home: "Solr Home",
core_root: "Core Root",
processors: "Processors",
},
logging: {
name: "Logger",
level: "Level",
set: "Set",
},
core: {
name: "Core",
docs: "Docs",
size: "Size",
segments: "Segs",
uptime: "Uptime",
ops: "Actions",
},
schema: {
fields: "fields",
dynamicFields: "dynamic fields",
fieldTypes: "field types",
name: "Name",
type: "Type",
flags: "Flags",
},
analysis: {
field: "Field name",
indexValue: "Index value",
queryValue: "Query value (optional)",
indexPlaceholder: "Text indexed into the field",
queryPlaceholder: "Query text analyzed at query time",
run: "Analyse",
index: "Index chain",
query: "Query chain",
empty: "No analysis stages returned. Check the field name.",
},
segments: {
name: "Segment",
deleted: "Deleted",
age: "Age",
source: "Source",
},
actions: {
create: "Create Core",
reload: "Reload",
unload: "Unload",
rename: "Rename",
swap: "Swap",
confirm: 'Run {action} on core "{core}"?',
success: '{action} on "{core}" succeeded',
failed: "Core admin action failed: {message}",
newName: "New core name",
swapWith: "Swap with core",
},
queryForm: {
defaultParser: "default parser",
paramsets: "paramset(s)",
jsonHint: "non-empty switches to POST JSON DSL",
execute: "Execute Query",
openInConsole: "Open in console",
rowsReturned: "rows",
tableView: "Table",
rawJson: "Raw JSON",
},
overview: {
statistics: "Statistics",
instance: "Instance",
replication: "Replication",
healthcheck: "Healthcheck",
lastModified: "Last Modified",
numDocs: "Num Docs",
maxDoc: "Max Doc",
deletedDocs: "Deleted Docs",
version: "Version",
segmentCount: "Segment Count",
current: "Current",
cwd: "CWD",
instanceDir: "Instance",
data: "Data",
index: "Index",
impl: "Impl",
colVersion: "Version",
colGen: "Gen",
colSize: "Size",
leaderSearching: "Leader (Searching)",
leaderReplicable: "Leader (Replicable)",
noReplication: "No leader replication details for this core.",
noHealthcheck: "Ping request handler is not configured with a healthcheck file.",
justNow: "just now",
minutesAgo: "about {n} minutes ago",
hoursAgo: "about {n} hours ago",
daysAgo: "about {n} days ago",
},
replication: {
refresh: "Refresh Status",
disable: "Disable Replication",
enable: "Enable Replication",
index: "Index",
settingsLeader: "Settings (Leader)",
settingsFollower: "Settings (Follower)",
noDetails: "No replication details for this core.",
},
},
userAdmin: {
title: "Users & Privileges",
unsupported: "MySQL-compatible and PostgreSQL-compatible connections are supported. SQL Server, Oracle, and other permission models can be added next.",
+148
View File
@@ -10217,4 +10217,152 @@ export default withEnglishFallback({
titleLabel: "Título (opcional)",
titlePlaceholder: "p. ej. Resultado de consulta",
},
solrAdmin: {
title: "Administración de Solr",
refresh: "Actualizar",
selectCore: "Seleccionar core",
noCore: "No se ha seleccionado ningún core. Seleccione un core antes de ver la página de administración a nivel de core.",
emptyBody: "El cuerpo de la respuesta está vacío.",
optional: "Opcional",
sections: {
server: "Servidor",
core: "Core",
},
views: {
dashboard: "Panel",
logging: "Registro",
javaProps: "Propiedades de Java",
threads: "Volcado de hilos",
coreAdmin: "Administración de Core",
metrics: "Métricas",
overview: "Resumen",
analysis: "Análisis de tokenización",
documents: "Documentos",
paramsets: "Conjuntos de parámetros",
files: "Archivos",
ping: "Ping",
plugins: "Complementos / Estadísticas",
query: "Consulta",
replication: "Replicación",
schema: "Schema",
segments: "Información de segmentos",
},
dashboard: {
instance: "Instancia",
start: "Hora de inicio",
uptime: "Tiempo de actividad",
versions: "Versiones",
jvmTitle: "JVM",
runtime: "Tiempo de ejecución",
args: "Argumentos de inicio",
system: "Sistema",
physMem: "Memoria física",
swap: "Espacio de intercambio",
fd: "Número de descriptores de archivos",
jvmMemory: "Memoria de JVM",
outdatedWarning: "Esta versión de Solr se publicó hace más de un año; se recomienda actualizar.",
mode: "Modo de ejecución",
solr_home: "Solr Home",
core_root: "Directorio raíz de Core",
processors: "Número de procesadores",
},
logging: {
name: "Logger",
level: "Nivel",
set: "Establecido",
},
core: {
name: "Core",
docs: "Número de documentos",
size: "Tamaño",
segments: "Número de segmentos",
uptime: "Tiempo de actividad",
ops: "Operaciones",
},
schema: {
fields: "campos",
dynamicFields: "campos dinámicos",
fieldTypes: "tipos de campo",
name: "Nombre",
type: "Tipo",
flags: "Indicadores",
},
analysis: {
field: "Nombre del campo",
indexValue: "Texto del lado del índice",
queryValue: "Texto del lado de consulta (opcional)",
indexPlaceholder: "Texto analizado al escribir en este campo",
queryPlaceholder: "Texto analizado en el momento de la consulta",
run: "Analizar",
index: "Cadena de indexación",
query: "Cadena de consulta",
empty: "No se devolvieron fases de análisis; compruebe que el nombre del campo sea correcto.",
},
segments: {
name: "Segmento",
deleted: "Eliminados",
age: "Antigüedad",
source: "Origen",
},
actions: {
create: "Crear Core",
reload: "Recargar",
unload: "Descargar",
rename: "Renombrar",
swap: "Intercambiar",
confirm: '¿Ejecutar {action} en el core "{core}"?',
success: '{action} en "{core}" se ejecutó correctamente',
failed: "Error en la operación de administración de Core: {message}",
newName: "Nombre del nuevo core",
swapWith: "Intercambiar con core",
},
queryForm: {
defaultParser: "Parser predeterminado",
paramsets: "paramset(s)",
jsonHint: "Si no está vacío, enviar con JSON DSL",
execute: "Ejecutar consulta",
openInConsole: "Abrir en la consola",
rowsReturned: "Filas",
tableView: "Tabla",
rawJson: "JSON sin formato",
},
overview: {
statistics: "Estadísticas",
instance: "Instancia",
replication: "Replicación",
healthcheck: "Comprobación de estado",
lastModified: "Última modificación",
numDocs: "Número de documentos",
maxDoc: "Número máximo de documentos",
deletedDocs: "Documentos eliminados",
version: "Versión",
segmentCount: "Número de segmentos",
current: "Actual",
cwd: "Directorio de trabajo",
instanceDir: "Directorio de instancias",
data: "Directorio de datos",
index: "Directorio de índices",
impl: "Clase de implementación",
colVersion: "Versión",
colGen: "Gen",
colSize: "Tamaño",
leaderSearching: "Leader (buscando)",
leaderReplicable: "Leader (replicable)",
noReplication: "Este core no tiene detalles de replicación de leader.",
noHealthcheck: "El controlador de solicitudes Ping no tiene configurado el archivo healthcheck.",
justNow: "Hace un momento",
minutesAgo: "Hace aproximadamente {n} minutos",
hoursAgo: "Hace aproximadamente {n} horas",
daysAgo: "Hace aproximadamente {n} días",
},
replication: {
refresh: "Actualizar estado",
disable: "Deshabilitar replicación",
enable: "Habilitar replicación",
index: "Índice",
settingsLeader: "Configuración (Leader)",
settingsFollower: "Configuración (Follower)",
noDetails: "Este core no tiene detalles de replicación.",
},
},
});
+148
View File
@@ -10217,4 +10217,152 @@ export default withEnglishFallback({
titleLabel: "Titolo (facoltativo)",
titlePlaceholder: "es. Risultato della query",
},
solrAdmin: {
title: "Amministrazione Solr",
refresh: "Aggiorna",
selectCore: "Seleziona core",
noCore: "Nessun core selezionato. Seleziona prima un core per visualizzare la pagina di amministrazione a livello di core.",
emptyBody: "Il corpo della risposta è vuoto.",
optional: "Opzionale",
sections: {
server: "Server",
core: "Core",
},
views: {
dashboard: "Dashboard",
logging: "Log",
javaProps: "Proprietà Java",
threads: "Dump dei thread",
coreAdmin: "Amministrazione core",
metrics: "Metriche",
overview: "Panoramica",
analysis: "Analisi",
documents: "Documenti",
paramsets: "Set di parametri",
files: "File",
ping: "Ping",
plugins: "Plugin / Statistiche",
query: "Query",
replication: "Replicazione",
schema: "Schema",
segments: "Informazioni sui segmenti",
},
dashboard: {
instance: "Istanza",
start: "Ora di avvio",
uptime: "Tempo di attività",
versions: "Versioni",
jvmTitle: "JVM",
runtime: "Runtime",
args: "Parametri di avvio",
system: "Sistema",
physMem: "Memoria fisica",
swap: "Spazio di swap",
fd: "Descrittori di file",
jvmMemory: "Memoria JVM",
outdatedWarning: "Questa versione di Solr è stata rilasciata da oltre un anno; si consiglia l'aggiornamento.",
mode: "Modalità di esecuzione",
solr_home: "Solr Home",
core_root: "Directory radice dei core",
processors: "Numero di processori",
},
logging: {
name: "Logger",
level: "Livello",
set: "Imposta",
},
core: {
name: "Core",
docs: "Numero di documenti",
size: "Dimensione",
segments: "Numero di segmenti",
uptime: "Tempo di attività",
ops: "Operazioni",
},
schema: {
fields: "campi",
dynamicFields: "campi dinamici",
fieldTypes: "tipi di campo",
name: "Nome",
type: "Tipo",
flags: "Flag",
},
analysis: {
field: "Nome del campo",
indexValue: "Testo lato indice",
queryValue: "Testo lato query (opzionale)",
indexPlaceholder: "Testo analizzato durante la scrittura in questo campo",
queryPlaceholder: "Testo analizzato durante la query",
run: "Analizza",
index: "Catena di indicizzazione",
query: "Catena di query",
empty: "Nessuna fase di analisi restituita. Controlla che il nome del campo sia corretto.",
},
segments: {
name: "Segmento",
deleted: "Eliminato",
age: "Età",
source: "Origine",
},
actions: {
create: "Crea core",
reload: "Ricarica",
unload: "Scarica",
rename: "Rinomina",
swap: "Scambia",
confirm: 'Eseguire {action} sul core "{core}"?',
success: '{action} su "{core}" eseguito correttamente',
failed: "Operazione di amministrazione core non riuscita: {message}",
newName: "Nuovo nome core",
swapWith: "Scambia con il core",
},
queryForm: {
defaultParser: "Parser predefinito",
paramsets: "paramset",
jsonHint: "Se non vuoto, invia con JSON DSL",
execute: "Esegui query",
openInConsole: "Apri nella console",
rowsReturned: "Righe",
tableView: "Tabella",
rawJson: "JSON non elaborato",
},
overview: {
statistics: "Statistiche",
instance: "Istanza",
replication: "Replicazione",
healthcheck: "Controllo di integrità",
lastModified: "Ultima modifica",
numDocs: "Numero di documenti",
maxDoc: "Numero massimo di documenti",
deletedDocs: "Documenti eliminati",
version: "Versione",
segmentCount: "Numero di segmenti",
current: "Corrente",
cwd: "Directory di lavoro",
instanceDir: "Directory dell'istanza",
data: "Directory dei dati",
index: "Directory dell'indice",
impl: "Classe di implementazione",
colVersion: "Versione",
colGen: "Gen",
colSize: "Dimensione",
leaderSearching: "Leader (ricerca in corso)",
leaderReplicable: "Leader (replicabile)",
noReplication: "Questo core non dispone di dettagli di replica del leader.",
noHealthcheck: "Il gestore di richieste Ping non ha configurato un file healthcheck.",
justNow: "Proprio ora",
minutesAgo: "Circa {n} minuti fa",
hoursAgo: "Circa {n} ore fa",
daysAgo: "Circa {n} giorni fa",
},
replication: {
refresh: "Aggiorna stato",
disable: "Disabilita replicazione",
enable: "Abilita replicazione",
index: "Indice",
settingsLeader: "Impostazioni (Leader)",
settingsFollower: "Impostazioni (Follower)",
noDetails: "Questo core non ha dettagli di replicazione.",
},
},
});
+148
View File
@@ -10267,4 +10267,152 @@ export default withEnglishFallback({
titleLabel: "タイトル(任意)",
titlePlaceholder: "例:クエリ結果",
},
solrAdmin: {
title: "Solr 管理",
refresh: "更新",
selectCore: "コアを選択",
noCore: "コアが選択されていません。コアレベルの管理ページを表示するには、まずコアを選択してください。",
emptyBody: "レスポンスボディが空です。",
optional: "任意",
sections: {
server: "サーバー",
core: "コア",
},
views: {
dashboard: "ダッシュボード",
logging: "ログ",
javaProps: "Java プロパティ",
threads: "スレッドダンプ",
coreAdmin: "コア管理",
metrics: "メトリクス",
overview: "概要",
analysis: "アナライズ",
documents: "ドキュメント",
paramsets: "パラメータセット",
files: "ファイル",
ping: "Ping",
plugins: "プラグイン / 統計",
query: "クエリ",
replication: "レプリケーション",
schema: "スキーマ",
segments: "セグメント情報",
},
dashboard: {
instance: "インスタンス",
start: "起動時間",
uptime: "稼働時間",
versions: "バージョン",
jvmTitle: "JVM",
runtime: "ランタイム",
args: "起動引数",
system: "システム",
physMem: "物理メモリ",
swap: "スワップ領域",
fd: "ファイル記述子数",
jvmMemory: "JVM メモリ",
outdatedWarning: "この Solr バージョンはリリースから1年以上経過しています。アップグレードをおすすめします。",
mode: "実行モード",
solr_home: "Solr Home",
core_root: "コアルートディレクトリ",
processors: "プロセッサ数",
},
logging: {
name: "ロガー",
level: "レベル",
set: "設定済み",
},
core: {
name: "コア",
docs: "ドキュメント数",
size: "サイズ",
segments: "セグメント数",
uptime: "稼働時間",
ops: "操作",
},
schema: {
fields: "フィールド",
dynamicFields: "動的フィールド",
fieldTypes: "フィールドタイプ",
name: "名前",
type: "型",
flags: "フラグ",
},
analysis: {
field: "フィールド名",
indexValue: "インデックス側テキスト",
queryValue: "クエリ側テキスト(任意)",
indexPlaceholder: "このフィールドに書き込む際に分析されるテキスト",
queryPlaceholder: "クエリ時に分析されるテキスト",
run: "分析",
index: "インデックスチェーン",
query: "クエリチェーン",
empty: "分析フェーズが返されませんでした。フィールド名が正しいか確認してください。",
},
segments: {
name: "セグメント",
deleted: "削除済み",
age: "経過時間",
source: "ソース",
},
actions: {
create: "コアの新規作成",
reload: "再読み込み",
unload: "アンロード",
rename: "名前の変更",
swap: "交換",
confirm: 'コア "{core}" に対して {action} を実行しますか?',
success: '{action} "{core}" が成功しました',
failed: "コア管理操作に失敗しました:{message}",
newName: "新しいコア名",
swapWith: "コアと交換",
},
queryForm: {
defaultParser: "デフォルトパーサー",
paramsets: "paramset(s)",
jsonHint: "空でない場合、JSON DSL で送信します",
execute: "クエリを実行",
openInConsole: "コンソールで開く",
rowsReturned: "行",
tableView: "テーブル",
rawJson: "生の JSON",
},
overview: {
statistics: "統計",
instance: "インスタンス",
replication: "レプリケーション",
healthcheck: "ヘルスチェック",
lastModified: "最終更新",
numDocs: "ドキュメント数",
maxDoc: "最大ドキュメント数",
deletedDocs: "削除済みドキュメント",
version: "バージョン",
segmentCount: "セグメント数",
current: "現在",
cwd: "作業ディレクトリ",
instanceDir: "インスタンスディレクトリ",
data: "データディレクトリ",
index: "インデックスディレクトリ",
impl: "実装クラス",
colVersion: "バージョン",
colGen: "Gen",
colSize: "サイズ",
leaderSearching: "リーダー(検索中)",
leaderReplicable: "リーダー(複製可能)",
noReplication: "このコアにはリーダーの複製詳細がありません。",
noHealthcheck: "Ping リクエストハンドラに healthcheck ファイルが設定されていません。",
justNow: "たった今",
minutesAgo: "約 {n} 分前",
hoursAgo: "約 {n} 時間前",
daysAgo: "約 {n} 日前",
},
replication: {
refresh: "状態を更新",
disable: "レプリケーションを無効化",
enable: "レプリケーションを有効化",
index: "インデックス",
settingsLeader: "設定(リーダー)",
settingsFollower: "設定(フォロワー)",
noDetails: "このコアにはレプリケーションの詳細がありません。",
},
},
});
+148
View File
@@ -9675,4 +9675,152 @@ export default withEnglishFallback({
titleLabel: "제목 (선택)",
titlePlaceholder: "예: 쿼리 결과",
},
solrAdmin: {
title: "Solr 관리",
refresh: "새로 고침",
selectCore: "코어 선택",
noCore: "코어를 선택하지 않았습니다. 먼저 코어를 선택한 후 코어 수준 관리 페이지를 확인하세요.",
emptyBody: "응답 본문이 비어 있습니다.",
optional: "선택 사항",
sections: {
server: "서버",
core: "코어",
},
views: {
dashboard: "대시보드",
logging: "로깅",
javaProps: "Java 속성",
threads: "스레드 덤프",
coreAdmin: "코어 관리",
metrics: "지표",
overview: "개요",
analysis: "분석",
documents: "문서",
paramsets: "매개변수 집합",
files: "파일",
ping: "Ping",
plugins: "플러그인 / 통계",
query: "쿼리",
replication: "복제",
schema: "스키마",
segments: "세그먼트 정보",
},
dashboard: {
instance: "인스턴스",
start: "시작 시간",
uptime: "가동 시간",
versions: "버전",
jvmTitle: "JVM",
runtime: "런타임",
args: "시작 매개변수",
system: "시스템",
physMem: "물리 메모리",
swap: "스왑 공간",
fd: "파일 디스크립터 수",
jvmMemory: "JVM 메모리",
outdatedWarning: "이 Solr 버전은 출시된 지 1년이 넘었습니다. 업그레이드를 권장합니다.",
mode: "실행 모드",
solr_home: "Solr Home",
core_root: "코어 루트 디렉터리",
processors: "프로세서 수",
},
logging: {
name: "로거",
level: "레벨",
set: "설정됨",
},
core: {
name: "코어",
docs: "문서 수",
size: "크기",
segments: "세그먼트 수",
uptime: "가동 시간",
ops: "작업",
},
schema: {
fields: "필드",
dynamicFields: "동적 필드",
fieldTypes: "필드 유형",
name: "이름",
type: "유형",
flags: "플래그",
},
analysis: {
field: "필드 이름",
indexValue: "색인 측 텍스트",
queryValue: "쿼리 측 텍스트(선택 사항)",
indexPlaceholder: "이 필드에 쓸 때 분석되는 텍스트",
queryPlaceholder: "쿼리 시 분석되는 텍스트",
run: "분석",
index: "색인 체인",
query: "쿼리 체인",
empty: "분석 단계가 반환되지 않았습니다. 필드 이름이 올바른지 확인하세요.",
},
segments: {
name: "세그먼트",
deleted: "삭제됨",
age: "나이",
source: "소스",
},
actions: {
create: "새 코어 생성",
reload: "다시 로드",
unload: "언로드",
rename: "이름 바꾸기",
swap: "교환",
confirm: '코어 "{core}"에 대해 {action}을(를) 실행하시겠습니까?',
success: '{action} "{core}" 실행 성공',
failed: "코어 관리 작업 실패: {message}",
newName: "새 코어 이름",
swapWith: "코어와 교환",
},
queryForm: {
defaultParser: "기본 파서",
paramsets: "paramset(s)",
jsonHint: "비어 있지 않으면 JSON DSL로 제출합니다",
execute: "쿼리 실행",
openInConsole: "콘솔에서 열기",
rowsReturned: "행",
tableView: "표",
rawJson: "원시 JSON",
},
overview: {
statistics: "통계",
instance: "인스턴스",
replication: "복제",
healthcheck: "상태 확인",
lastModified: "최종 수정",
numDocs: "문서 수",
maxDoc: "최대 문서 수",
deletedDocs: "삭제된 문서",
version: "버전",
segmentCount: "세그먼트 수",
current: "현재",
cwd: "작업 디렉터리",
instanceDir: "인스턴스 디렉터리",
data: "데이터 디렉터리",
index: "색인 디렉터리",
impl: "구현 클래스",
colVersion: "버전",
colGen: "Gen",
colSize: "크기",
leaderSearching: "리더(검색 중)",
leaderReplicable: "리더(복제 가능)",
noReplication: "이 코어에는 리더 복제 세부 정보가 없습니다.",
noHealthcheck: "Ping 요청 핸들러에 healthcheck 파일이 구성되지 않았습니다.",
justNow: "방금",
minutesAgo: "약 {n}분 전",
hoursAgo: "약 {n}시간 전",
daysAgo: "약 {n}일 전",
},
replication: {
refresh: "상태 새로 고침",
disable: "복제 사용 안 함",
enable: "복제 사용",
index: "색인",
settingsLeader: "설정(리더)",
settingsFollower: "설정(팔로워)",
noDetails: "이 코어에는 복제 세부 정보가 없습니다.",
},
},
});
+148
View File
@@ -10218,4 +10218,152 @@ export default withEnglishFallback({
titleLabel: "Título (opcional)",
titlePlaceholder: "ex.: Resultado da consulta",
},
solrAdmin: {
title: "Administração do Solr",
refresh: "Atualizar",
selectCore: "Selecionar core",
noCore: "Nenhum core selecionado. Selecione um core primeiro para visualizar a página de administração do core.",
emptyBody: "O corpo da resposta está vazio.",
optional: "Opcional",
sections: {
server: "Servidor",
core: "Core",
},
views: {
dashboard: "Painel",
logging: "Logs",
javaProps: "Propriedades Java",
threads: "Dump de threads",
coreAdmin: "Administração do Core",
metrics: "Métricas",
overview: "Visão geral",
analysis: "Análise",
documents: "Documentos",
paramsets: "Conjuntos de parâmetros",
files: "Arquivos",
ping: "Ping",
plugins: "Plugins / Estatísticas",
query: "Consulta",
replication: "Replicação",
schema: "Schema",
segments: "Informações de segmentos",
},
dashboard: {
instance: "Instância",
start: "Hora de início",
uptime: "Tempo de atividade",
versions: "Versões",
jvmTitle: "JVM",
runtime: "Tempo de execução",
args: "Argumentos de inicialização",
system: "Sistema",
physMem: "Memória física",
swap: "Swap",
fd: "Descritores de arquivo",
jvmMemory: "Memória da JVM",
outdatedWarning: "Esta versão do Solr foi lançada há mais de um ano, recomenda-se atualizar.",
mode: "Modo de execução",
solr_home: "Solr Home",
core_root: "Diretório raiz do Core",
processors: "Número de processadores",
},
logging: {
name: "Logger",
level: "Nível",
set: "Definido",
},
core: {
name: "Core",
docs: "Número de documentos",
size: "Tamanho",
segments: "Número de segmentos",
uptime: "Tempo de atividade",
ops: "Operações",
},
schema: {
fields: "campos",
dynamicFields: "campos dinâmicos",
fieldTypes: "tipos de campo",
name: "Nome",
type: "Tipo",
flags: "Sinalizadores",
},
analysis: {
field: "Nome do campo",
indexValue: "Texto do lado do índice",
queryValue: "Texto do lado da consulta (opcional)",
indexPlaceholder: "Texto analisado ao indexar este campo",
queryPlaceholder: "Texto analisado durante a consulta",
run: "Analisar",
index: "Cadeia de indexação",
query: "Cadeia de consulta",
empty: "Nenhuma fase de análise foi retornada. Verifique se o nome do campo está correto.",
},
segments: {
name: "Segmento",
deleted: "Excluído",
age: "Idade",
source: "Origem",
},
actions: {
create: "Criar Core",
reload: "Recarregar",
unload: "Descarregar",
rename: "Renomear",
swap: "Trocar",
confirm: 'Executar {action} no core "{core}"?',
success: '{action} "{core}" executado com sucesso',
failed: "Falha na operação de administração do Core: {message}",
newName: "Nome do novo core",
swapWith: "Trocar com o core",
},
queryForm: {
defaultParser: "Parser padrão",
paramsets: "paramset(s)",
jsonHint: "Se não vazio, enviar usando JSON DSL",
execute: "Executar consulta",
openInConsole: "Abrir no console",
rowsReturned: "Linhas",
tableView: "Tabela",
rawJson: "JSON bruto",
},
overview: {
statistics: "Estatísticas",
instance: "Instância",
replication: "Replicação",
healthcheck: "Verificação de integridade",
lastModified: "Última modificação",
numDocs: "Número de documentos",
maxDoc: "Número máximo de documentos",
deletedDocs: "Documentos excluídos",
version: "Versão",
segmentCount: "Número de segmentos",
current: "Atual",
cwd: "Diretório de trabalho",
instanceDir: "Diretório da instância",
data: "Diretório de dados",
index: "Diretório do índice",
impl: "Classe de implementação",
colVersion: "Versão",
colGen: "Gen",
colSize: "Tamanho",
leaderSearching: "Líder (pesquisando)",
leaderReplicable: "Líder (replicável)",
noReplication: "Este core não possui detalhes de replicação do líder.",
noHealthcheck: "O manipulador de requisições Ping não configurou o arquivo healthcheck.",
justNow: "Agora mesmo",
minutesAgo: "Há cerca de {n} minutos",
hoursAgo: "Há cerca de {n} horas",
daysAgo: "Há cerca de {n} dias",
},
replication: {
refresh: "Atualizar status",
disable: "Desabilitar replicação",
enable: "Habilitar replicação",
index: "Índice",
settingsLeader: "Configurações (Líder)",
settingsFollower: "Configurações (Seguidor)",
noDetails: "Este core não possui detalhes de replicação.",
},
},
});
+148
View File
@@ -4185,6 +4185,154 @@ export default withEnglishFallback({
unknown: "未知",
},
},
solrAdmin: {
title: "Solr 管理",
refresh: "刷新",
selectCore: "选择 core",
noCore: "未选择 core。请先选择一个 core 再查看 core 级管理页。",
emptyBody: "响应体为空。",
optional: "可选",
sections: {
server: "服务器",
core: "Core",
},
views: {
dashboard: "仪表盘",
logging: "日志",
javaProps: "Java 属性",
threads: "线程转储",
coreAdmin: "Core 管理",
metrics: "指标",
overview: "概览",
analysis: "分词分析",
documents: "文档",
paramsets: "参数集",
files: "文件",
ping: "Ping",
plugins: "插件 / 统计",
query: "查询",
replication: "复制",
schema: "Schema",
segments: "段信息",
},
dashboard: {
instance: "实例",
start: "启动时间",
uptime: "运行时长",
versions: "版本",
jvmTitle: "JVM",
runtime: "运行时",
args: "启动参数",
system: "系统",
physMem: "物理内存",
swap: "交换空间",
fd: "文件描述符数",
jvmMemory: "JVM 内存",
outdatedWarning: "该 Solr 版本发布已超过一年,建议升级。",
mode: "运行模式",
solr_home: "Solr Home",
core_root: "Core 根目录",
processors: "处理器数",
},
logging: {
name: "Logger",
level: "级别",
set: "已设置",
},
core: {
name: "Core",
docs: "文档数",
size: "大小",
segments: "段数",
uptime: "运行时长",
ops: "操作",
},
schema: {
fields: "个字段",
dynamicFields: "个动态字段",
fieldTypes: "个字段类型",
name: "名称",
type: "类型",
flags: "标记",
},
analysis: {
field: "字段名",
indexValue: "索引侧文本",
queryValue: "查询侧文本(可选)",
indexPlaceholder: "写入该字段时分析的文本",
queryPlaceholder: "查询时分析的文本",
run: "分析",
index: "索引链",
query: "查询链",
empty: "未返回分析阶段,请检查字段名是否正确。",
},
segments: {
name: "段",
deleted: "已删除",
age: "年龄",
source: "来源",
},
actions: {
create: "新建 Core",
reload: "重新加载",
unload: "卸载",
rename: "重命名",
swap: "交换",
confirm: '对 core "{core}" 执行 {action}?',
success: '{action} "{core}" 执行成功',
failed: "Core 管理操作失败:{message}",
newName: "新 core 名称",
swapWith: "与 core 交换",
},
queryForm: {
defaultParser: "默认解析器",
paramsets: "paramset(s)",
jsonHint: "非空时改用 JSON DSL 提交",
execute: "执行查询",
openInConsole: "在控制台打开",
rowsReturned: "行",
tableView: "表格",
rawJson: "原始 JSON",
},
overview: {
statistics: "统计",
instance: "实例",
replication: "复制",
healthcheck: "健康检查",
lastModified: "最后修改",
numDocs: "文档数",
maxDoc: "最大文档数",
deletedDocs: "已删除文档",
version: "版本",
segmentCount: "段数",
current: "当前",
cwd: "工作目录",
instanceDir: "实例目录",
data: "数据目录",
index: "索引目录",
impl: "实现类",
colVersion: "版本",
colGen: "Gen",
colSize: "大小",
leaderSearching: "Leader(检索中)",
leaderReplicable: "Leader(可复制)",
noReplication: "该 core 没有 leader 复制详情。",
noHealthcheck: "Ping 请求处理器未配置 healthcheck 文件。",
justNow: "刚刚",
minutesAgo: "约 {n} 分钟前",
hoursAgo: "约 {n} 小时前",
daysAgo: "约 {n} 天前",
},
replication: {
refresh: "刷新状态",
disable: "禁用复制",
enable: "启用复制",
index: "索引",
settingsLeader: "设置(Leader)",
settingsFollower: "设置(Follower)",
noDetails: "该 core 没有复制详情。",
},
},
userAdmin: {
title: "用户与权限",
unsupported: "当前支持 MySQL 兼容与 PostgreSQL 兼容连接。SQL Server、Oracle 等会按各自权限模型继续扩展。",
+148
View File
@@ -10214,4 +10214,152 @@ export default withEnglishFallback({
titleLabel: "標題(可選)",
titlePlaceholder: "例如:查詢結果",
},
solrAdmin: {
title: "Solr 管理",
refresh: "重新整理",
selectCore: "選擇 Core",
noCore: "未選擇 Core。請先選擇一個 Core 再檢視 Core 層級管理頁。",
emptyBody: "回應主體為空。",
optional: "選填",
sections: {
server: "伺服器",
core: "Core",
},
views: {
dashboard: "儀表板",
logging: "日誌",
javaProps: "Java 屬性",
threads: "執行緒傾印",
coreAdmin: "Core 管理",
metrics: "指標",
overview: "總覽",
analysis: "分詞分析",
documents: "文件",
paramsets: "參數集",
files: "檔案",
ping: "Ping",
plugins: "外掛 / 統計",
query: "查詢",
replication: "複製",
schema: "Schema",
segments: "段資訊",
},
dashboard: {
instance: "執行個體",
start: "啟動時間",
uptime: "運作時間",
versions: "版本",
jvmTitle: "JVM",
runtime: "執行階段",
args: "啟動參數",
system: "系統",
physMem: "實體記憶體",
swap: "交換空間",
fd: "檔案描述符數",
jvmMemory: "JVM 記憶體",
outdatedWarning: "此 Solr 版本發佈已超過一年,建議升級。",
mode: "運作模式",
solr_home: "Solr Home",
core_root: "Core 根目錄",
processors: "處理器數",
},
logging: {
name: "Logger",
level: "等級",
set: "已設定",
},
core: {
name: "Core",
docs: "文件數",
size: "大小",
segments: "段數",
uptime: "運作時間",
ops: "操作",
},
schema: {
fields: "個欄位",
dynamicFields: "個動態欄位",
fieldTypes: "個欄位類型",
name: "名稱",
type: "類型",
flags: "標記",
},
analysis: {
field: "欄位名稱",
indexValue: "索引端文字",
queryValue: "查詢端文字(選填)",
indexPlaceholder: "寫入該欄位時分析的文字",
queryPlaceholder: "查詢時分析的文字",
run: "分析",
index: "索引鏈",
query: "查詢鏈",
empty: "未傳回分析階段,請檢查欄位名稱是否正確。",
},
segments: {
name: "段",
deleted: "已刪除",
age: "年齡",
source: "來源",
},
actions: {
create: "新增 Core",
reload: "重新載入",
unload: "卸載",
rename: "重新命名",
swap: "交換",
confirm: '對 Core "{core}" 執行 {action}?',
success: '{action} "{core}" 執行成功',
failed: "Core 管理操作失敗:{message}",
newName: "新 Core 名稱",
swapWith: "與 Core 交換",
},
queryForm: {
defaultParser: "預設解析器",
paramsets: "參數集",
jsonHint: "非空時改用 JSON DSL 提交",
execute: "執行查詢",
openInConsole: "在主控台開啟",
rowsReturned: "列",
tableView: "表格",
rawJson: "原始 JSON",
},
overview: {
statistics: "統計",
instance: "執行個體",
replication: "複製",
healthcheck: "健康檢查",
lastModified: "最後修改",
numDocs: "文件數",
maxDoc: "最大文件數",
deletedDocs: "已刪除文件",
version: "版本",
segmentCount: "段數",
current: "目前",
cwd: "工作目錄",
instanceDir: "執行個體目錄",
data: "資料目錄",
index: "索引目錄",
impl: "實作類別",
colVersion: "版本",
colGen: "Gen",
colSize: "大小",
leaderSearching: "Leader(搜尋中)",
leaderReplicable: "Leader(可複製)",
noReplication: "此 Core 沒有 Leader 複製詳細資料。",
noHealthcheck: "Ping 請求處理常式未設定 healthcheck 檔案。",
justNow: "剛剛",
minutesAgo: "約 {n} 分鐘前",
hoursAgo: "約 {n} 小時前",
daysAgo: "約 {n} 天前",
},
replication: {
refresh: "重新整理狀態",
disable: "停用複製",
enable: "啟用複製",
index: "索引",
settingsLeader: "設定(Leader)",
settingsFollower: "設定(Follower)",
noDetails: "此 Core 沒有複製詳細資料。",
},
},
});
+103
View File
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildAiContext, buildSystemPrompt, type AiContext } from "@/lib/ai/ai";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const apiMock = vi.hoisted(() => ({
listTables: vi.fn(),
getColumns: vi.fn(),
listIndexes: vi.fn(),
listForeignKeys: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
function solrConnection(): ConnectionConfig {
return {
id: "solr-1",
name: "Solr",
db_type: "solr",
host: "127.0.0.1",
port: 8983,
username: "",
password: "",
};
}
function queryTab(): QueryTab {
return {
id: "tab-1",
title: "Query",
connectionId: "solr-1",
database: "default",
sql: "GET /products/select?q=*:*",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
};
}
function solrAiContext(): AiContext {
return {
connectionId: "solr-1",
connectionName: "Solr",
databaseType: "solr",
database: "default",
currentSql: "GET /products/select?q=*:*",
tables: [
{
name: "products",
tableType: "CORE",
columns: [
{ name: "id", data_type: "string", is_primary_key: true, is_nullable: false, column_default: null, extra: null },
{ name: "title", data_type: "text_general", is_primary_key: false, is_nullable: true, column_default: null, extra: null },
],
},
],
sqlFiles: [],
csvFiles: [],
schemaScope: "database",
truncated: false,
};
}
describe("Solr AI context", () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.listTables.mockResolvedValue([{ name: "products", table_type: "CORE" }]);
apiMock.getColumns.mockResolvedValue([]);
apiMock.listIndexes.mockResolvedValue([]);
apiMock.listForeignKeys.mockResolvedValue([]);
});
it("loads Solr cores as schema tables through the generic metadata path", async () => {
const context = await buildAiContext(queryTab(), solrConnection());
expect(context.databaseType).toBe("solr");
expect(apiMock.listTables).toHaveBeenCalledWith("solr-1", "default", "default");
expect(apiMock.getColumns).toHaveBeenCalledWith("solr-1", "default", "default", "products");
expect(context.tables.map((table) => table.name)).toEqual(["products"]);
});
});
describe("Solr AI system prompt", () => {
it("describes the REST-console request format instead of SQL", () => {
const prompt = buildSystemPrompt("generate", solrAiContext(), "ask");
expect(prompt).toContain("Apache Solr");
expect(prompt).toContain("METHOD /path");
expect(prompt).toContain("Solr");
// The prompt must not instruct the model to emit SQL for Solr.
expect(prompt).not.toContain("adapt SQL to the active database dialect");
expect(prompt).not.toContain("Generate SQL and explanations only");
});
it("frames agent-mode tools with core semantics", () => {
const prompt = buildSystemPrompt("query", solrAiContext(), "agent");
expect(prompt).toContain("list_tables");
expect(prompt).toContain("execute_query");
expect(prompt).toContain("METHOD /path");
expect(prompt).toContain("core");
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { documentStoreProviderFor } from "@/lib/app/documentStoreProvider";
describe("Solr document store provider", () => {
it("uses the Solr document provider", () => {
const provider = documentStoreProviderFor("solr");
expect(provider.kind).toBe("solr");
const preview = provider.queryPreview({
collection: "mycore",
filterJson: '{"status":"paid"}',
sortJson: '{"id":1}',
skip: 0,
limit: 100,
});
expect(preview).toContain("DBX SOLR QUERY DOCUMENTS");
expect(preview).toContain('core: "mycore"');
expect(preview).toContain("offset: 0");
expect(preview).toContain("limit: 100");
expect(preview).not.toContain("/_search");
expect(preview).not.toContain("_routing");
});
});
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { applyParsedConnectionUrl, connectionProfileForScheme, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import type { ConnectionConfig } from "@/types/database";
describe("Solr connection URLs", () => {
it("rejects an empty host URL", () => {
expect(() => parseConnectionUrl("", "solr")).toThrow("Connection URL is empty");
});
it("parses the dedicated scheme with the default port and credentials", () => {
expect(parseConnectionUrl("solr://solr:SolrRocks@search.example.com")).toMatchObject({
dbType: "solr",
driverProfile: "solr",
driverLabel: "Apache Solr",
host: "search.example.com",
port: 8983,
username: "solr",
password: "SolrRocks",
database: undefined,
ssl: false,
});
});
it("keeps Solr selected for HTTP/HTTPS URLs", () => {
expect(parseConnectionUrl("http://search.example.com:8984", "solr")).toMatchObject({
dbType: "solr",
host: "search.example.com",
port: 8984,
ssl: false,
});
expect(parseConnectionUrl("https://search.example.com?insecure=true", "solr")).toMatchObject({
dbType: "solr",
host: "search.example.com",
port: 8983,
urlParams: "insecure=true",
ssl: true,
});
});
it("does not treat the /solr context path as a database", () => {
// The driver appends /solr itself; storing it as the database would make the
// parsed value look like a selectable namespace that does not exist.
for (const url of ["solr://search.example.com/solr", "http://search.example.com:8983/solr", "https://search.example.com/solr/"]) {
expect(parseConnectionUrl(url, "solr")).toMatchObject({
dbType: "solr",
database: undefined,
});
}
});
it("does not resurrect a database through applyParsedConnectionUrl", () => {
const parsed = parseConnectionUrl("solr://search.example.com/solr", "solr");
const config = applyParsedConnectionUrl(
{
db_type: "solr",
driver_profile: "solr",
driver_label: "Apache Solr",
host: "localhost",
port: 8983,
username: "",
password: "",
database: "stale-core",
url_params: "",
ssl: false,
} as Omit<ConnectionConfig, "id">,
parsed,
);
expect(config.database).toBeUndefined();
});
it("exposes the Solr scheme profile", () => {
expect(connectionProfileForScheme("solr")).toEqual({
type: "solr",
profile: "solr",
label: "Apache Solr",
defaultPort: 8983,
});
});
});
@@ -28,7 +28,7 @@ describe("Cloud Spanner connection dialog", () => {
// database that opts out of one of them means updating the string here too.
expect(dialogSource).toContain("<div v-if=\"form.db_type !== 'meilisearch' && form.db_type !== 'spanner'\" class=\"grid grid-cols-4 items-center gap-4\">");
expect(dialogSource).toContain('<div v-if="form.db_type !== \'spanner\'" class="grid grid-cols-4 items-center gap-4">');
expect(dialogSource).toContain("<div v-if=\"form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'spanner'\" class=\"grid grid-cols-4 items-center gap-4\">");
expect(dialogSource).toContain("<div v-if=\"form.db_type !== 'hbase' && form.db_type !== 'meilisearch' && form.db_type !== 'solr' && form.db_type !== 'spanner'\" class=\"grid grid-cols-4 items-center gap-4\">");
// Switching to the Spanner profile clears host and credentials.
expect(dialogSource).toContain('if (profile.type === "spanner") {');
// Save/Test gating and submit-time normalization.
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { classifySqlRisk, classifySqlStatementRisk } from "@/lib/sql/sqlRisk";
import { sqlLooksLikeMutation } from "@/lib/database/readOnlyWriteAccess";
const SEARCH_REQUEST = 'POST /solr/mycore/select\n{\n "query": "*:*",\n "limit": 100\n}';
describe("Solr request risk", () => {
it("treats queries and other retrieval requests as reads", () => {
expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/mycore/select?q=*:*", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/admin/cores?action=STATUS", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("HEAD /solr/mycore/admin/ping", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk('POST /solr/mycore/query\n{"query":"*:*"}', { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk('POST /mycore/suggest\n{"suggest":{"q":"sol"}}', { dialect: "solr" }).risk).toBe("read");
});
it("treats document updates as writes and admin/schema changes as dangerous", () => {
expect(classifySqlRisk('POST /solr/mycore/update\n{"add":{"doc":{"id":"1"}}}', { dialect: "solr" }).risk).toBe("write");
expect(classifySqlRisk('POST /mycore/update/json/docs\n{"a":1}', { dialect: "solr" }).risk).toBe("write");
expect(classifySqlRisk('POST /solr/mycore/update?commit=true\n{"delete":{"query":"*:*"}}', { dialect: "solr" }).risk).toBe("write");
expect(classifySqlRisk("POST /solr/admin/cores?action=CREATE&name=x", { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlRisk('POST /solr/mycore/schema\n{"add-field":{"name":"f","type":"string"}}', { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlRisk("DELETE /solr/mycore/update", { dialect: "solr" }).risk).toBe("write");
expect(classifySqlRisk("DELETE /solr/mycore/config", { dialect: "solr" }).risk).toBe("ddl");
});
it("treats GET-driven update and admin actions as mutations", () => {
// Solr update handlers answer GET (commit/optimize/stream.body) and
// CoreAdmin actions ride on GET as well, so the method alone cannot mark
// these paths read-only.
expect(classifySqlRisk("GET /solr/mycore/update?commit=true", { dialect: "solr" }).risk).toBe("write");
expect(classifySqlRisk("GET /solr/admin/cores?action=CREATE&name=x", { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlRisk("GET /solr/admin/cores?action=UNLOAD&core=mycore", { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlRisk("GET /solr/admin/cores?action=REQUESTSTATUS&requestid=1", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/admin/info/system", { dialect: "solr" }).risk).toBe("read");
});
it("treats GET replication commands outside the read whitelist as dangerous", () => {
expect(classifySqlRisk("GET /solr/mycore/replication?command=details", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/mycore/replication?command=restorestatus", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/mycore/replication?command=filelist", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/mycore/replication", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("GET /solr/mycore/replication?command=disablereplication", { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlRisk("GET /solr/mycore/replication?command=enablereplication", { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlRisk("GET /solr/mycore/replication?command=fetchindex", { dialect: "solr" }).risk).toBe("ddl");
});
it("reports the highest risk across every request in the editor text", () => {
const source = `${SEARCH_REQUEST}\n\nPOST /solr/admin/cores?action=UNLOAD&core=mycore`;
expect(classifySqlRisk(source, { dialect: "solr" }).risk).toBe("ddl");
expect(classifySqlStatementRisk(SEARCH_REQUEST, { dialect: "solr" }).risk).toBe("read");
});
it("reads the request line through query strings, trailing slashes and CRLF endings", () => {
expect(classifySqlRisk("GET /solr/mycore/select?q=*:*&rows=1", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("post /solr/mycore/select/\r\n{}", { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk('POST /solr/mycore/update?commitWithin=500\n{"delete":{"id":"1"}}', { dialect: "solr" }).risk).toBe("write");
expect(classifySqlRisk(" GET /solr/mycore/select // inline comment", { dialect: "solr" }).risk).toBe("read");
});
it("ignores commented-out requests", () => {
expect(classifySqlRisk(`# DELETE /solr/mycore/config\n// DELETE /solr/mycore/config\n/* DELETE /solr/mycore/config */\n${SEARCH_REQUEST}`, { dialect: "solr" }).risk).toBe("read");
expect(classifySqlRisk("/*\nDELETE /solr/mycore/config\n*/\nGET /solr/mycore/select", { dialect: "solr" }).risk).toBe("read");
});
it("leaves text that does not start with a request line to the SQL rules", () => {
// The backend anchors on the first line too, so a stray request line further
// down must not turn unparseable text into a read.
expect(classifySqlRisk('{"query":"*:*"}\nGET /solr/mycore/select', { dialect: "solr" }).risk).toBe("unknown");
});
it("keeps REST requests classified as SQL for other database types", () => {
expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "mysql" }).risk).toBe("unknown");
expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "elasticsearch" }).risk).not.toBe("read");
});
it("does not ask a read-only connection to unlock writes for a search", () => {
expect(sqlLooksLikeMutation(SEARCH_REQUEST, "solr")).toBe(false);
expect(sqlLooksLikeMutation("GET /solr/mycore/select?q=*:*", "solr")).toBe(false);
expect(sqlLooksLikeMutation('POST /solr/mycore/update\n{"delete":{"query":"*:*"}}', "solr")).toBe(true);
expect(sqlLooksLikeMutation("POST /solr/admin/cores?action=CREATE&name=x", "solr")).toBe(true);
});
});
+79
View File
@@ -32,6 +32,7 @@ function dbLabel(dbType: DatabaseType): string {
milvus: "Milvus",
weaviate: "Weaviate",
chromadb: "ChromaDB",
solr: "Apache Solr",
};
return labels[dbType] || dbType;
}
@@ -318,6 +319,9 @@ export function buildSystemPrompt(action: AiAction, context: AiContext, mode: Ai
if (context.databaseType === "redis") {
return buildRedisSystemPrompt(context, mode, custom);
}
if (context.databaseType === "solr") {
return buildSolrSystemPrompt(context, mode, custom);
}
const schema = formatSchema(context);
const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
@@ -397,6 +401,59 @@ function buildRedisSystemPrompt(context: AiContext, mode: AiAssistantMode, custo
return lines.filter(Boolean).join("\n");
}
/**
* Solr prompt: Solr speaks REST, not SQL, so the generic dialect instructions
* would push the model toward SELECT statements that the Solr driver rejects.
* The query surface is the DBX REST console (`METHOD /path` + optional JSON
* body); schema context entries are Solr cores whose "columns" are schema
* fields (the PK-flagged field is the core's uniqueKey).
*/
function buildSolrSystemPrompt(context: AiContext, mode: AiAssistantMode, custom?: CustomPromptContext): string {
const isZh = isChineseLocale(currentLocale());
const schema = formatSchema(context);
const resultPreview = context.lastResultPreview ? `\nLast result preview:\n${context.lastResultPreview}\n` : "";
const lastError = context.lastError ? `\nLast error:\n${context.lastError}\n` : "";
const referencedSqlFiles = formatReferencedSqlFiles(context);
const lines: string[] = [
isZh ? "你是 DBX 内置的 Apache Solr 助手。用中文回复。" : "You are DBX's built-in Apache Solr assistant. Reply in English.",
isZh
? 'Solr 不使用 SQL。查询一律使用 DBX REST 控制台格式:首行 `METHOD /path`,后续可选 JSON 请求体,例如 `GET /{core}/select?q=*:*&rows=20`、`POST /{core}/query` 加 {"query":"..."} JSON body。路径可以省略开头的 /solr 段,wt=json 会自动补上。'
: 'Solr does not use SQL. Always express queries in the DBX REST-console format: `METHOD /path` on the first line plus an optional JSON body, e.g. `GET /{core}/select?q=*:*&rows=20` or `POST /{core}/query` with a body such as {"query":"..."}. A leading /solr path segment may be omitted and wt=json is appended automatically.',
isZh
? "Schema 上下文中每个条目是一个 Solr core(类型标注为 CORE);其下列出的是 schema fields,标记为 PK 的字段是该 core 的唯一键(uniqueKey)。用真实字段名构造 q/fq 参数,不要编造不存在的字段。"
: "Each entry in the schema context is a Solr core (marked CORE); the listed items are its schema fields and the PK-flagged field is the core's uniqueKey. Build q/fq parameters from real field names and never invent fields that are not listed.",
...buildModePromptLines(mode, isZh, context.databaseType),
...buildRichContentPromptLines(isZh),
...buildCustomInstructionLines(custom, isZh),
attachmentSafetyInstruction(isZh),
// The ```sql fence is only a transport convention: the editor treats the
// block content as a Solr REST request, never as SQL.
isZh ? "返回请求时放在 ```sql 代码块中,块内容是 Solr REST 请求文本而非 SQL。额外说明简短实用。" : "Put the request in a fenced ```sql code block; the block holds Solr REST request text, not SQL. Keep extra explanation short and practical.",
"",
"Database type: solr",
`Connection: ${context.connectionName}`,
`Database: ${context.database}`,
context.schema ? `Selected schema: ${context.schema}` : "",
schemaCoverageLine(context, isZh),
"",
`Current request:\n${context.currentSql.trim() || "(empty)"}`,
referencedSqlFiles,
lastError,
resultPreview,
`Schema:\n${schema}`,
];
if (context.schemaScope === "focused_table") {
lines.push(
isZh
? "Schema 上下文只覆盖当前打开的 core;连接中可能还有其他 core。用户询问有哪些 core 或提到上下文中不存在的 core 时,不要直接断言不存在,先用 list_tables 确认。"
: "Schema context covers only the currently opened core; the connection may contain other cores. When the user asks what cores exist or mentions a core absent from context, do not conclude it is missing; use list_tables to verify first.",
);
}
return lines.filter(Boolean).join("\n");
}
function buildBasePromptLines(isZh: boolean): string[] {
return [
isZh ? "你是 DBX 内置的数据库助手。用中文回复。" : "You are DBX's built-in database assistant. Reply in English.",
@@ -554,6 +611,28 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean, databaseType
currentTimeGuidance,
];
}
if (databaseType === "solr") {
if (mode === "agent") {
return [
isZh
? "你处于 Solr Agent 模式。你有以下工具可用:list_tables(列出 Solr cores)、get_columns(返回某个 core 的 schema fields)、execute_query、get_current_time。"
: "You are in Solr Agent mode. You have the following tools available: list_tables (lists Solr cores), get_columns (returns a core's schema fields), execute_query, get_current_time.",
isZh
? "execute_query 接受 DBX REST 控制台格式的 Solr 请求而不是 SQL:首行 `METHOD /path`,后续可选 JSON body,例如 `GET /{core}/select?q=*:*&rows=20` 或 `POST /{core}/query` 加 JSON body。用户提出数据查询意图时,必须调用该工具获取真实结果后再回答。"
: "execute_query accepts Solr REST-console requests, not SQL: `METHOD /path` on the first line plus an optional JSON body, for example `GET /{core}/select?q=*:*&rows=20` or `POST /{core}/query` with a JSON body. For data queries, call the tool and answer from its actual results.",
currentTimeGuidance,
isZh
? "禁止不经确认直接执行 Solr 写请求(`/{core}/update`、commit、schema/admin 变更);如果安全执行条件不满足,先说明原因,再给出只读替代方案。"
: "Never execute Solr write requests (`/{core}/update`, commits, schema/admin changes) without confirmation. If safe execution requirements are not met, explain why and provide a read-only alternative.",
];
}
return [
isZh
? "你处于 Solr Ask 模式。只生成 Solr REST 请求(`METHOD /path` + 可选 JSON body)和说明,不要生成 SQL,也不要暗示已经执行或即将自动执行。"
: "You are in Solr Ask mode. Generate Solr REST requests (`METHOD /path` plus optional JSON body) and explanations only, not SQL, and do not imply that anything has run or will auto-run.",
currentTimeGuidance,
];
}
if (mode === "agent") {
return [
isZh ? "你处于 Agent 模式。你有以下工具可用:list_tables、get_columns、execute_query、get_sample_data、get_current_time。" : "You are in Agent mode. You have the following tools available: list_tables, get_columns, execute_query, get_sample_data, get_current_time.",
@@ -41,6 +41,9 @@ export function documentStoreValueForGrid(value: unknown, kind: DocumentStoreKin
export function serializeDocumentStoreId(value: unknown, kind: DocumentStoreKind): string {
if (kind !== "mongodb" && isLosslessJsonNumber(value)) return value.raw;
if (kind === "elasticsearch") return String(value);
// Solr document ids are the uniqueKey field value verbatim — no quoting or
// sentinel encoding like Meilisearch's string-id marker.
if (kind === "solr") return String(value);
if (kind === "meilisearch") return typeof value === "string" ? `__dbx_meilisearch_string_id__${JSON.stringify(value)}` : String(value);
if (kind === "dynamodb") return stringifyJsonPreservingLargeNumbers(value);
return serializeMongoDocumentId(value);
@@ -1,10 +1,10 @@
import type { ComposerTranslation } from "vue-i18n";
import { normalizeJsonArgument } from "@dbx-app/mongo-shell";
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
import { quoteUnquotedObjectKeys } from "@/lib/mongo/mongoShellCommand";
import { formatMongoShellLiteral } from "@/lib/mongo/mongoDocumentValues";
export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch";
export type DocumentStoreKind = "mongodb" | "dynamodb" | "elasticsearch" | "meilisearch" | "solr";
export type DocumentFilterMode = "equals" | "not-equals" | "like" | "not-like" | "begins-with" | "ends-with" | "greater-than" | "greater-than-or-equal" | "less-than" | "less-than-or-equal" | "in" | "not-in" | "between" | "not-between" | "is-null" | "is-not-null";
export type DocumentFilterValueType = "auto" | "string" | "number" | "boolean" | "object-id" | "date" | "int32" | "int64" | "decimal128" | "json";
export type ElasticsearchBoolClause = "filter" | "must" | "should" | "must_not";
@@ -80,7 +80,9 @@ const MONGO_ONLY_DOCUMENT_FILTER_MODES = new Set<DocumentFilterMode>(["begins-wi
export function documentFilterModeOptionsFor(kind: DocumentStoreKind): Array<{ value: DocumentFilterMode; labelKey: string }> {
if (kind === "meilisearch") return documentFilterModeOptions.filter((option) => option.value !== "like" && option.value !== "not-like" && !MONGO_ONLY_DOCUMENT_FILTER_MODES.has(option.value));
if (kind === "mongodb") return documentFilterModeOptions;
// Solr's driver translates every mode — including $in/$nin and range pairs —
// into fq clauses, so the full Mongo-style operator set is available.
if (kind === "mongodb" || kind === "solr") return documentFilterModeOptions;
return documentFilterModeOptions.filter((option) => !MONGO_ONLY_DOCUMENT_FILTER_MODES.has(option.value));
}
@@ -166,6 +168,22 @@ const meilisearchDocumentProvider: DocumentStoreProvider = {
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
const solrDocumentProvider: DocumentStoreProvider = {
kind: "solr",
filterInputLabel: "filter",
sortInputLabel: "sort",
documentsLabel: ({ total }) => `${total} Documents`,
queryPreview: ({ collection, filterJson, sortJson, skip, limit }) => {
const lines = ["DBX SOLR QUERY DOCUMENTS", `core: ${JSON.stringify(collection)}`, `offset: ${skip}`, `limit: ${limit}`];
const filter = documentStorePreviewJson(filterJson);
if (filter) lines.push("filter:", filter);
const sort = documentStorePreviewJson(sortJson);
if (sort) lines.push("sort:", sort);
return lines.join("\n");
},
sortInputForColumn: mongoDocumentProvider.sortInputForColumn,
};
const dynamodbDocumentProvider: DocumentStoreProvider = {
kind: "dynamodb",
filterInputLabel: "filter",
@@ -197,6 +215,7 @@ export function documentStoreProviderFor(databaseType?: DatabaseType): DocumentS
if (databaseType === "dynamodb") return dynamodbDocumentProvider;
if (isElasticsearchCompatibleDatabaseType(databaseType)) return elasticsearchDocumentProvider;
if (isMeilisearchDatabaseType(databaseType)) return meilisearchDocumentProvider;
if (isSolrDatabaseType(databaseType)) return solrDocumentProvider;
return mongoDocumentProvider;
}
@@ -590,7 +609,10 @@ function escapeRegexLiteral(value: string): string {
}
function mongoDocumentFilterKind(options: DocumentFilterParseOptions): boolean {
return options.kind === undefined || options.kind === "mongodb";
// Solr shares the Mongo-style filter document — the driver translates $in,
// $gte/$lte and anchored $regex into fq clauses — so it uses the same
// operator-rich condition shapes.
return options.kind === undefined || options.kind === "mongodb" || options.kind === "solr";
}
/** Splits a comma/newline separated filter value list, keeping quoted values intact. */
@@ -824,6 +846,7 @@ const documentQueryInputNormalizers: Record<DocumentStoreKind, DocumentQueryInpu
dynamodb: quoteUnquotedObjectKeys,
elasticsearch: quoteUnquotedObjectKeys,
meilisearch: quoteUnquotedObjectKeys,
solr: quoteUnquotedObjectKeys,
};
function normalizeDocumentQueryObjectInput(input: string, kind?: DocumentStoreKind): string {
@@ -14,6 +14,13 @@ export function formatMeilisearchDocumentOperationPreview(options: { action: "in
return lines.join("\n");
}
export function formatSolrDocumentOperationPreview(options: { action: "insert" | "upsert" | "update" | "delete"; core: string; id?: unknown; document?: Record<string, unknown> }): string {
const lines = [`DBX SOLR ${options.action.toUpperCase()} DOCUMENT`, `core: ${JSON.stringify(options.core)}`];
if (options.id !== undefined) lines.push(`id: ${stringifyDocumentStoreValue(options.id, "solr")}`);
if (options.document) lines.push("document:", stringifyDocumentStoreValue(options.document, "solr", 2));
return lines.join("\n");
}
/**
* Write a document body under a known identity.
* - `put`: Elasticsearch index-by-id / Mongo update-by-id (identity via path, not body).
@@ -142,6 +142,7 @@ export function connectionDisplayUrlScheme(connection: Pick<ConnectionConfig, "d
case "elasticsearch":
case "easysearch":
case "meilisearch":
case "solr":
case "qdrant":
case "milvus":
case "weaviate":
@@ -233,6 +234,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType, driverProfile?: s
case "meilisearch":
return "http://host:port/base/path";
case "solr":
return "http://user:password@host:8983";
case "dameng":
return "dm://user:password@host:port";
@@ -59,6 +59,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
elasticsearch: { type: "elasticsearch", profile: "elasticsearch", label: "Elasticsearch", defaultPort: 9200 },
easysearch: { type: "easysearch", profile: "easysearch", label: "Easysearch", defaultPort: 9200 },
meilisearch: { type: "meilisearch", profile: "meilisearch", label: "Meilisearch", defaultPort: 7700 },
solr: { type: "solr", profile: "solr", label: "Apache Solr", defaultPort: 8983 },
qdrant: { type: "qdrant", profile: "qdrant", label: "Qdrant", defaultPort: 6333 },
milvus: { type: "milvus", profile: "milvus", label: "Milvus", defaultPort: 19530 },
weaviate: { type: "weaviate", profile: "weaviate", label: "Weaviate", defaultPort: 8080 },
@@ -97,6 +98,7 @@ const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
elasticsearch: SCHEME_PROFILES.elasticsearch,
easysearch: SCHEME_PROFILES.easysearch,
meilisearch: SCHEME_PROFILES.meilisearch,
solr: SCHEME_PROFILES.solr,
qdrant: SCHEME_PROFILES.qdrant,
milvus: SCHEME_PROFILES.milvus,
weaviate: SCHEME_PROFILES.weaviate,
@@ -795,7 +797,7 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}),
username: jdbcCredentials?.username ?? decodeUrlPart(parsed.username),
password: jdbcCredentials?.password ?? decodeUrlPart(parsed.password),
database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch ? undefined : pathDatabase,
database: profile.type === "victoriametrics" ? "metrics" : profile.type === "dynamodb" ? dynamodbRegionFromHost(parsed.hostname) : isMeilisearch || profile.type === "solr" ? undefined : pathDatabase,
urlParams: effectiveUrlParams,
ssl: scheme === "rediss" || scheme === "https" || (redisPath?.tls ?? false) || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)),
...(profile.type === "victoriametrics" ? { apiPath: parsed.pathname.replace(/\/+$/, "") } : {}),
@@ -74,7 +74,7 @@ const STANDARD_URL_SCHEMES: Partial<Record<DatabaseType, string>> = {
};
/** Schemes that are plain HTTP(S) endpoints. */
const HTTP_URL_DB_TYPES = new Set<DatabaseType>(["elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]);
const HTTP_URL_DB_TYPES = new Set<DatabaseType>(["elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "rqlite", "consul", "victoriametrics", "influxdb", "influxdb3", "dynamodb"]);
/**
* Generic `prefix://host:port/db` JDBC dialects. Dialects with a different URL
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
// non-database services expose their own root objects rather than database namespaces.
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin"]);
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
@@ -25,7 +25,7 @@ export function isColumnNameCopySeparator(value: unknown): value is ColumnNameCo
}
// SQL 执行能力是产品能力,不代表数据库支持 SQL 标识符引用。
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "nacos", "victoriametrics"]);
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
@@ -1,4 +1,4 @@
import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
import { isElasticsearchCompatibleDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
/**
* Resolve the data type to display in a data-grid column header.
@@ -207,6 +207,13 @@ const INTEGER_COLUMN_TYPE_BASES = new Set([
// BYTE is binary), so they only apply to Elasticsearch-compatible databases.
const ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES = new Set(["byte", "short", "long"]);
// Solr schema field types: point/trie numerics and dates carry a `p`/`t`
// prefix, and analyzed text fields are named `text_*` — none of those names
// exist in the SQL type palette, so they resolve only under a Solr connection.
const SOLR_INTEGER_COLUMN_TYPE_BASES = new Set(["pint", "plong", "tint", "tlong", "sint", "slong"]);
const SOLR_NUMERIC_COLUMN_TYPE_BASES = new Set(["pfloat", "pdouble", "tfloat", "tdouble", "sfloat", "sdouble", "pcurrency", "currency"]);
const SOLR_TEMPORAL_COLUMN_TYPE_BASES = new Set(["pdate", "tdate"]);
const STRING_COLUMN_TYPE_BASES = new Set([
"varchar",
"varchar2",
@@ -300,8 +307,14 @@ export function resolveDataGridTypeVisualKind(dataType: string | undefined, data
if (array) return "structured";
if (databaseType === "sqlserver" && (base === "timestamp" || base === "rowversion")) return "binary";
if (databaseType === "postgres" && (base === "bit" || base === "bit varying")) return "binary";
if (isElasticsearchCompatibleDatabaseType(databaseType) && ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
if ((isElasticsearchCompatibleDatabaseType(databaseType) || isSolrDatabaseType(databaseType)) && ELASTICSEARCH_ONLY_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
if (INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
if (isSolrDatabaseType(databaseType)) {
if (SOLR_INTEGER_COLUMN_TYPE_BASES.has(base)) return "integer";
if (SOLR_NUMERIC_COLUMN_TYPE_BASES.has(base)) return "numeric";
if (SOLR_TEMPORAL_COLUMN_TYPE_BASES.has(base)) return "temporal";
if (base.startsWith("text")) return "string";
}
if (isNumericColumnType(dataType)) return "numeric";
if (BOOLEAN_COLUMN_TYPE_BASES.has(base)) return "boolean";
if (TEMPORAL_COLUMN_TYPE_BASES.has(base) || base.startsWith("timestamp_")) return "temporal";
@@ -1,6 +1,6 @@
import type { DatabaseType } from "@/types/database";
const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch", "meilisearch"]);
const NON_SQL_PREVIEW_DATABASE_TYPES: ReadonlySet<DatabaseType> = new Set<DatabaseType>(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr"]);
export function dataGridPreviewLabelKey(databaseType?: DatabaseType): "toolbar.previewQuery" | "toolbar.previewSql" {
return databaseType && NON_SQL_PREVIEW_DATABASE_TYPES.has(databaseType) ? "toolbar.previewQuery" : "toolbar.previewSql";
@@ -33,6 +33,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
elasticsearch: { deferred: "index creation is not modeled as database creation" },
easysearch: { deferred: "index creation is not modeled as database creation" },
meilisearch: { deferred: "index creation is not modeled as database creation" },
solr: { deferred: "core creation is not modeled as database creation" },
hbase: { deferred: "namespace creation needs dedicated HBase namespace options" },
qdrant: { deferred: "collection creation is separate from database creation" },
milvus: { deferred: "collection/database lifecycle needs a dedicated vector workflow" },
@@ -33,6 +33,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
elasticsearch: { deferred: "index settings are not database properties" },
easysearch: { deferred: "index settings are not database properties" },
meilisearch: { deferred: "index settings are not database properties" },
solr: { deferred: "core settings are not database properties" },
hbase: { deferred: "namespace and table properties need a dedicated HBase workflow" },
qdrant: { deferred: "collection settings are not database properties" },
milvus: { deferred: "collection/database settings need a dedicated vector workflow" },
@@ -110,6 +110,7 @@ const SQL_EXECUTION_TARGET_REGISTRY: Partial<Record<DatabaseType, SqlExecutionTa
elasticsearch: { scope: "connection" },
easysearch: { scope: "connection" },
meilisearch: { scope: "connection" },
solr: { scope: "connection" },
qdrant: { scope: "connection" },
milvus: { scope: "connection" },
weaviate: { scope: "connection" },
@@ -70,7 +70,7 @@ function decorationsForHints(hints: readonly InsertValueHint[]): DecorationSet {
* keep this list in sync instead of re-inlining it at call sites.
*/
export function supportsInsertValueHints(databaseType: string | undefined | null): boolean {
return databaseType !== "redis" && databaseType !== "mongodb" && databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "meilisearch" && databaseType !== "victoriametrics";
return databaseType !== "redis" && databaseType !== "mongodb" && databaseType !== "elasticsearch" && databaseType !== "easysearch" && databaseType !== "meilisearch" && databaseType !== "solr" && databaseType !== "victoriametrics";
}
export function buildInsertValueHintDecorations(hints: readonly InsertValueHint[]): DecorationSet {
@@ -1,4 +1,4 @@
import { isElasticsearchCompatibleDatabaseType, type DatabaseType, type QueryResult } from "@/types/database";
import { isElasticsearchCompatibleDatabaseType, isSolrDatabaseType, type DatabaseType, type QueryResult } from "@/types/database";
import { stripLeadingElasticsearchComments } from "@/lib/sql/sqlStatementRanges";
export interface ElasticsearchJsonResponse {
@@ -9,11 +9,12 @@ export interface ElasticsearchJsonResponse {
const ELASTICSEARCH_REST_STATEMENT = /^(?:GET|POST|PUT|DELETE|HEAD)\s+\S+/i;
/**
* Detect the raw HTTP result emitted for an Elasticsearch REST request.
* DBX asks unformatted CAT requests for JSON so they use this response panel.
* Detect the raw HTTP result emitted for a search-engine REST request.
* Elasticsearch asks unformatted CAT requests for JSON; Solr REST passthrough
* emits the same `status`/`response` shape, so both share this response panel.
*/
export function elasticsearchJsonResponseForResult(databaseType: DatabaseType | undefined, sourceStatement: string | undefined, result: QueryResult | undefined): ElasticsearchJsonResponse | undefined {
if (!isElasticsearchCompatibleDatabaseType(databaseType) || !result || typeof sourceStatement !== "string") return undefined;
if ((!isElasticsearchCompatibleDatabaseType(databaseType) && !isSolrDatabaseType(databaseType)) || !result || typeof sourceStatement !== "string") return undefined;
if (!ELASTICSEARCH_REST_STATEMENT.test(stripLeadingElasticsearchComments(sourceStatement))) return undefined;
if (result.columns.length !== 2 || result.columns[0] !== "status" || result.columns[1] !== "response" || result.rows.length !== 1) return undefined;
@@ -71,7 +71,7 @@ function orderSidebarTreeChildrenForParent(parent: Pick<TreeNode, "type">, child
const regularChildren = normalized.filter((child) => child.type !== "user-admin" && child.type !== "dameng-users" && child.type !== "dameng-roles" && child.type !== "saved-sql-root");
const withConnectionUtilityOrder = (children: TreeNode[]) => [...savedSqlNodes, ...children, ...userAdminNodes];
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
if (databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "easysearch" || databaseType === "meilisearch" || databaseType === "solr" || databaseType === "qdrant" || databaseType === "milvus" || databaseType === "weaviate" || databaseType === "chromadb") {
const meilisearchSystem = regularChildren.filter((child) => child.type === "meilisearch-system");
const databaseObjects = regularChildren.filter((child) => child.type !== "meilisearch-system");
return withConnectionUtilityOrder([...sortByLabel(databaseObjects), ...meilisearchSystem]);
+572
View File
@@ -0,0 +1,572 @@
import { executeQuery } from "@/lib/backend/api";
import { parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
/**
* Solr Admin 面板的数据层:所有请求复用 REST 控制台链路
* (execute_query → solr_driver::execute_rest_query),后端零新增命令。
* 非表格化 JSON 响应由驱动包装为 [status, response] 单行返回;
* /response/docs 形态走 elasticsearch_raw_body 透出原文。
*/
export interface SolrAdminResponse {
status: number;
/** 解析后的 JSON;非 JSON 时为原始文本;空响应为 null。 */
body: unknown;
rawBody: string;
elapsedMs: number;
}
export type SolrAdminMethod = "GET" | "POST" | "PUT" | "DELETE" | "HEAD";
export async function solrAdminRequest(connectionId: string, method: SolrAdminMethod, path: string, body?: string): Promise<SolrAdminResponse> {
const sql = body ? `${method} ${path}\n${body}` : `${method} ${path}`;
const result = await executeQuery(connectionId, "", sql);
const status = typeof result.rows?.[0]?.[0] === "number" ? result.rows[0][0] : 200;
const rawBody = result.elasticsearch_raw_body ?? (typeof result.rows?.[0]?.[1] === "string" ? result.rows[0][1] : "");
let parsed: unknown = null;
if (rawBody) {
try {
parsed = parseJsonPreservingLargeNumbers(rawBody);
} catch {
parsed = rawBody;
}
}
return { status, body: parsed, rawBody, elapsedMs: result.execution_time_ms };
}
export function solrAdminGet(connectionId: string, path: string): Promise<SolrAdminResponse> {
return solrAdminRequest(connectionId, "GET", path);
}
/** Solr 错误体 {"error":{"msg","code"}} → 可读消息;用于写操作的状态判定。 */
export function solrAdminErrorMessage(res: SolrAdminResponse): string | null {
if (res.status < 400) return null;
const body = res.body as Record<string, unknown> | null;
const msg = body?.error && typeof body.error === "object" ? (body.error as Record<string, unknown>).msg : undefined;
return `Solr error (${res.status}): ${typeof msg === "string" ? msg : res.rawBody.slice(0, 500)}`;
}
/** 服务端点(server scope)与 core 端点(core scope,{core} 占位)。 */
export interface SolrAdminViewDef {
id: SolrAdminViewId;
scope: "server" | "core";
/** REST 路径模板;core scope 中 {core} 会被替换。 */
path: string;
/** 需要专用渲染而非通用 JSON 树。 */
view: "generic" | "dashboard" | "properties" | "logging" | "coreAdmin" | "overview" | "ping" | "schema" | "analysis" | "segments" | "queryForm" | "replication" | "action";
}
export type SolrAdminViewId = "dashboard" | "logging" | "javaProps" | "threads" | "coreAdmin" | "metrics" | "overview" | "analysis" | "documents" | "paramsets" | "files" | "ping" | "plugins" | "query" | "replication" | "schema" | "segments";
export const SOLR_ADMIN_SERVER_VIEWS: readonly SolrAdminViewDef[] = [
{ id: "dashboard", scope: "server", path: "/admin/info/system", view: "dashboard" },
{ id: "logging", scope: "server", path: "/admin/info/logging", view: "logging" },
{ id: "javaProps", scope: "server", path: "/admin/info/properties", view: "properties" },
{ id: "threads", scope: "server", path: "/admin/info/threads", view: "generic" },
{ id: "coreAdmin", scope: "server", path: "/admin/cores?action=STATUS", view: "coreAdmin" },
{ id: "metrics", scope: "server", path: "/admin/metrics", view: "generic" },
];
export const SOLR_ADMIN_CORE_VIEWS: readonly SolrAdminViewDef[] = [
{ id: "overview", scope: "core", path: "/{core}/admin/luke?show=index", view: "overview" },
{ id: "analysis", scope: "core", path: "/{core}/analysis/field", view: "analysis" },
{ id: "documents", scope: "core", path: "", view: "action" },
{ id: "paramsets", scope: "core", path: "/{core}/config/params", view: "generic" },
{ id: "files", scope: "core", path: "/{core}/admin/file", view: "generic" },
{ id: "ping", scope: "core", path: "/{core}/admin/ping", view: "ping" },
{ id: "plugins", scope: "core", path: "/{core}/admin/mbeans?stats=true", view: "generic" },
{ id: "query", scope: "core", path: "", view: "queryForm" },
{ id: "replication", scope: "core", path: "/{core}/replication?command=details", view: "replication" },
{ id: "schema", scope: "core", path: "/{core}/schema", view: "schema" },
{ id: "segments", scope: "core", path: "/{core}/admin/segments", view: "segments" },
];
export function solrAdminViewById(id: SolrAdminViewId): SolrAdminViewDef | undefined {
return [...SOLR_ADMIN_SERVER_VIEWS, ...SOLR_ADMIN_CORE_VIEWS].find((view) => view.id === id);
}
export function solrAdminPath(view: SolrAdminViewDef, core: string): string {
return view.path.replace("{core}", encodeURIComponent(core));
}
/* ---------- Core Admin ---------- */
export interface SolrCoreStatus {
name: string;
instanceDir?: string;
dataDir?: string;
config?: string;
schema?: string;
startTime?: string;
uptime?: number;
numDocs?: number;
maxDoc?: number;
deletedDocs?: number;
sizeInBytes?: number;
version?: number;
segmentCount?: number;
}
export function parseCoreStatus(body: unknown): SolrCoreStatus[] {
const status = (body as Record<string, unknown> | null)?.status;
if (!status || typeof status !== "object" || Array.isArray(status)) return [];
return Object.entries(status as Record<string, Record<string, unknown>>).map(([name, info]) => {
const index = (info?.index ?? {}) as Record<string, unknown>;
return {
name: typeof info?.name === "string" ? info.name : name,
instanceDir: info?.instanceDir as string | undefined,
dataDir: info?.dataDir as string | undefined,
config: info?.config as string | undefined,
schema: info?.schema as string | undefined,
startTime: info?.startTime as string | undefined,
uptime: typeof info?.uptime === "number" ? info.uptime : undefined,
numDocs: typeof index.numDocs === "number" ? index.numDocs : undefined,
maxDoc: typeof index.maxDoc === "number" ? index.maxDoc : undefined,
deletedDocs: typeof index.deletedDocs === "number" ? index.deletedDocs : undefined,
sizeInBytes: typeof index.sizeInBytes === "number" ? index.sizeInBytes : typeof index.size === "number" ? index.size : undefined,
version: typeof index.version === "number" ? index.version : undefined,
segmentCount: typeof index.segmentCount === "number" ? index.segmentCount : undefined,
};
});
}
export type SolrCoreAction = "CREATE" | "RELOAD" | "UNLOAD" | "RENAME" | "SWAP";
/** CoreAdmin 写操作统一走 GET /admin/cores(Solr 官方形态),参数全部由 UI 收集。 */
export function solrCoreAdminRequest(connectionId: string, params: Record<string, string>): Promise<SolrAdminResponse> {
const query = new URLSearchParams(params).toString();
return solrAdminRequest(connectionId, "GET", `/admin/cores?${query}`);
}
/* ---------- Schema ---------- */
export interface SolrSchemaField {
name: string;
type?: string;
stored?: boolean;
indexed?: boolean;
required?: boolean;
multiValued?: boolean;
docValues?: boolean;
uninvertible?: boolean;
}
export interface SolrSchemaInfo {
name?: string;
version?: string;
uniqueKey?: string;
fields: SolrSchemaField[];
dynamicFields: SolrSchemaField[];
copyFields: Array<{ source: string; dest: string; maxChars?: number }>;
fieldTypeCount: number;
}
export function parseSolrSchema(body: unknown): SolrSchemaInfo {
const root = (body as Record<string, unknown> | null)?.schema ?? body;
const schema = (root ?? {}) as Record<string, unknown>;
const fields = Array.isArray(schema.fields) ? (schema.fields as SolrSchemaField[]) : [];
const dynamicFields = Array.isArray(schema.dynamicFields) ? (schema.dynamicFields as SolrSchemaField[]) : [];
const copyFields = Array.isArray(schema.copyFields) ? (schema.copyFields as Array<{ source: string; dest: string; maxChars?: number }>) : [];
const fieldTypes = Array.isArray(schema.fieldTypes) ? schema.fieldTypes : [];
return {
name: schema.name as string | undefined,
version: schema.version != null ? String(schema.version) : undefined,
uniqueKey: schema.uniqueKey as string | undefined,
fields,
dynamicFields,
copyFields,
fieldTypeCount: fieldTypes.length,
};
}
/* ---------- Analysis ---------- */
export interface SolrAnalysisStage {
/** 分析器类名,如 org.apache.lucene.analysis.standard.StandardTokenizer */
name: string;
tokens: string[];
}
export interface SolrAnalysisResult {
/** index 侧阶段链 */
index: SolrAnalysisStage[];
/** query 侧阶段链 */
query: SolrAnalysisStage[];
}
/**
* /analysis/field 响应:analysis.field_names.{f}.index 是
* [ [stageName, [tokenObj...]], ... ] 交替数组;query 侧结构相同。
*/
export function parseAnalysisResponse(body: unknown, fieldName: string): SolrAnalysisResult {
const analysis = (body as Record<string, unknown> | null)?.analysis as Record<string, unknown> | undefined;
const fieldNames = analysis?.field_names as Record<string, unknown> | undefined;
const entry = (fieldName ? fieldNames?.[fieldName] : undefined) ?? (fieldNames ? Object.values(fieldNames)[0] : undefined);
const stages = (entry ?? {}) as Record<string, unknown>;
const parseStages = (raw: unknown): SolrAnalysisStage[] => {
if (!Array.isArray(raw)) return [];
const stages: SolrAnalysisStage[] = [];
for (const item of raw) {
if (!Array.isArray(item) || item.length < 2) continue;
const name = typeof item[0] === "string" ? item[0] : String(item[0]);
const tokenObjs = Array.isArray(item[1]) ? item[1] : [];
const tokens = tokenObjs.map((token) => {
if (token && typeof token === "object" && !Array.isArray(token)) {
const text = (token as Record<string, unknown>).text;
return text != null ? String(text) : JSON.stringify(token);
}
return String(token);
});
stages.push({ name, tokens });
}
return stages;
};
return { index: parseStages(stages.index), query: parseStages(stages.query) };
}
export function solrAnalysisPath(core: string, fieldName: string, fieldValue: string, queryValue?: string): string {
const params = new URLSearchParams();
params.set("analysis.fieldname", fieldName);
params.set("analysis.fieldvalue", fieldValue);
if (queryValue) params.set("analysis.query", queryValue);
return `/${encodeURIComponent(core)}/analysis/field?${params.toString()}`;
}
/* ---------- Query Builder(对标官方 Admin UI Query 表单) ---------- */
export interface SolrQueryParam {
key: string;
value: string;
}
export interface SolrQueryFormState {
/** Request-Handler,如 /select、/query */
handler: string;
q: string;
qop: "" | "AND" | "OR";
fq: string[];
sort: string;
start: string;
rows: string;
fl: string;
df: string;
/** paramset 名(useParams 参数,逗号拼接) */
useParams: string[];
wt: string;
indent: boolean;
debugQuery: boolean;
defType: string;
hl: { enabled: boolean; fl: string; snippets: string; fragsize: string; simplePre: string; simplePost: string; requireFieldMatch: boolean; mergeContiguous: boolean };
facet: { enabled: boolean; queries: string[]; fields: string[]; prefix: string; sort: string; limit: string; offset: string; mincount: string; missing: boolean };
spatial: { enabled: boolean; pt: string; sfield: string; d: string; geofilt: boolean; bbox: boolean };
spellcheck: { enabled: boolean; q: string; build: boolean; collate: boolean; count: string; dictionary: string; onlyMorePopular: boolean };
rawParams: SolrQueryParam[];
/** 非空时改用 JSON Request DSL:POST {handler} + body,与参数表单互斥 */
jsonQuery: string;
}
export function defaultSolrQueryForm(): SolrQueryFormState {
return {
handler: "/select",
q: "*:*",
qop: "",
fq: [],
sort: "",
start: "0",
rows: "10",
fl: "",
df: "",
useParams: [],
wt: "json",
indent: true,
debugQuery: false,
defType: "",
hl: { enabled: false, fl: "", snippets: "", fragsize: "", simplePre: "", simplePost: "", requireFieldMatch: false, mergeContiguous: false },
facet: { enabled: false, queries: [], fields: [], prefix: "", sort: "", limit: "", offset: "", mincount: "", missing: false },
spatial: { enabled: false, pt: "", sfield: "", d: "", geofilt: false, bbox: false },
spellcheck: { enabled: false, q: "", build: false, collate: false, count: "", dictionary: "", onlyMorePopular: false },
rawParams: [],
jsonQuery: "",
};
}
export interface SolrBuiltQuery {
/** REST 控制台可执行文本:"GET /path?qs" 或 "POST /path\n{json}" */
text: string;
jsonMode: boolean;
}
const nonEmpty = (v: string) => v.trim() !== "";
/** 表单 → REST 文本。重复参数(fq/facet.field…)经 URLSearchParams.append 保留多值。 */
export function buildSolrQuery(core: string, state: SolrQueryFormState): SolrBuiltQuery {
const handler = state.handler.trim() || "/select";
const base = `/${encodeURIComponent(core)}${handler.startsWith("/") ? handler : `/${handler}`}`;
if (nonEmpty(state.jsonQuery)) {
return { text: `POST ${base}\n${state.jsonQuery.trim()}`, jsonMode: true };
}
const params = new URLSearchParams();
const set = (key: string, value: string | undefined) => {
if (value != null && nonEmpty(value)) params.set(key, value.trim());
};
const appendEach = (key: string, values: string[]) => {
for (const v of values) if (nonEmpty(v)) params.append(key, v.trim());
};
set("q", state.q || "*:*");
set("q.op", state.qop);
appendEach("fq", state.fq);
set("sort", state.sort);
set("start", state.start);
set("rows", state.rows);
set("fl", state.fl);
set("df", state.df);
if (state.useParams.length) params.set("useParams", state.useParams.join(","));
set("wt", state.wt);
if (state.indent) params.set("indent", "on");
if (state.debugQuery) params.set("debugQuery", "true");
set("defType", state.defType);
const hl = state.hl;
if (hl.enabled) {
params.set("hl", "true");
set("hl.fl", hl.fl);
set("hl.snippets", hl.snippets);
set("hl.fragsize", hl.fragsize);
set("hl.simple.pre", hl.simplePre);
set("hl.simple.post", hl.simplePost);
if (hl.requireFieldMatch) params.set("hl.requireFieldMatch", "true");
if (hl.mergeContiguous) params.set("hl.mergeContiguous", "true");
}
const facet = state.facet;
if (facet.enabled) {
params.set("facet", "true");
appendEach("facet.query", facet.queries);
appendEach("facet.field", facet.fields);
set("facet.prefix", facet.prefix);
set("facet.sort", facet.sort);
set("facet.limit", facet.limit);
set("facet.offset", facet.offset);
set("facet.mincount", facet.mincount);
if (facet.missing) params.set("facet.missing", "true");
}
const spatial = state.spatial;
if (spatial.enabled) {
set("pt", spatial.pt);
set("sfield", spatial.sfield);
set("d", spatial.d);
// 官方 UI 的 geofilt/bbox 以 fq 函数查询注入
if (spatial.geofilt) params.append("fq", "{!geofilt}");
if (spatial.bbox) params.append("fq", "{!bbox}");
}
const sc = state.spellcheck;
if (sc.enabled) {
params.set("spellcheck", "true");
set("spellcheck.q", sc.q);
if (sc.build) params.set("spellcheck.build", "true");
if (sc.collate) params.set("spellcheck.collate", "true");
set("spellcheck.count", sc.count);
set("spellcheck.dictionary", sc.dictionary);
if (sc.onlyMorePopular) params.set("spellcheck.onlyMorePopular", "true");
}
for (const rp of state.rawParams) {
if (nonEmpty(rp.key) && nonEmpty(rp.value)) params.append(rp.key.trim(), rp.value.trim());
}
return { text: `GET ${base}?${params.toString()}`, jsonMode: false };
}
/** /{core}/config/params 响应 → paramset 名列表,供 useParams 选择。 */
export function parseParamsetNames(body: unknown): string[] {
const params = (body as Record<string, unknown> | null)?.response as Record<string, unknown> | undefined;
const named = params?.params;
if (!named || typeof named !== "object" || Array.isArray(named)) return [];
return Object.keys(named as Record<string, unknown>).sort();
}
/** select 响应体 → numFound;非 select/出错为 null。 */
export function parseSolrNumFound(rawBody: string): number | null {
try {
const body = parseJsonPreservingLargeNumbers(rawBody) as Record<string, unknown> | null;
const num = (body?.response as Record<string, unknown> | undefined)?.numFound;
return typeof num === "number" ? num : null;
} catch {
return null;
}
}
/* ---------- Overview(对标官方 UI:Statistics / Instance / Replication / Healthcheck 组合) ---------- */
/** luke ?show=index 的 index 块 → Statistics 行 */
export interface SolrIndexStats {
lastModified?: string;
numDocs?: number;
maxDoc?: number;
deletedDocs?: number;
version?: number;
segmentCount?: number;
current?: boolean;
/** Lucene Directory 实现类(org.apache.lucene.store.NRTCachingDirectory 等) */
directoryImpl?: string;
}
export function parseIndexStats(body: unknown): SolrIndexStats {
const index = (body as Record<string, unknown> | null)?.index as Record<string, unknown> | undefined;
if (!index) return {};
const directory = typeof index.directory === "string" ? index.directory : undefined;
return {
lastModified: index.lastModified as string | undefined,
numDocs: typeof index.numDocs === "number" ? index.numDocs : undefined,
maxDoc: typeof index.maxDoc === "number" ? index.maxDoc : undefined,
deletedDocs: typeof index.deletedDocs === "number" ? index.deletedDocs : undefined,
version: typeof index.version === "number" ? index.version : undefined,
segmentCount: typeof index.segmentCount === "number" ? index.segmentCount : undefined,
current: typeof index.current === "boolean" ? index.current : undefined,
directoryImpl: directory?.split(":")[0],
};
}
/** replication?command=details 的 details 块 */
export interface SolrReplicationDetails {
isLeader: boolean;
isFollower: boolean;
indexSize?: string;
indexPath?: string;
indexVersion?: string;
generation?: number;
replicableVersion?: number;
replicableGeneration?: number;
replicationEnabled?: boolean;
replicateAfter: string[];
/** follower 侧信息(isLeader=false 时展示) */
follower?: Record<string, unknown>;
}
export function parseReplicationDetails(body: unknown): SolrReplicationDetails | null {
const details = (body as Record<string, unknown> | null)?.details as Record<string, unknown> | undefined;
if (!details) return null;
const leader = (details.leader ?? {}) as Record<string, unknown>;
const follower = details.follower as Record<string, unknown> | undefined;
const num = (v: unknown) => (typeof v === "number" ? v : v != null ? Number(v) : undefined);
return {
isLeader: details.isLeader === true || details.isLeader === "true",
isFollower: details.isFollower === true || details.isFollower === "true",
indexSize: details.indexSize as string | undefined,
indexPath: details.indexPath as string | undefined,
indexVersion: details.indexVersion != null ? String(details.indexVersion) : undefined,
generation: num(details.generation),
replicableVersion: num(leader.replicableVersion),
replicableGeneration: num(leader.replicableGeneration),
replicationEnabled: leader.replicationEnabled === true || leader.replicationEnabled === "true",
replicateAfter: Array.isArray(leader.replicateAfter) ? leader.replicateAfter.map(String) : [],
follower,
};
}
/** leader 侧启停复制:/{core}/replication?command=enablereplication|disablereplication */
export function solrReplicationCommand(connectionId: string, core: string, command: "enablereplication" | "disablereplication"): Promise<SolrAdminResponse> {
return solrAdminRequest(connectionId, "GET", `/${encodeURIComponent(core)}/replication?command=${command}`);
}
/** /admin/info/properties 响应 → JVM 工作目录(官方 Instance 框的 CWD)。 */
export function parseServerCwd(body: unknown): string | undefined {
const props = (body as Record<string, unknown> | null)?.["system.properties"] as Record<string, unknown> | undefined;
return typeof props?.["user.dir"] === "string" ? props["user.dir"] : undefined;
}
/* ---------- Dashboard(官方布局:Instance / Versions / JVM + System 进度条) ---------- */
export interface SolrSystemInfo {
mode?: string;
solrHome?: string;
coreRoot?: string;
/** jvm.jmx.startTime(ISO) */
startTime?: string;
uptimeMs?: number;
solrSpec?: string;
solrImpl?: string;
luceneSpec?: string;
luceneImpl?: string;
/** name + version 拼接,如 "Eclipse Adoptium OpenJDK 64-Bit Server VM 17.0.15 17.0.15+6" */
jvmRuntime?: string;
processors?: number;
args: string[];
jvmMemUsedPct?: number;
jvmMemUsed?: string;
jvmMemTotal?: string;
/** 物理内存:bytes */
physMemUsedPct?: number;
physMemUsed?: number;
physMemTotal?: number;
swapUsedPct?: number;
swapUsed?: number;
swapTotal?: number;
fdUsed?: number;
fdMax?: number;
}
export function parseSolrSystemInfo(body: unknown): SolrSystemInfo {
const root = (body ?? {}) as Record<string, unknown>;
const jvm = (root.jvm ?? {}) as Record<string, unknown>;
const jmx = (jvm.jmx ?? {}) as Record<string, unknown>;
const lucene = (root.lucene ?? {}) as Record<string, unknown>;
const system = (root.system ?? {}) as Record<string, unknown>;
const memory = (jvm.memory ?? {}) as Record<string, unknown>;
const raw = (memory.raw ?? {}) as Record<string, unknown>;
const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
const str = (v: unknown): string | undefined => (typeof v === "string" && v !== "" ? v : undefined);
const pct = (used: number | undefined, total: number | undefined) => (used != null && total != null && total > 0 ? (used / total) * 100 : undefined);
const physTotal = num(system.totalPhysicalMemorySize);
const physFree = num(system.freePhysicalMemorySize);
const physUsed = physTotal != null && physFree != null ? physTotal - physFree : undefined;
const swapTotal = num(system.totalSwapSpaceSize);
const swapFree = num(system.freeSwapSpaceSize);
const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : undefined;
const jvmName = str(jvm.name);
const jvmVersion = str(jvm.version);
return {
mode: str(root.mode),
solrHome: str(root.solr_home),
coreRoot: str(root.core_root),
startTime: str(jmx.startTime),
uptimeMs: num(jmx.upTimeMS),
solrSpec: str(lucene["solr-spec-version"]),
solrImpl: str(lucene["solr-impl-version"]),
luceneSpec: str(lucene["lucene-spec-version"]),
luceneImpl: str(lucene["lucene-impl-version"]),
jvmRuntime: [jvmName, jvmVersion].filter(Boolean).join(" ") || undefined,
processors: num(jvm.processors),
args: Array.isArray(jmx.commandLineArgs) ? jmx.commandLineArgs.map(String) : [],
jvmMemUsedPct: num(raw["used%"]),
jvmMemUsed: str(memory.used),
jvmMemTotal: str(memory.total),
physMemUsedPct: pct(physUsed, physTotal),
physMemUsed: physUsed,
physMemTotal: physTotal,
swapUsedPct: pct(swapUsed, swapTotal),
swapUsed,
swapTotal,
fdUsed: num(system.openFileDescriptorCount),
fdMax: num(system.maxFileDescriptorCount),
};
}
/** solr-impl-version 里带构建日期("9.8.1 dab835... - houston - 2025-03-06 13:59:17"),>1 年提示升级。 */
export function solrReleaseAgeDays(implVersion?: string): number | null {
if (!implVersion) return null;
const match = implVersion.match(/(\d{4})-(\d{2})-(\d{2})/);
if (!match) return null;
const ts = Date.parse(`${match[1]}-${match[2]}-${match[3]}T00:00:00Z`);
if (!Number.isFinite(ts)) return null;
return Math.floor((Date.now() - ts) / 86400000);
}
@@ -0,0 +1,173 @@
/**
* Read/write classification for Apache Solr REST requests.
*
* Mirrors `classify_solr_query_risk` in `crates/dbx-sql/src/query_execution_sql.rs`
* so the desktop guards (read-only unlock, production safety) agree with the
* backend read-only gate instead of treating every request as unrecognized.
*
* Solr write traffic funnels through `/{core}/update*` (plus `commit`) — for
* every HTTP method, since Solr update handlers also answer GET commits.
* CoreAdmin/Collections actions and ReplicationHandler commands ride on GET
* too, so `action=`/`command=` values outside the read whitelists are
* dangerous. Read-only POST handlers are the query
* endpoints (select/query/get/export/...). Any other mutating method/path —
* `/admin/*`, `/schema`, `/config`, core management — is classified dangerous.
*/
export type SolrRequestRisk = "read" | "write" | "dangerous";
const REQUEST_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)/i;
const READ_ONLY_POST_ENDPOINTS = new Set(["select", "query", "get", "export", "terms", "suggest", "spell", "mlt", "sql", "graph", "clustering", "tvrh", "luke", "elevate", "browse", "debug"]);
const READ_ONLY_REPLICATION_COMMANDS = new Set(["details", "restorestatus", "filelist", "filecontent", "filedownload", "filemtime", "indexversion", "showversion"]);
const RISK_ORDER: Record<SolrRequestRisk, number> = { read: 0, write: 1, dangerous: 2 };
function parseRequestLine(line: string): { method: string; rawPath: string; endpoint: string; isUpdatePath: boolean; segments: string[] } | null {
const match = line.trim().match(REQUEST_LINE);
if (!match) return null;
const rawPath = match[2];
const path = (rawPath.split("?", 1)[0] ?? "").replace(/\/+$/, "");
const segments = path.split("/").filter(Boolean);
// Users may paste absolute Solr paths ("/solr/{core}/select") or relative
// ones ("{core}/select"); drop the fixed /solr context segment if present.
if (segments[0]?.toLowerCase() === "solr") segments.shift();
// Update handler variants (/update/json, /update/json/docs, /update/csv)
// nest below the update segment, so the last segment alone cannot spot them.
const isUpdatePath = segments.some((segment) => segment.startsWith("update") || segment === "commit");
return { method: match[1].toUpperCase(), rawPath, endpoint: segments[segments.length - 1]?.toLowerCase() ?? "", isUpdatePath, segments };
}
/**
* CoreAdmin/Collections endpoints accept GET-triggered mutations
* (`GET /admin/cores?action=CREATE&name=x` really creates a core). Only the
* explicitly read-only actions stay "read"; any other action is dangerous.
*/
function solrAdminRequestIsMutation(rawPath: string, segments: string[]): boolean {
const isAdminTarget = segments.some((segment, index) => segment.toLowerCase() === "admin" && ["cores", "collections"].includes(segments[index + 1]?.toLowerCase() ?? ""));
if (!isAdminTarget) return false;
const query = rawPath.split("?")[1];
if (!query) return false;
const action = query
.split("&")
.map((param) => param.split("=", 2))
.find(([key]) => key.toLowerCase() === "action")?.[1];
if (action === undefined) return false;
return !["STATUS", "REQUESTSTATUS", "LIST"].includes(action.toUpperCase());
}
/**
* The replication handler also takes GET-triggered mutations
* (`GET /{core}/replication?command=disablereplication` really stops
* replication). Only the explicitly read-only commands stay "read"; a missing
* command falls back to the handler's default details response.
*/
function solrReplicationRequestIsMutation(rawPath: string, segments: string[]): boolean {
if (segments[segments.length - 1]?.toLowerCase() !== "replication") return false;
const query = rawPath.split("?")[1];
if (!query) return false;
const command = query
.split("&")
.map((param) => param.split("=", 2))
.find(([key]) => key.toLowerCase() === "command")?.[1];
if (command === undefined) return false;
return !READ_ONLY_REPLICATION_COMMANDS.has(command.toLowerCase());
}
function classifyParsedRequest({ method, rawPath, endpoint, isUpdatePath, segments }: { method: string; rawPath: string; endpoint: string; isUpdatePath: boolean; segments: string[] }): SolrRequestRisk {
// Solr update handlers also answer GET (commit/optimize/stream.body all
// mutate), so update paths count as writes for every method, not just POST.
if (isUpdatePath) return "write";
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
return solrAdminRequestIsMutation(rawPath, segments) || solrReplicationRequestIsMutation(rawPath, segments) ? "dangerous" : "read";
}
if (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE") {
return method === "POST" && READ_ONLY_POST_ENDPOINTS.has(endpoint) ? "read" : "dangerous";
}
return "dangerous";
}
/**
* Classifies a single request: the request line is the first non-comment line,
* everything after it is the JSON body. Returns null when the text is not a
* REST request.
*/
export function classifySolrRequestRisk(request: string): SolrRequestRisk | null {
const parsed = parseRequestLine(uncommentedLines(request)[0] ?? "");
return parsed ? classifyParsedRequest(parsed) : null;
}
/**
* Classifies every request in the editor text and returns the highest risk, so
* a read request followed by a write is still guarded. Like the backend, the
* text only counts as REST when its first non-comment line is a request line.
*/
export function classifySolrSourceRisk(source: string): SolrRequestRisk | null {
const lines = uncommentedLines(source);
if (!parseRequestLine(lines[0] ?? "")) return null;
let highest: SolrRequestRisk = "read";
for (const line of lines) {
const parsed = parseRequestLine(line);
if (!parsed) continue;
const risk = classifyParsedRequest(parsed);
if (RISK_ORDER[risk] > RISK_ORDER[highest]) highest = risk;
}
return highest;
}
/**
* Whether a parsed request line targets a mutating path that is not a document
* update — used by the dangerous-execution confirmation gate.
*/
export function isDangerousSolrRequest(method: string, path: string): boolean {
const upperMethod = method.toUpperCase();
const pathname = path.split("?", 1)[0].replace(/\/+$/, "");
const segments = pathname.split("/").filter(Boolean);
if (segments[0]?.toLowerCase() === "solr") segments.shift();
const endpoint = segments[segments.length - 1]?.toLowerCase() ?? "";
const isUpdatePath = segments.some((segment) => segment.startsWith("update") || segment === "commit");
// Document writes are guarded by the write confirmation, not the dangerous
// gate — for every method, including GET-driven commits.
if (isUpdatePath) return false;
if (upperMethod === "GET" || upperMethod === "HEAD" || upperMethod === "OPTIONS") {
return solrAdminRequestIsMutation(path, segments) || solrReplicationRequestIsMutation(path, segments);
}
if (upperMethod === "POST" || upperMethod === "PUT" || upperMethod === "PATCH" || upperMethod === "DELETE") {
return !(upperMethod === "POST" && READ_ONLY_POST_ENDPOINTS.has(endpoint));
}
return true;
}
/**
* Returns each line with its leading comments removed, dropping lines that hold
* nothing else. Block comments spanning several lines are skipped as a whole so
* a commented-out request line is never classified.
*/
function uncommentedLines(source: string): string[] {
const lines: string[] = [];
let inBlockComment = false;
for (const rawLine of source.split("\n")) {
let offset = 0;
while (offset < rawLine.length) {
if (inBlockComment) {
const close = rawLine.indexOf("*/", offset);
if (close < 0) break;
inBlockComment = false;
offset = close + 2;
continue;
}
while (offset < rawLine.length && /\s/.test(rawLine[offset] ?? "")) offset += 1;
if (offset >= rawLine.length) break;
if (rawLine.startsWith("/*", offset)) {
inBlockComment = true;
offset += 2;
continue;
}
if (rawLine[offset] === "#" || rawLine.startsWith("//", offset)) break;
lines.push(rawLine.slice(offset));
break;
}
}
return lines;
}
@@ -461,6 +461,7 @@ export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number, opt
options.databaseType === "elasticsearch" ||
options.databaseType === "easysearch" ||
options.databaseType === "meilisearch" ||
options.databaseType === "solr" ||
options.databaseType === "qdrant" ||
options.databaseType === "milvus" ||
options.databaseType === "weaviate" ||
@@ -481,6 +482,7 @@ export function isSqlSemanticDiagnosticInputContext(sql: string, cursor: number,
options.databaseType === "elasticsearch" ||
options.databaseType === "easysearch" ||
options.databaseType === "meilisearch" ||
options.databaseType === "solr" ||
options.databaseType === "qdrant" ||
options.databaseType === "milvus" ||
options.databaseType === "weaviate" ||
+9 -6
View File
@@ -1,6 +1,7 @@
import { classifyElasticsearchRequestRisk, classifyElasticsearchSourceRisk, type ElasticsearchRequestRisk } from "@/lib/elasticsearch/elasticsearchRequestRisk";
import { classifySolrRequestRisk, classifySolrSourceRisk } from "@/lib/solr/solrRequestRisk";
import { mongoAggregateWriteStage, splitMongoCommandRanges, type MongoCommand } from "@/lib/mongo/mongoShellCommand";
import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
import { isElasticsearchCompatibleDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
@@ -54,7 +55,7 @@ export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlR
// REST requests carry a JSON body that must not be split on semicolons, and
// every request in the text is classified so the highest risk wins.
const searchEngineRisk = searchEngineAssessment(sql, options.dialect, classifyElasticsearchSourceRisk);
const searchEngineRisk = searchEngineAssessment(sql, options.dialect, { elasticsearch: classifyElasticsearchSourceRisk, solr: classifySolrSourceRisk });
if (searchEngineRisk) return { ...searchEngineRisk, statements: [searchEngineRisk] };
const statements = splitSqlStatementsForSafety(sql).map((statement) => classifySqlStatementRisk(statement, options));
@@ -69,7 +70,7 @@ export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions =
const dynamodbRisk = classifyDynamoDbStatementRisk(sql, options.dialect);
if (dynamodbRisk) return dynamodbRisk;
const searchEngineRisk = searchEngineAssessment(sql, options.dialect, classifyElasticsearchRequestRisk);
const searchEngineRisk = searchEngineAssessment(sql, options.dialect, { elasticsearch: classifyElasticsearchRequestRisk, solr: classifySolrRequestRisk });
if (searchEngineRisk) return searchEngineRisk;
return classifyTokens(tokenizeSqlForRisk(sql));
}
@@ -85,9 +86,11 @@ export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions =
* is a schema change, ...), which a request path does not carry. Reporting
* `rest` keeps REST mutations as opaque as they were before this branch existed.
*/
function searchEngineAssessment(sql: string, dialect: DatabaseType | string | undefined, classify: (value: string) => ElasticsearchRequestRisk | null): SqlRiskStatementAssessment | null {
if (!isElasticsearchCompatibleDatabaseType(dialect as DatabaseType | undefined)) return null;
const risk = classify(sql);
function searchEngineAssessment(sql: string, dialect: DatabaseType | string | undefined, classify: { elasticsearch: (value: string) => ElasticsearchRequestRisk | null; solr: (value: string) => ElasticsearchRequestRisk | null }): SqlRiskStatementAssessment | null {
const databaseType = dialect as DatabaseType | undefined;
const classifier = isSolrDatabaseType(databaseType) ? classify.solr : isElasticsearchCompatibleDatabaseType(databaseType) ? classify.elasticsearch : null;
if (!classifier) return null;
const risk = classifier(sql);
if (!risk) return null;
return { risk: risk === "read" ? "read" : risk === "write" ? "write" : "ddl", firstKeyword: "rest" };
}
@@ -3,7 +3,7 @@ import { cursorBelongsToTrailingStatementDelimiter } from "@/lib/sql/statementDe
import { splitMongoCommandRanges } from "@/lib/mongo/mongoShellCommand";
import { isRedisCommentLine } from "@/lib/redis/redisCommandTokenizer";
import { readSqlBracedParameterAt, type SqlParameterOptions } from "@/lib/sql/sqlParameters";
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, type DatabaseType } from "@/types/database";
import { isElasticsearchCompatibleDatabaseType, isMeilisearchDatabaseType, isSolrDatabaseType, type DatabaseType } from "@/types/database";
/**
* A contiguous range of SQL text expressed as document offsets plus the
@@ -18,7 +18,7 @@ export interface SqlTextRange {
const ELASTICSEARCH_REST_REQUEST = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD)\s+\S+/i;
function isHttpJsonRestDatabaseType(databaseType?: DatabaseType): boolean {
return isElasticsearchCompatibleDatabaseType(databaseType) || isMeilisearchDatabaseType(databaseType);
return isElasticsearchCompatibleDatabaseType(databaseType) || isMeilisearchDatabaseType(databaseType) || isSolrDatabaseType(databaseType);
}
export function elasticsearchRestRequestRanges(sql: string, databaseType?: DatabaseType): SqlTextRange[] {
@@ -27,7 +27,7 @@ export function elasticsearchRestRequestRanges(sql: string, databaseType?: Datab
return requests.length > 0 && requests.every((request) => ELASTICSEARCH_REST_REQUEST.test(request.sql)) ? requests : [];
}
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "meilisearch", "solr", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "consul", "mq", "neo4j", "victoriametrics"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || isHttpJsonRestDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
@@ -270,6 +270,7 @@ const DATABASE_SOFT_STATEMENT_KEYWORDS: Partial<Record<DatabaseType, readonly st
mongodb: [],
elasticsearch: [],
easysearch: [],
solr: [],
qdrant: [],
milvus: [],
weaviate: [],
@@ -84,6 +84,12 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
triggers: false,
ddl: false,
},
solr: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
hbase: {
indexes: false,
foreignKeys: false,
+7 -5
View File
@@ -1506,6 +1506,7 @@ export const useConnectionStore = defineStore("connection", () => {
elasticsearch: "Elasticsearch",
easysearch: "Easysearch",
meilisearch: "Meilisearch",
solr: "Apache Solr",
qdrant: "Qdrant",
milvus: "Milvus",
weaviate: "Weaviate",
@@ -4165,8 +4166,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadMongoDatabases(connectionId);
} else if (config.db_type === "dynamodb") {
await loadDynamoDbTables(connectionId);
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch") {
// Reload: list indices.
} else if (config.db_type === "elasticsearch" || config.db_type === "easysearch" || config.db_type === "meilisearch" || config.db_type === "solr") {
// Reload: list indices/cores.
await loadElasticsearchIndices(connectionId);
} else if (config.db_type === "milvus") {
await loadMilvusDatabases(connectionId);
@@ -5041,7 +5042,7 @@ export const useConnectionStore = defineStore("connection", () => {
async function loadConnectedConnectionRootForSidebarSearch(connectionId: string) {
if (!connectedIds.value.has(connectionId)) return;
const config = getConfig(connectionId);
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
if (!config || ["redis", "etcd", "zookeeper", "consul", "mongodb", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "solr", "milvus", "qdrant", "weaviate", "chromadb", "mq", "nacos"].includes(config.db_type)) return;
const node = findConnectionNode(connectionId);
if (!node || node.type !== "connection" || hasConnectionMetadataChildren(node.children)) return;
const scope = { kind: "connection-databases" as const, connectionId, driverProfile: metadataDriverProfile(config) };
@@ -5464,9 +5465,10 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureConnected(connectionId);
load = reclaimTreeNodeLoad(load, node);
const isMeilisearch = getConfig(connectionId)?.db_type === "meilisearch";
const isSolr = getConfig(connectionId)?.db_type === "solr";
const collections = isMeilisearch
? sortSidebarNames(await withMetadataLoadTimeout(connectionId, api.meilisearchListIndexes(connectionId), "Meilisearch indexes")).map((name) => ({ name, aliases: [] as string[] }))
: [...(await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, "default"), "Elasticsearch indices"))].sort((left, right) => compareSidebarNames(left.name, right.name));
: [...(await withMetadataLoadTimeout(connectionId, api.documentListCollections(connectionId, "default"), isSolr ? "Solr cores" : "Elasticsearch indices"))].sort((left, right) => compareSidebarNames(left.name, right.name));
const indexNodes = collections.map((collection) => {
const aliases = collection.aliases?.filter((alias) => alias.trim());
return {
@@ -7214,7 +7216,7 @@ export const useConnectionStore = defineStore("connection", () => {
await loadMongoDatabases(node.connectionId);
} else if (config?.db_type === "dynamodb") {
await loadDynamoDbTables(node.connectionId);
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch") {
} else if (config?.db_type === "elasticsearch" || config?.db_type === "easysearch" || config?.db_type === "meilisearch" || config?.db_type === "solr") {
await loadElasticsearchIndices(node.connectionId);
} else if (config?.db_type === "milvus") {
await loadMilvusDatabases(node.connectionId);
+24
View File
@@ -3278,6 +3278,29 @@ export const useQueryStore = defineStore("query", () => {
return registerOpenTab(tab);
}
function openSolrAdmin(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "solr-admin" && tab.connectionId === connectionId);
if (existing) {
switchTab(existing.id);
return existing.id;
}
const conn = useConnectionStore().getConfig(connectionId);
const id = uuid();
const tab: QueryTab = {
id,
title: conn?.name ? `${conn.name} - ${t("solrAdmin.title")}` : t("solrAdmin.title"),
connectionId,
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "solr-admin",
};
return registerOpenTab(tab);
}
function openDamengJobAdmin(connectionId: string) {
const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId);
if (existing) {
@@ -8997,6 +9020,7 @@ export const useQueryStore = defineStore("query", () => {
openPostgresDashboard,
openXuguDashboard,
openNacosDashboard,
openSolrAdmin,
openDamengUsers,
openDamengRoles,
openDamengJobAdmin,
+5
View File
@@ -13,6 +13,10 @@ export function isMeilisearchDatabaseType(dbType?: DatabaseType): boolean {
return dbType === "meilisearch";
}
export function isSolrDatabaseType(dbType?: DatabaseType): boolean {
return dbType === "solr";
}
export interface SqlSnippet {
id: string;
label: string;
@@ -1796,6 +1800,7 @@ export interface QueryTab {
| "mysql-dashboard"
| "postgres-dashboard"
| "xugu-dashboard"
| "solr-admin"
| "dolt-version-control"
| "plugin-workbench"
| "plugin-filesystem";
@@ -39,6 +39,7 @@ export const CONNECTION_PROFILES = {
oracle: { type: "oracle", port: 1521, user: "system", label: "Oracle", icon: "oracle" },
elasticsearch: { type: "elasticsearch", port: 9200, user: "", label: "Elasticsearch", icon: "elasticsearch" },
easysearch: { type: "easysearch", port: 9200, user: "", label: "Easysearch", icon: "easysearch" },
solr: { type: "solr", port: 8983, user: "", label: "Apache Solr", icon: "solr" },
meilisearch: { type: "meilisearch", port: 7700, user: "", label: "Meilisearch", icon: "meilisearch" },
hbase: { type: "hbase", port: 8080, user: "", label: "Apache HBase", icon: "hbase" },
qdrant: { type: "qdrant", port: 6333, user: "", label: "Qdrant", icon: "qdrant" },
@@ -146,6 +147,7 @@ export const CONNECTION_PROFILE_ICONS = {
oracle: "oracle",
elasticsearch: "elasticsearch",
easysearch: "easysearch",
solr: "solr",
meilisearch: "meilisearch",
hbase: "hbase",
qdrant: "qdrant",
@@ -252,6 +254,7 @@ export const CONNECTION_PICKER_OPTIONS = [
{ value: "oracle", label: "Oracle", category: "sql" },
{ value: "elasticsearch", label: "Elasticsearch", category: "document" },
{ value: "easysearch", label: "Easysearch", category: "document" },
{ value: "solr", label: "Apache Solr", category: "document" },
{ value: "meilisearch", label: "Meilisearch", category: "document" },
{ value: "hbase", label: "Apache HBase", category: "document" },
{ value: "qdrant", label: "Qdrant", category: "graph_ai" },
@@ -16,6 +16,7 @@ export const DATABASE_TYPES = [
"elasticsearch",
"easysearch",
"meilisearch",
"solr",
"hbase",
"qdrant",
"chromadb",