feat(db): 支持 VictoriaMetrics 数据库

* feat(victoriametrics): support native MetricsQL queries

* fix(victoriametrics): 完善指标浏览和结果导出

* fix(victoriametrics): 修复指标对象通用操作入口

* fix(victoriametrics): 修正指标注释和截断提示

* fix(i18n): 补齐 VictoriaMetrics 连接配置翻译

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-08-05 23:58:11 +08:00
1 parent 930aa48c20
commit 2ede64f1cb
64 files changed
+1293 -64

No files matched your search

@@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch";
import type { ConnectionConfig, ConnectionTestResult, DatabaseConnectionInfo, DatabaseType, HttpTunnelConfig, IdentifierCase, JdbcDriverInfo, JdbcLocalBundleInfo, JdbcMavenBundleInfo, ProxyTunnelConfig, SshConfigHostEntry, SshTunnelConfig, TransportLayerConfig } from "@/types/database";
import type { InfluxDbExternalConfig, InfluxDbVersion } from "@/types/influxdb";
import type { VictoriaMetricsExternalConfig } from "@/types/victoriametrics";
import type { MqAdminConfig, MqAuth, MqSystemKind } from "@/types/mq";
import type { MqttConnectionConfig } from "@/types/mqtt";
import type { NacosAdminConfig, NacosAuthConfig, NacosImplementation, NacosMetricsMode, NacosRNacosConsoleAuth, NacosVersionMode } from "@/types/nacos";
@@ -1025,6 +1026,7 @@ const driverProfiles: Record<
mqtt: { type: "mqtt", port: 1883, user: "", label: "MQTT", icon: "mqtt", host: "127.0.0.1" },
iris: { type: "iris", port: 1972, user: "_SYSTEM", label: "IRIS", icon: "iris" },
influxdb: { type: "influxdb", port: 8086, user: "", label: "InfluxDB", icon: "InfluxDB" },
victoriametrics: { type: "victoriametrics", port: 8428, user: "", label: "VictoriaMetrics", icon: "victoriametrics" },
custom_mysql: {
type: "mysql",
port: 3306,
@@ -1291,6 +1293,8 @@ function buildMqttExternalConfig(): MqttConnectionConfig {
const influxDbVersion = ref<InfluxDbVersion>("1");
const influxDbOrg = ref("");
const victoriaMetricsApiPath = ref("/prometheus");
const victoriaMetricsLookback = ref("1h");
function resetInfluxDbFields(config?: Partial<InfluxDbExternalConfig>) {
influxDbVersion.value = config?.version === "2" ? "2" : "1";
@@ -1328,6 +1332,33 @@ function buildInfluxDbExternalConfig(): InfluxDbExternalConfig {
return { version: "2", org };
}
function resetVictoriaMetricsFields(config?: Partial<VictoriaMetricsExternalConfig>) {
victoriaMetricsApiPath.value = config?.apiPath?.trim() || "/prometheus";
victoriaMetricsLookback.value = config?.lookback?.trim() || "1h";
}
function hydrateVictoriaMetricsFields(value: unknown) {
if (!value || typeof value !== "object") {
resetVictoriaMetricsFields();
return;
}
const config = value as Partial<VictoriaMetricsExternalConfig> & { api_path?: string };
resetVictoriaMetricsFields({
apiPath: config.apiPath || config.api_path,
lookback: config.lookback,
});
}
function buildVictoriaMetricsExternalConfig(): VictoriaMetricsExternalConfig {
const apiPath = victoriaMetricsApiPath.value.trim().replace(/\/+$/, "");
if (apiPath && !apiPath.startsWith("/")) throw new Error(t("connection.victoriametricsInvalidApiPath"));
const lookback = victoriaMetricsLookback.value.trim();
if (!/^\d+(?:ms|[smhdwy])$/.test(lookback) || lookback.startsWith("0")) {
throw new Error(t("connection.victoriametricsInvalidLookback"));
}
return { apiPath, lookback };
}
watch(influxDbVersion, (version) => {
if (form.value.db_type !== "influxdb") return;
if (version === "2") {
@@ -2070,6 +2101,13 @@ function applyProfile(val: string, preserveConnectionFields = false) {
form.value.password = "";
form.value.connection_string = undefined;
}
if (profile.type === "victoriametrics") {
resetVictoriaMetricsFields();
form.value.database = "metrics";
form.value.password = "";
form.value.connection_string = undefined;
form.value.url_params = "";
}
resetHiveKerberosFields(profile.type === "hive" ? form.value : undefined);
}
}
@@ -2177,6 +2215,11 @@ watch(
} else {
resetInfluxDbFields();
}
if (config.db_type === "victoriametrics") {
hydrateVictoriaMetricsFields(config.external_config);
} else {
resetVictoriaMetricsFields();
}
resetElasticsearchProxyFields(config.db_type === "elasticsearch" ? config.external_config : undefined);
resetHiveKerberosFields(config.db_type === "hive" ? config : undefined);
resetDamengJvmOptions(config.db_type === "dameng" ? config : undefined);
@@ -2436,6 +2479,7 @@ const iconTypeMap: Record<string, string> = {
sundb: "sundb",
oscar: "oscar",
influxdb: "influxdb",
victoriametrics: "victoriametrics",
jdbc: "jdbc",
custom_mysql: "mysql",
custom_postgres: "postgres",
@@ -2519,6 +2563,7 @@ const dbOptions: DbOption[] = [
{ value: "mqtt", label: "MQTT" },
{ value: "nacos", label: "Nacos" },
{ value: "influxdb", label: "InfluxDB" },
{ value: "victoriametrics", label: "VictoriaMetrics" },
{ value: "iris", label: "IRIS" },
{ value: "jdbcx", label: "JDBCX" },
{ value: "manticoresearch", label: "Manticore Search" },
@@ -2565,7 +2610,7 @@ const dbCategoryDefinitions: Array<{
{
key: "timeseries",
titleKey: "connection.databaseCategoryTimeseries",
optionValues: ["questdb", "tdengine", "iotdb", "influxdb"],
optionValues: ["questdb", "tdengine", "iotdb", "influxdb", "victoriametrics"],
},
{
key: "mq",
@@ -2670,9 +2715,31 @@ const sqliteExtensionPaths = computed({
form.value.url_params = setSqliteExtensionPaths(form.value.url_params, value);
},
});
const tlsCapableDatabaseTypes = new Set<DatabaseType>(["mysql", "starrocks", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "dameng", "redis", "etcd", "clickhouse", "elasticsearch", "easysearch", "hbase", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]);
const tlsCapableDatabaseTypes = new Set<DatabaseType>([
"mysql",
"starrocks",
"postgres",
"redshift",
"gaussdb",
"kwdb",
"opengauss",
"questdb",
"dameng",
"redis",
"etcd",
"clickhouse",
"elasticsearch",
"easysearch",
"hbase",
"qdrant",
"milvus",
"weaviate",
"chromadb",
"influxdb",
"victoriametrics",
]);
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse");
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse" || form.value.db_type === "victoriametrics");
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch" && form.value.db_type !== "hbase");
const bareMysqlProfiles = new Set(["doris", "selectdb", "oceanbase"]);
const supportsMysqlTlsOptions = computed(() => form.value.db_type === "starrocks" || (form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value)));
@@ -3117,6 +3184,9 @@ function applyConnectionUrlToForm(input: string): boolean {
const parsed = parseConnectionUrl(input, selectedType.value);
form.value = applyParsedConnectionUrl(form.value, parsed);
if (form.value.db_type === "victoriametrics") {
hydrateVictoriaMetricsFields(form.value.external_config);
}
oracleTnsAdminPath.value = parseOracleTnsConnectionString(parsed.connectionString)?.tnsAdmin || "";
selectedType.value = parsed.driverProfile;
customDriverName.value = isCustomCompatibleProfile() ? parsed.driverLabel : "";
@@ -3405,6 +3475,11 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.password = config.password.trim();
config.database = config.database?.trim() || undefined;
}
} else if (config.db_type === "victoriametrics") {
config.external_config = buildVictoriaMetricsExternalConfig();
config.connection_string = undefined;
config.database = "metrics";
config.username = config.username.trim();
} else if (config.db_type === "elasticsearch") {
config.external_config = buildElasticsearchExternalConfig(elasticsearchConnectionMode.value, elasticsearchKibanaBasePath.value, elasticsearchConnectivityCheckPath.value);
} else if (config.db_type === "sqlserver") {
@@ -3518,7 +3593,7 @@ function connectionConfigForSubmit(id: string, generatedName = ""): ConnectionCo
config.client_cert_path = undefined;
config.client_key_path = undefined;
}
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "starrocks" && config.db_type !== "mongodb") {
if (config.db_type !== "mysql" && config.db_type !== "clickhouse" && config.db_type !== "etcd" && config.db_type !== "starrocks" && config.db_type !== "mongodb" && config.db_type !== "victoriametrics") {
config.ca_cert_path = undefined;
} else {
config.ca_cert_path = config.ca_cert_path?.trim() || "";
@@ -6062,6 +6137,43 @@ function openExternalUrl(url: string) {
</div>
</template>
<template v-else-if="form.db_type === 'victoriametrics'">
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.host") }}</Label>
<Input v-model="form.host" class="col-span-2" />
<Input v-model.number="form.port" type="number" class="col-span-1" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<span />
<label class="col-span-3 flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.ssl" />
<span>{{ t("connection.sslEnable") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.user") }}</Label>
<Input v-model="form.username" class="col-span-3" autocomplete="username" />
</div>
<div class="grid grid-cols-4 items-center gap-4">
<Label :class="connectionLabelClass">{{ t("connection.password") }}</Label>
<PasswordInput v-model="form.password" class="col-span-3" />
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.victoriametricsApiPath") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="victoriaMetricsApiPath" placeholder="/prometheus" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.victoriametricsApiPathHint") }}</p>
</div>
</div>
<div class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.victoriametricsLookback") }}</Label>
<div class="col-span-3 space-y-1.5">
<Input v-model="victoriaMetricsLookback" class="w-28" placeholder="1h" />
<p class="text-xs leading-5 text-muted-foreground">{{ t("connection.victoriametricsLookbackHint") }}</p>
</div>
</div>
</template>
<!-- InfluxDB: v1 username/password or v2 token/org/bucket -->
<template v-else-if="form.db_type === 'influxdb'">
<div class="grid grid-cols-4 items-center gap-4">
@@ -1489,7 +1489,7 @@ function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view")
function extendQueryEditorSelectionForView(currentView: EditorViewType): boolean {
const databaseType = props.databaseType;
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" ? "text" : "sql";
const language = databaseType === "redis" || databaseType === "mongodb" || databaseType === "elasticsearch" || databaseType === "victoriametrics" ? "text" : "sql";
return extendQueryEditorSelection(currentView, {
databaseType,
dialect: props.syntaxDialect ?? props.dialect,
@@ -1688,7 +1688,7 @@ function getInsertValueHintTableColumns(table: string, schema?: string, database
function requestInsertValueHintTableColumns(table: string, schema?: string, database?: string) {
if (!props.connectionId || props.database == null) return;
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") return;
if (props.databaseType === "redis" || props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "victoriametrics") return;
const cacheKey = insertHintCacheKey({ name: table, schema, database });
const hasCachedColumns = props.databaseType === "sqlserver" ? cachedInsertValueHintColumnsByTable.has(cacheKey) : cachedColumnsByTable.has(cacheKey);
if (hasCachedColumns || pendingInsertValueHintColumnLoads.has(cacheKey)) return;
@@ -2122,7 +2122,7 @@ function invalidateSemanticDiagnosticsForDocumentChange() {
}
function shouldSkipSqlSemanticDiagnostics() {
return props.databaseType !== "redis" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled;
return props.databaseType === "victoriametrics" || (props.databaseType !== "redis" && !settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled);
}
function rangesOverlap(left: { from: number; to: number }, right: { from: number; to: number }): boolean {
@@ -2248,7 +2248,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
setSemanticDiagnostics([]);
return;
}
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch") {
if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch" || props.databaseType === "easysearch" || props.databaseType === "victoriametrics") {
setSemanticDiagnostics([]);
return;
}
@@ -2363,6 +2363,7 @@ function scheduleSemanticDiagnostics(delay = 500, options: { preserveOutsideRang
async function formatCurrentSql() {
if (props.readOnly) return;
if (props.databaseType === "victoriametrics") return;
const currentView = view.value;
if (!currentView) return;
@@ -2581,7 +2582,7 @@ function localCompletionSchemasForDatabaseDisambiguation(completionContext: Retu
}
function shouldInsertSqlCompletionSpace(): boolean {
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch";
return props.databaseType !== "mongodb" && props.databaseType !== "redis" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics";
}
function completionOptionForItem(item: QueryCompletionItem) {
@@ -2764,6 +2765,7 @@ async function provideSqlCompletions(context: CompletionContext) {
if (props.databaseType === "redis") {
return provideRedisCompletions(currentState, position, explicit);
}
if (props.databaseType === "victoriametrics") return null;
const hasDatabase = props.database != null;
const epoch = ++completionEpoch;
@@ -2971,6 +2973,7 @@ function shouldStartSqlCompletionAfterInput(insertedText: string, removedText: s
if (props.databaseType === "mongodb") {
return !!(insertedText || removedText) && shouldAutoOpenMongoCompletion(fullDoc, position);
}
if (props.databaseType === "victoriametrics") return false;
if (!insertedText && removedText) {
const completionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions());
return isTableNameCompletionContext(completionContext) && shouldAutoOpenSqlCompletion(fullDoc, position, sqlCompletionDialectOptions());
@@ -4010,7 +4013,7 @@ onMounted(async () => {
const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1));
let insertValueHints: Array<{ from: number; column: string }> = [];
try {
if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch") {
if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics") {
const statementSql = view.state.doc.sliceString(range.from, range.to);
if (/\binsert\b/i.test(statementSql)) {
insertValueHints = parseInsertValueHints(statementSql, { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({
@@ -4163,7 +4166,7 @@ onMounted(async () => {
sqlSignatureComp.of(buildSqlSignatureExtension()),
diagnosticComp.of(buildSqlDiagnosticExtension()),
createInsertValueHintsExtension({
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch",
isEnabled: () => settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics",
getTableColumns: getInsertValueHintTableColumns,
requestTableColumns: requestInsertValueHintTableColumns,
}),
@@ -4746,7 +4749,7 @@ watch(
watch(
() => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled,
(enabled) => {
if (props.databaseType === "redis") return;
if (props.databaseType === "redis" || props.databaseType === "victoriametrics") return;
if (!shouldSkipSqlSemanticDiagnostics() && enabled) {
scheduleSemanticDiagnostics(0);
return;
@@ -139,7 +139,7 @@ import { BOOLEAN_CELL_EDITOR_VALUES, booleanCellEditorValue, isBooleanCellValue,
import { resolveDataGridColumnsByResultIndex } from "@/lib/dataGrid/dataGridColumnMetadata";
import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowShortcut, isFocusSearchShortcut, isModRShortcut, isSaveShortcut, isToggleTransposeShortcut } from "@/lib/editor/keyboardShortcuts";
import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination";
import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions";
import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll";
import { CANVAS_DATA_GRID_ROW_HEIGHT, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer";
@@ -2484,6 +2484,7 @@ watch(
const manualTotalRowCount = ref<number | undefined>(undefined);
const manualTotalRowCountLoading = ref(false);
const showTruncationWarning = computed(() => props.result.truncated === true && typeof props.pageLimit !== "number" && props.result.has_more !== true);
const truncationHintKey = computed(() => dataGridTruncationHintKey(resolvedDatabaseType.value));
const isResultsContext = computed(() => props.context === "results");
// affected_rows reported by the backend can be larger than the rows we
// actually have in memory — e.g. ES auto-pages SELECT * on a big index and
@@ -2613,8 +2614,8 @@ watch(
const showQueryEditReadOnlyBadge = computed(() => isResultsContext.value && hasData.value && !props.editable && !!props.queryEditabilityReason);
const queryEditReadOnlyReason = computed(() => (props.queryEditabilityReason ? t(`grid.queryEditUnsupported.${props.queryEditabilityReason}`) : ""));
const showKeylessEditWarning = computed(() => !!props.editable && !!props.tableMeta && canUseKeylessRowPredicate(props.databaseType, props.tableMeta.primaryKeys ?? []));
const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value);
const canUseWhereSearch = computed(() => !!props.tableMeta && !!props.onExecuteSql && !isResultsContext.value);
const canShowWhereSearch = computed(() => !!props.onExecuteSql && !isResultsContext.value && resolvedDatabaseType.value !== "victoriametrics");
const canUseWhereSearch = computed(() => !!props.tableMeta && canShowWhereSearch.value);
const canUseServerColumnFilter = computed(() => canUseWhereSearch.value && !!props.connectionId && !!props.tableMeta);
type DataGridTableMeta = NonNullable<typeof props.tableMeta>;
const hiveTableTransactional = ref<boolean | undefined>(undefined);
@@ -8635,7 +8636,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</div>
<!-- Truncation warning banner -->
<div v-if="showTruncationWarning" class="shrink-0 px-3 py-1 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
<span>{{ t("grid.truncatedHint", { count: result.rows.length }) }}</span>
<span>{{ t(truncationHintKey, { count: result.rows.length }) }}</span>
</div>
<!-- Content area: table + side/bottom detail panes -->
<div class="flex-1 grid min-h-0 overflow-hidden" :style="contentGridStyle">
@@ -1,11 +1,17 @@
import { describe, expect, it } from "vitest";
import { dataGridTotalRowCountLabelKey } from "@/lib/dataGrid/dataGridPagination";
import { dataGridTotalRowCountLabelKey, dataGridTruncationHintKey } from "@/lib/dataGrid/dataGridPagination";
import DataGrid from "../DataGrid.vue";
type VuePropDefinition = { default?: unknown };
type VueComponentWithProps = { props?: Record<string, VuePropDefinition> };
describe("DataGrid total row count exactness", () => {
it("uses a VictoriaMetrics-specific truncation explanation", () => {
expect(dataGridTruncationHintKey("victoriametrics")).toBe("grid.victoriaMetricsTruncatedHint");
expect(dataGridTruncationHintKey("mysql")).toBe("grid.truncatedHint");
expect(dataGridTruncationHintKey()).toBe("grid.truncatedHint");
});
it("treats totals as exact unless a caller explicitly marks them as a lower bound", () => {
const component = DataGrid as unknown as VueComponentWithProps;
expect(component.props?.totalRowCountIsExact?.default).toBe(true);
@@ -100,6 +100,7 @@ const assetIcons: Record<string, string> = {
nacos: "nacos.png",
iris: "iris",
influxdb: "influxdb",
victoriametrics: "victoriametrics.png",
zookeeper: "zookeeper",
oscar: "oscar.png",
jdbcx: "jdbcx",
@@ -96,7 +96,21 @@ const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
const supportsExplain = computed(() => {
const dbType = props.activeConnection?.db_type;
return dbType !== "redis" && dbType !== "mongodb" && dbType !== "elasticsearch" && dbType !== "easysearch" && dbType !== "qdrant" && dbType !== "milvus" && dbType !== "weaviate" && dbType !== "chromadb" && dbType !== "etcd" && dbType !== "zookeeper" && dbType !== "mq" && dbType !== "nacos";
return (
dbType !== "redis" &&
dbType !== "mongodb" &&
dbType !== "elasticsearch" &&
dbType !== "easysearch" &&
dbType !== "qdrant" &&
dbType !== "milvus" &&
dbType !== "weaviate" &&
dbType !== "chromadb" &&
dbType !== "etcd" &&
dbType !== "zookeeper" &&
dbType !== "mq" &&
dbType !== "nacos" &&
dbType !== "victoriametrics"
);
});
const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type));
const supportsExPaste = computed(() => supportsSqlInListPaste(props.activeConnection?.db_type));
@@ -198,6 +198,8 @@ const isResizingSidePanel = ref(false);
const sidePanelGuard = createSidePanelRequestGuard();
const tableMetadataCapabilities = computed<TableMetadataCapabilities>(() => getTableMetadataCapabilities(effectiveDatabaseType.value));
const effectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const isVictoriaMetrics = computed(() => effectiveDatabaseType.value === "victoriametrics");
const objectRowsLabel = computed(() => t(isVictoriaMetrics.value ? "objects.series" : "objects.rows"));
const tableStructureDatabaseType = computed(() => tableStructureDatabaseTypeForConnection(props.connection) ?? props.connection.db_type);
const sourceEditableText = ref("");
const sourceDraft = ref("");
@@ -438,7 +440,8 @@ function toggleCheckboxColumn() {
const objectBrowserColumns = computed<ObjectBrowserColumnKey[]>(() => {
const columns: ObjectBrowserColumnKey[] = [];
if (showCheckboxColumn.value) columns.push("select");
columns.push("name", "type", "estimatedRows", "totalBytes");
columns.push("name", "type", "estimatedRows");
if (!isVictoriaMetrics.value) columns.push("totalBytes");
if (hasCreatedAt.value) columns.push("created_at");
if (hasUpdatedAt.value) columns.push("updated_at");
columns.push("comment");
@@ -600,7 +603,8 @@ function toggleSort(key: ObjectBrowserSortKey) {
}
const sortKeyOptions = computed<ObjectBrowserSortKey[]>(() => {
const options: ObjectBrowserSortKey[] = ["name", "type", "estimatedRows", "totalBytes"];
const options: ObjectBrowserSortKey[] = ["name", "type", "estimatedRows"];
if (!isVictoriaMetrics.value) options.push("totalBytes");
if (hasCreatedAt.value) options.push("created_at");
if (hasUpdatedAt.value) options.push("updated_at");
options.push("comment");
@@ -617,7 +621,7 @@ watch(sortKeyOptions, (options) => {
function sortKeyLabel(key: ObjectBrowserSortKey): string {
if (key === "name") return t("objects.name");
if (key === "type") return t("objects.type");
if (key === "estimatedRows") return t("objects.rows");
if (key === "estimatedRows") return objectRowsLabel.value;
if (key === "totalBytes") return t("objects.size");
if (key === "created_at") return t("objects.createdAt");
if (key === "updated_at") return t("objects.updatedAt");
@@ -1856,6 +1860,22 @@ async function exportTableData(row: ObjectBrowserRow, format: "csv" | "xlsx" | "
let task: ExportTask | null = null;
try {
if (isVictoriaMetrics.value) {
const result = await fetchTableDataForExport({
databaseType: effectiveDatabaseType.value,
schema,
tableName: row.name,
executePage: (sql) => api.executeQuery(props.connection.id, props.database, sql),
});
if (format === "csv") {
await api.exportQueryResultCsv(filePath, result.columns, result.rows);
} else {
const comments = useCommentHeader ? result.columns.map((name) => columnInfos?.find((column) => column.name.toLocaleLowerCase() === name.toLocaleLowerCase())?.comment ?? null) : undefined;
await api.exportQueryResultXlsx(filePath, row.name, result.columns, result.column_types ?? result.columns.map(() => ""), comments, result.rows);
}
toast(t("grid.exported"));
return;
}
let columns: string[] | undefined;
let columnComments: (string | null)[] | undefined;
@@ -1969,7 +1989,7 @@ function copySelectedTablesToClipboard() {
}
function canPasteTableClipboard(): boolean {
return tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
return !isVictoriaMetrics.value && tableClipboardMatchesTarget(normalizedObjectBrowserTableClipboardEntries(), pasteTableTargetContext());
}
function normalizedObjectBrowserTableClipboardEntries() {
@@ -1982,6 +2002,7 @@ function normalizedObjectBrowserTableClipboardEntries() {
}
function canTransferTableClipboard(): boolean {
if (isVictoriaMetrics.value) return false;
const entries = normalizedObjectBrowserTableClipboardEntries();
const target = pasteTableTargetContext();
if (entries.length === 0 || props.connection.read_only) return false;
@@ -2548,15 +2569,16 @@ watch(
// ---- CustomContextMenu helpers ----
function exportDataSubmenu(item: ObjectBrowserRow): ContextMenuItem {
const formats: ContextMenuItem[] = [
{ label: "CSV", action: () => exportData(item, "csv") },
{ label: "JSON", action: () => exportData(item, "json") },
];
if (!isVictoriaMetrics.value) formats.push({ label: "SQL INSERT", action: () => exportData(item, "sql") });
formats.push({ label: "XLSX", action: () => exportDataXlsx(item) });
return {
label: t("contextMenu.exportData"),
icon: Upload,
children: [
{ label: "CSV", action: () => exportData(item, "csv") },
{ label: "JSON", action: () => exportData(item, "json") },
{ label: "SQL INSERT", action: () => exportData(item, "sql") },
{ label: "XLSX", action: () => exportDataXlsx(item) },
],
children: formats,
};
}
@@ -2574,6 +2596,7 @@ function objectBrowserTableClipboardMenuState(item: ObjectBrowserRow) {
}
function tableClipboardMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (isVictoriaMetrics.value) return [];
const copyItem: ContextMenuItem = { label: t("contextMenu.copyTable"), action: () => copySingleTableToClipboard(item), icon: Copy };
const state = objectBrowserTableClipboardMenuState(item);
if (state === "copy") return [copyItem];
@@ -2590,6 +2613,16 @@ function selectedBatchTableCountLabel(key: "batchDrop" | "batchTruncate" | "batc
}
function getTableMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
if (isVictoriaMetrics.value) {
return [
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
{ label: t("contextMenu.newQuery"), action: () => openNewQuery(item), icon: TerminalSquare },
{ label: "", separator: true },
exportDataSubmenu(item),
{ label: "", separator: true },
{ label: t("contextMenu.copyName"), action: () => copyName(item), icon: Copy },
];
}
const useBatchActions = isSelectedBatchTableContext(item);
return [
{ label: t("contextMenu.viewData"), action: () => openViewData(item), icon: Table2 },
@@ -2789,23 +2822,23 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div class="min-w-0 flex-1 truncate text-muted-foreground">
{{ t("objects.selectedTables", { count: selectedTableCount }) }}
</div>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="openBatchDatabaseExport">
<Upload class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.exportSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="copySelectedTablesToClipboard">
<Clipboard class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.copyTableSelected") }}
</Button>
<Button v-if="supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
<Button v-if="!isVictoriaMetrics && supportsTruncateTable" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchTruncateTables">
<Scissors class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.truncateSelected") }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchEmptyTables">
<Eraser class="mr-1.5 h-3.5 w-3.5" />
{{ t("contextMenu.batchEmpty", { count: selectedTableCount }) }}
</Button>
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Button v-if="!isVictoriaMetrics" variant="ghost" size="sm" class="h-7 px-2 text-xs text-destructive" @click="requestBatchDropTables">
<Trash2 class="mr-1.5 h-3.5 w-3.5" />
{{ t("objects.dropSelected") }}
</Button>
@@ -2858,7 +2891,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div>
<div class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<span class="truncate">{{ t("objects.rows") }}</span>
<span class="truncate">{{ objectRowsLabel }}</span>
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
</button>
<div
@@ -2869,7 +2902,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<GripVertical class="h-3 w-3" />
</div>
</div>
<div class="relative flex min-w-0 items-center">
<div v-if="!isVictoriaMetrics" class="relative flex min-w-0 items-center">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<span class="truncate">{{ t("objects.size") }}</span>
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
@@ -2962,7 +2995,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.estimatedRows == null ? '' : formatObjectBrowserCount(item.estimatedRows)">
{{ formatObjectBrowserCount(item.estimatedRows) }}
</div>
<div class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
<div v-if="!isVictoriaMetrics" class="truncate text-xs tabular-nums text-muted-foreground" :title="item.totalBytes == null ? '' : formatObjectBrowserBytes(item.totalBytes)">
{{ formatObjectBrowserBytes(item.totalBytes) }}
</div>
<div v-if="hasCreatedAt" class="truncate text-xs tabular-nums text-muted-foreground" :title="formatObjectBrowserTimestamp(item.created_at)">
@@ -3008,7 +3041,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<span v-if="item.estimatedRows != null && item.estimatedRows > 0" class="object-browser-stat-badge object-browser-stat-badge-rows rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-primary">{{
formatObjectBrowserCount(item.estimatedRows)
}}</span>
<span v-if="item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{ formatObjectBrowserBytes(item.totalBytes) }}</span>
<span v-if="!isVictoriaMetrics && item.totalBytes != null && item.totalBytes > 0" class="object-browser-stat-badge object-browser-stat-badge-bytes rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">{{
formatObjectBrowserBytes(item.totalBytes)
}}</span>
</div>
<!-- Always reserve timestamp/comment slots when the dataset has them so every card shares one height. -->
<div v-if="hasCreatedAt || hasUpdatedAt" class="flex min-h-[15px] items-center gap-1 text-[10px] leading-[15px] text-muted-foreground/70">
@@ -3034,7 +3069,7 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<div class="flex items-center gap-2 px-3 py-1.5 border-b shrink-0 bg-muted/20 h-9">
<TableProperties class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ sidePanelRow?.name }}</span>
<div v-if="tableInfoTab === 'ddl'" class="table-info-actions flex min-w-0 shrink-0 items-center gap-1">
<div v-if="tableInfoTab === 'ddl' && tableMetadataCapabilities.ddl" class="table-info-actions flex min-w-0 shrink-0 items-center gap-1">
<Button variant="ghost" size="sm" class="table-info-action-button h-6 px-2 text-xs" :title="t('grid.copyDdl')" :aria-label="t('grid.copyDdl')" @click="copyTableDdl">
<Copy class="w-3 h-3" />
<span class="table-info-action-label">{{ t("grid.copyDdl") }}</span>
@@ -891,7 +891,7 @@ function normalizedTreeClipboardTableEntries(): TableClipboardTableContext[] {
}
function canPasteTreeClipboardToCurrentNode(): boolean {
return tableClipboardMatchesTarget(normalizedTreeClipboardTableEntries(), pasteTableTargetContext());
return currentDatabaseType() !== "victoriametrics" && tableClipboardMatchesTarget(normalizedTreeClipboardTableEntries(), pasteTableTargetContext());
}
function canTransferTreeClipboardToCurrentNode(): boolean {
@@ -924,6 +924,7 @@ function openTransferFromTreeClipboard(): boolean {
}
function requestPasteTreeClipboard(): boolean {
if (currentDatabaseType() === "victoriametrics") return false;
claimTreeItemDialogOwnership();
ensureDangerDialogRouting();
routeTreeItemDialogController();
@@ -1376,6 +1377,7 @@ async function newDeleteTemplate() {
}
async function generateDdlTemplate() {
if (currentDatabaseType() === "victoriametrics") return;
const targets = selectedDdlTargets();
if (!targets.length) return;
const tabTarget = targets.find((target) => target.id === activeNode.value.id) ?? targets[0]!;
@@ -1411,6 +1413,7 @@ function selectedDdlTargets() {
}
async function openDdl() {
if (currentDatabaseType() === "victoriametrics") return;
const targets = selectedDdlTargets();
if (!targets.length) return;
if (targets.length > 1) {
@@ -1471,6 +1474,10 @@ function updateTreeClipboardForNodes(nodes: TreeNode[]) {
connectionStore.treeClipboard = null;
return;
}
if (tableNodes.some((node) => databaseTypeForNode(node) === "victoriametrics")) {
connectionStore.treeClipboard = null;
return;
}
connectionStore.treeClipboard = {
kind: "table-copy",
tables: tableNodes.map((node) => ({
@@ -3083,6 +3090,7 @@ async function confirmDropSchema() {
function duplicateStructure(source: TreeNode = activeNode.value) {
if (!isDuplicateStructureSource(source)) return;
if (databaseTypeForNode(source) === "victoriametrics") return;
duplicateStructureSource.value = source;
duplicateTableName.value = `${source.label}_copy`;
showDuplicateDialog.value = true;
@@ -3096,6 +3104,7 @@ async function confirmDuplicateStructure() {
const node = duplicateStructureSource.value || (isDuplicateStructureSource(activeNode.value) ? activeNode.value : null);
const newName = duplicateTableName.value.trim();
if (!newName || !node) return;
if (databaseTypeForNode(node) === "victoriametrics") return;
showDuplicateDialog.value = false;
try {
await connectionStore.ensureConnected(node.connectionId);
@@ -3840,16 +3849,17 @@ const shortcutRefresh = "F5";
const shortcutDelete = "Delete";
function exportDataSubmenu(): ContextMenuItem {
function exportDataSubmenu(includeSqlInsert = true): ContextMenuItem {
const children: ContextMenuItem[] = [
{ label: "CSV", action: () => exportData("csv") },
{ label: "JSON", action: () => exportData("json") },
];
if (includeSqlInsert) children.push({ label: "SQL INSERT", action: () => exportData("sql") });
children.push({ label: "XLSX", action: () => exportDataXlsx() });
return {
label: t("contextMenu.exportData"),
icon: Upload,
children: [
{ label: "CSV", action: () => exportData("csv") },
{ label: "JSON", action: () => exportData("json") },
{ label: "SQL INSERT", action: () => exportData("sql") },
{ label: "XLSX", action: () => exportDataXlsx() },
],
children,
};
}
@@ -4349,6 +4359,17 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
const { node, items, deleteMenuLabel, deleteMenuAction, truncateMenuLabel, truncateMenuAction, emptyMenuLabel, emptyMenuAction } = context;
// 6. Table / View / Materialized View
if (node.type === "table" || node.type === "view" || node.type === "materialized_view") {
if (currentDatabaseType() === "victoriametrics" && node.type === "table") {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.viewData"), action: openDataImmediately, icon: TableProperties });
items.push({ label: t("contextMenu.openInNewDataTab"), action: openDataInNewTabImmediately, icon: CopyPlus });
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
items.push({ label: "", separator: true });
items.push(exportDataSubmenu(false));
items.push({ label: t("contextMenu.refreshChildren"), action: refresh, icon: RefreshCw, shortcut: shortcutRefresh });
return true;
}
const destructiveActions: ContextMenuItem[] = [];
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
items.push({ label: "", separator: true });
@@ -4568,6 +4589,7 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
}
function treeTableClipboardMenuItems(node: TreeNode): ContextMenuItem[] {
if (currentDatabaseType() === "victoriametrics") return [];
const copyItem: ContextMenuItem = { label: t("contextMenu.copyTable"), action: copySelectedNames, icon: Copy };
if (!node.connectionId || !node.database) return [copyItem];
const state = tableClipboardMenuState(
+1 -1
View File
@@ -18,7 +18,7 @@ const QUICK_OPEN_MAX_RESULTS = 200;
const INITIAL_SQL_LIBRARY_LIMIT = 20;
const INITIAL_SQL_FILE_LIMIT = 20;
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "etcd", "zookeeper", "mq", "nacos"]);
const REMOTE_SEARCH_UNSUPPORTED_TYPES = new Set<ConnectionConfig["db_type"]>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "neo4j", "influxdb", "victoriametrics", "etcd", "zookeeper", "mq", "nacos"]);
export interface QuickOpenItem {
id: string;
@@ -258,6 +258,26 @@ export function useSidebarTreeExportRuntime(options: SidebarTreeExportRuntimeOpt
await connectionStore.ensureConnected(connectionId);
task = addExportTask(node.label, format, outputPath);
const currentTask = task;
const effectiveDbType = effectiveDatabaseTypeForConnection(config);
if (effectiveDbType === "victoriametrics") {
const result = await fetchTableDataForExport({
databaseType: effectiveDbType,
schema: node.schema,
tableName: node.label,
tableType: node.tableType,
executePage: (sql) => api.executeQuery(connectionId, database, sql),
});
if (format === "csv") {
await api.exportQueryResultCsv(outputPath, result.columns, result.rows);
} else {
await api.exportQueryResultXlsx(outputPath, node.label, result.columns, result.column_types ?? result.columns.map(() => ""), undefined, result.rows);
}
currentTask.status = "Done";
currentTask.rowsExported = result.rows.length;
currentTask.totalRows = result.rows.length;
toast(t("grid.exported"));
return;
}
const queryColumns = config.db_type === "neo4j" ? (await api.getColumns(connectionId, database, node.schema || database, node.label)).map((column) => column.name) : undefined;
const rowLimit = settingsStore.editorSettings.exportRowLimitEnabled ? settingsStore.editorSettings.exportRowLimit : null;
const request: api.TableExportRequest = {
@@ -366,7 +366,7 @@ export function useSqlExecution(deps: {
export function supportsSqlTemplateParameters(connection: Pick<ConnectionConfig, "db_type"> | undefined, sql = ""): boolean {
if (!connection) return false;
if (connection.db_type === "elasticsearch" || connection.db_type === "easysearch") return !isElasticsearchRestRequestText(sql);
return connection.db_type !== "redis" && connection.db_type !== "mongodb";
return connection.db_type !== "redis" && connection.db_type !== "mongodb" && connection.db_type !== "victoriametrics";
}
export function requiresDatabaseSelection(tab: QueryTab, connection: ConnectionConfig | undefined, _sql = ""): boolean {
+8
View File
@@ -243,6 +243,12 @@ export default {
elasticsearchConnectivityCheckPath: "Connectivity Path",
elasticsearchConnectivityCheckPathPlaceholder: "/ or /my-index/_search",
version: "Version",
victoriametricsApiPath: "API Path",
victoriametricsApiPathHint: "Use /prometheus for single-node deployments, or /select/tenantID/prometheus for clusters.",
victoriametricsLookback: "Metadata Lookback",
victoriametricsLookbackHint: "Used for metric discovery and label columns, for example 1h or 7d. Metric previews use 1h and remain editable.",
victoriametricsInvalidApiPath: "The VictoriaMetrics API path must be empty or start with /.",
victoriametricsInvalidLookback: "Invalid VictoriaMetrics lookback. Use a value such as 15m, 1h, or 7d.",
driverInstallHintPrefix: "Install the required driver from ",
driverInstallHintSuffix: " in the top toolbar before connecting.",
driverName: "Driver Name",
@@ -1562,6 +1568,7 @@ export default {
},
sortUnsupported: "This SQL does not support full-result sorting. Try again with a single SELECT query.",
truncatedHint: "Results were truncated after loading {count} rows. Use the footer pagination to browse loaded data; exporting the full result reruns the database query.",
victoriaMetricsTruncatedHint: "Results exceeded the loading limit, so only the first {count} rows were loaded. Rows per page only paginates loaded data; shorten the time range or add filters to retrieve a complete result.",
},
exportProgress: {
streamingUnsupported: "Streaming export is unsupported for this query. Simplify it or use a supported driver.",
@@ -2719,6 +2726,7 @@ export default {
name: "Name",
type: "Type",
rows: "Rows",
series: "Series",
size: "Size",
statisticsHint: "Estimated from database statistics; no COUNT(*) is executed.",
createdAt: "Created",
+8
View File
@@ -239,6 +239,12 @@ export default withEnglishFallback({
oracleTnsAdminRequired: "El modo Oracle TNS requiere el directorio TNS_ADMIN que contiene tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS no se puede combinar con túneles SSH, proxy o HTTP. Elimine la capa de transporte o use Servicio/SID.",
version: "Versión",
victoriametricsApiPath: "Ruta de la API",
victoriametricsApiPathHint: "Use /prometheus para implementaciones de nodo único o /select/tenantID/prometheus para clústeres.",
victoriametricsLookback: "Intervalo retrospectivo de metadatos",
victoriametricsLookbackHint: "Se usa para descubrir métricas y columnas de etiquetas, por ejemplo, 1h o 7d. Las vistas previas de métricas usan 1h y se pueden editar.",
victoriametricsInvalidApiPath: "La ruta de la API de VictoriaMetrics debe estar vacía o comenzar por /.",
victoriametricsInvalidLookback: "El intervalo retrospectivo de VictoriaMetrics no es válido. Use un valor como 15m, 1h o 7d.",
driverInstallHintPrefix: "Instala el driver requerido desde ",
driverInstallHintSuffix: " en la barra superior antes de conectar.",
driverName: "Nombre del driver",
@@ -1440,6 +1446,7 @@ export default withEnglishFallback({
},
sortUnsupported: "Este SQL no admite ordenamiento del resultado completo. Intenta con una consulta SELECT simple.",
truncatedHint: "Los resultados se truncaron después de cargar {count} filas. Usa la paginación inferior para explorar los datos cargados; al exportar el resultado completo se vuelve a consultar la base de datos.",
victoriaMetricsTruncatedHint: "Los resultados superaron el límite de carga, por lo que solo se cargaron las primeras {count} filas. Las filas por página solo paginan los datos cargados; reduce el intervalo de tiempo o añade filtros para obtener el resultado completo.",
filterBuilderSearchColumns: "Buscar columnas...",
filterBuilderNoMatchingColumns: "Sin columnas coincidentes",
copyExtractorSmart: "Copia inteligente",
@@ -2571,6 +2578,7 @@ export default withEnglishFallback({
name: "Nombre",
type: "Tipo",
rows: "Filas",
series: "Series",
size: "Tamaño",
statisticsHint: "Estimado desde estadísticas de la base de datos; no se ejecuta COUNT(*).",
createdAt: "Creado",
+8
View File
@@ -238,6 +238,12 @@ export default withEnglishFallback({
oracleTnsAdminRequired: "La modalità Oracle TNS richiede la directory TNS_ADMIN contenente tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS non può usare tunnel SSH, proxy o HTTP. Rimuovi il livello di trasporto o usa Servizio/SID.",
version: "Versione",
victoriametricsApiPath: "Percorso API",
victoriametricsApiPathHint: "Usa /prometheus per le installazioni a nodo singolo oppure /select/tenantID/prometheus per i cluster.",
victoriametricsLookback: "Intervallo metadati",
victoriametricsLookbackHint: "Usato per rilevare metriche e colonne delle etichette, ad esempio 1h o 7d. Le anteprime delle metriche usano 1h e restano modificabili.",
victoriametricsInvalidApiPath: "Il percorso API di VictoriaMetrics deve essere vuoto o iniziare con /.",
victoriametricsInvalidLookback: "Intervallo VictoriaMetrics non valido. Usa un valore come 15m, 1h o 7d.",
driverInstallHintPrefix: "Installa il driver richiesto da ",
driverInstallHintSuffix: " nella barra degli strumenti superiore prima di connetterti.",
driverName: "Nome Driver",
@@ -1438,6 +1444,7 @@ export default withEnglishFallback({
},
sortUnsupported: "Questo SQL non supporta l'ordinamento sull'intero risultato. Riprova con una query SELECT semplice.",
truncatedHint: "I risultati sono stati troncati dopo il caricamento di {count} righe. Usa la paginazione in basso per consultare i dati caricati; l'esportazione del risultato completo riesegue la query sul database.",
victoriaMetricsTruncatedHint: "I risultati hanno superato il limite di caricamento, quindi sono state caricate solo le prime {count} righe. Le righe per pagina suddividono solo i dati caricati; riduci l'intervallo di tempo o aggiungi filtri per ottenere il risultato completo.",
filterBuilderSearchColumns: "Cerca campi...",
filterBuilderNoMatchingColumns: "Nessun campo corrispondente",
copyExtractorSmart: "Copia intelligente",
@@ -2569,6 +2576,7 @@ export default withEnglishFallback({
name: "Nome",
type: "Tipo",
rows: "Righe",
series: "Serie",
size: "Dimensione",
statisticsHint: "Stimato dalle statistiche del database; COUNT(*) non viene eseguito.",
createdAt: "Creato",
+8
View File
@@ -238,6 +238,12 @@ export default withEnglishFallback({
oracleTnsAdminRequired: "Oracle TNS モードでは tnsnames.ora を含む TNS_ADMIN ディレクトリが必要です。",
oracleTnsTransportUnsupported: "Oracle TNS 接続は SSH、プロキシ、HTTP トンネルと併用できません。トランスポート層を削除するか、サービス名/SID モードを使用してください。",
version: "バージョン",
victoriametricsApiPath: "APIパス",
victoriametricsApiPathHint: "単一ノード構成では /prometheus、クラスター構成では /select/tenantID/prometheus を使用します。",
victoriametricsLookback: "メタデータ参照範囲",
victoriametricsLookbackHint: "メトリクスとラベル列の検出に使用します(例: 1h、7d)。メトリクスのプレビューには 1h を使用し、クエリは編集できます。",
victoriametricsInvalidApiPath: "VictoriaMetrics の API パスは空にするか、/ で始める必要があります。",
victoriametricsInvalidLookback: "VictoriaMetrics の参照範囲が無効です。15m、1h、7d などの値を使用してください。",
driverInstallHintPrefix: "接続前に上部ツールバーの",
driverInstallHintSuffix: "から必要なドライバーをインストールしてください。",
driverName: "ドライバー名",
@@ -1454,6 +1460,7 @@ export default withEnglishFallback({
},
sortUnsupported: "このSQLは完全な結果の並び替えをサポートしていません。単一のSELECTクエリで再試行してください。",
truncatedHint: "結果は{count}行を読み込んだ時点で切り詰められました。フッターのページネーションで読み込み済みデータを参照できます。完全な結果のエクスポート時はデータベースへ再問い合わせします。",
victoriaMetricsTruncatedHint: "結果が読み込み上限を超えたため、先頭の{count}行のみ読み込みました。1ページあたりの行数は読み込み済みデータだけをページ分割します。完全な結果を取得するには、時間範囲を短くするかフィルターを追加してください。",
loadingMore: "さらにデータを読み込み中...",
allLoaded: "すべて読み込み済み",
previewSqlEmpty: "プレビューする保留中のSQL変更はありません",
@@ -2630,6 +2637,7 @@ export default withEnglishFallback({
name: "名前",
type: "タイプ",
rows: "行数",
series: "系列数",
size: "サイズ",
statisticsHint: "データベース統計からの推定値です。COUNT(*) は実行しません。",
createdAt: "作成日時",
+8
View File
@@ -239,6 +239,12 @@ export default withEnglishFallback({
elasticsearchConnectivityCheckPath: "연결 확인 경로",
elasticsearchConnectivityCheckPathPlaceholder: "/ 또는 /my-index/_search",
version: "버전",
victoriametricsApiPath: "API 경로",
victoriametricsApiPathHint: "단일 노드 배포에는 /prometheus를, 클러스터에는 /select/tenantID/prometheus를 사용하세요.",
victoriametricsLookback: "메타데이터 조회 범위",
victoriametricsLookbackHint: "메트릭 및 레이블 열 검색에 사용합니다(예: 1h 또는 7d). 메트릭 미리보기는 1h를 사용하며 쿼리를 편집할 수 있습니다.",
victoriametricsInvalidApiPath: "VictoriaMetrics API 경로는 비워 두거나 /로 시작해야 합니다.",
victoriametricsInvalidLookback: "VictoriaMetrics 조회 범위가 올바르지 않습니다. 15m, 1h 또는 7d와 같은 값을 사용하세요.",
driverInstallHintPrefix: "연결 전에 상단 도구 모음의 ",
driverInstallHintSuffix: "에서 필수 드라이버를 설치하세요.",
driverName: "드라이버 이름",
@@ -1457,6 +1463,7 @@ export default withEnglishFallback({
},
sortUnsupported: "이 SQL은 전체 결과 정렬을 지원하지 않습니다. 단일 SELECT 쿼리로 다시 시도하세요.",
truncatedHint: "{count}행 로드 후 결과가 잘렸습니다. 로드된 데이터를 탐색하려면 푸터 페이지 매김을 사용하세요. 전체 결과를 내보내면 데이터베이스 쿼리를 다시 실행합니다.",
victoriaMetricsTruncatedHint: "결과가 로드 한도를 초과하여 처음 {count}행만 로드했습니다. 페이지당 행 수는 로드된 데이터만 나눠 표시합니다. 전체 결과를 가져오려면 시간 범위를 줄이거나 필터를 추가하세요.",
},
exportProgress: {
streamingUnsupported: "이 쿼리는 스트리밍 내보내기를 지원하지 않습니다. 쿼리를 단순화하거나 지원되는 드라이버를 사용하세요.",
@@ -2600,6 +2607,7 @@ export default withEnglishFallback({
name: "이름",
type: "유형",
rows: "행",
series: "시계열 수",
size: "크기",
statisticsHint: "데이터베이스 통계에서 추정한 값이며, COUNT(*)는 실행되지 않습니다.",
createdAt: "생성일",
+8
View File
@@ -239,6 +239,12 @@ export default withEnglishFallback({
oracleTnsAdminRequired: "O modo Oracle TNS requer o diretório TNS_ADMIN que contém tnsnames.ora.",
oracleTnsTransportUnsupported: "Oracle TNS não pode usar túneis SSH, proxy ou HTTP. Remova a camada de transporte ou use Serviço/SID.",
version: "Versão",
victoriametricsApiPath: "Caminho da API",
victoriametricsApiPathHint: "Use /prometheus para implantações de nó único ou /select/tenantID/prometheus para clusters.",
victoriametricsLookback: "Intervalo de consulta de metadados",
victoriametricsLookbackHint: "Usado para descobrir métricas e colunas de rótulos, por exemplo, 1h ou 7d. As visualizações de métricas usam 1h e podem ser editadas.",
victoriametricsInvalidApiPath: "O caminho da API do VictoriaMetrics deve estar vazio ou começar com /.",
victoriametricsInvalidLookback: "O intervalo de consulta do VictoriaMetrics é inválido. Use um valor como 15m, 1h ou 7d.",
driverInstallHintPrefix: "Instale o driver necessário em ",
driverInstallHintSuffix: " na barra de ferramentas superior antes de conectar.",
driverName: "Nome do Driver",
@@ -1440,6 +1446,7 @@ export default withEnglishFallback({
},
sortUnsupported: "Este SQL não suporta a ordenação do resultado completo. Tente novamente com uma única consulta SELECT.",
truncatedHint: "Os resultados foram truncados após carregar {count} linhas. Use a paginação no rodapé para navegar pelos dados carregados; exportar o resultado completo executa novamente a consulta no banco de dados.",
victoriaMetricsTruncatedHint: "Os resultados excederam o limite de carregamento, então apenas as primeiras {count} linhas foram carregadas. As linhas por página paginam somente os dados carregados; reduza o intervalo de tempo ou adicione filtros para obter o resultado completo.",
filterBuilderSearchColumns: "Pesquisar campos...",
filterBuilderNoMatchingColumns: "Nenhum campo correspondente",
copyExtractorSmart: "Cópia inteligente",
@@ -2571,6 +2578,7 @@ export default withEnglishFallback({
name: "Nome",
type: "Tipo",
rows: "Linhas",
series: "Séries",
size: "Tamanho",
statisticsHint: "Estimado pelas estatísticas do banco de dados; COUNT(*) não é executado.",
createdAt: "Criado em",
+8
View File
@@ -245,6 +245,12 @@ export default withEnglishFallback({
elasticsearchConnectivityCheckPath: "连通性检查路径",
elasticsearchConnectivityCheckPathPlaceholder: "/ 或 /my-index/_search",
version: "版本",
victoriametricsApiPath: "API 路径",
victoriametricsApiPathHint: "单机版通常为 /prometheus;集群版可填写 /select/租户ID/prometheus。",
victoriametricsLookback: "元数据回看范围",
victoriametricsLookbackHint: "用于指标列表和标签列,例如 1h、7d。双击指标默认预览 1h,并可直接修改查询。",
victoriametricsInvalidApiPath: "VictoriaMetrics API 路径必须为空或以 / 开头。",
victoriametricsInvalidLookback: "VictoriaMetrics 回看范围格式无效,请使用 15m、1h、7d 等格式。",
driverInstallHintPrefix: "需要在顶部导航栏「",
driverInstallHintSuffix: "」中安装对应的驱动才能连接。",
driverName: "驱动名称",
@@ -1563,6 +1569,7 @@ export default withEnglishFallback({
},
sortUnsupported: "当前 SQL 不支持全量排序,请改为单条 SELECT 查询后再尝试。",
truncatedHint: "结果已截断,已加载前 {count} 行。可通过底部分页浏览已加载数据;导出完整结果时会重新查询数据库。",
victoriaMetricsTruncatedHint: "结果超过加载上限,仅加载前 {count} 行。右下角每页行数只调整已加载数据的分页显示;请缩短查询时间范围或增加筛选条件以获取完整结果。",
},
exportProgress: {
streamingUnsupported: "当前查询暂不支持流式导出,请简化查询或使用受支持的驱动。",
@@ -2719,6 +2726,7 @@ export default withEnglishFallback({
name: "名称",
type: "类型",
rows: "行数",
series: "序列数",
size: "大小",
statisticsHint: "来自数据库统计信息的估算值,不执行 COUNT(*)。",
createdAt: "创建时间",
+8
View File
@@ -239,6 +239,12 @@ export default withEnglishFallback({
oracleTnsAdminRequired: "Oracle TNS 模式需要填寫包含 tnsnames.ora 的 TNS_ADMIN 目錄。",
oracleTnsTransportUnsupported: "Oracle TNS 連線不能與 SSH、代理或 HTTP Tunnel 組合使用。請移除傳輸層,或改用服務名稱/SID 模式。",
version: "版本",
victoriametricsApiPath: "API 路徑",
victoriametricsApiPathHint: "單機版通常使用 /prometheus;叢集版可填寫 /select/tenantID/prometheus。",
victoriametricsLookback: "中繼資料回看範圍",
victoriametricsLookbackHint: "用於探索指標與標籤欄位,例如 1h 或 7d。指標預覽使用 1h,且查詢內容可直接修改。",
victoriametricsInvalidApiPath: "VictoriaMetrics API 路徑必須為空或以 / 開頭。",
victoriametricsInvalidLookback: "VictoriaMetrics 回看範圍格式無效,請使用 15m、1h 或 7d 等格式。",
driverInstallHintPrefix: "需要在上方導覽列「",
driverInstallHintSuffix: "」中安裝對應的驅動程式才能連線。",
driverName: "驅動程式名稱",
@@ -1439,6 +1445,7 @@ export default withEnglishFallback({
},
sortUnsupported: "目前 SQL 不支援完整排序,請改為單條 SELECT 查詢後再嘗試。",
truncatedHint: "結果已截斷,已載入前 {count} 筆。可透過底部分頁瀏覽已載入資料;匯出完整結果時會重新查詢資料庫。",
victoriaMetricsTruncatedHint: "結果超過載入上限,僅載入前 {count} 筆。右下角每頁筆數只調整已載入資料的分頁顯示;請縮短查詢時間範圍或增加篩選條件以取得完整結果。",
filterBuilderSearchColumns: "搜尋欄位...",
filterBuilderNoMatchingColumns: "沒有匹配的欄位",
copyExtractorSmart: "智慧複製",
@@ -2440,6 +2447,7 @@ export default withEnglishFallback({
name: "名稱",
type: "類型",
rows: "列數",
series: "序列數",
size: "大小",
statisticsHint: "來自資料庫統計資訊的估算值,不執行 COUNT(*)。",
createdAt: "建立時間",
@@ -2,10 +2,11 @@ import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const tlsCapableDatabaseTypesSource = dialogSource.match(/const tlsCapableDatabaseTypes = new Set<DatabaseType>\(\[([\s\S]*?)\]\);/)?.[1] ?? "";
describe("Dameng SSL connection form", () => {
it("shows Dameng in the TLS tab with certificate directory and password fields", () => {
expect(dialogSource).toContain('"questdb", "dameng", "redis"');
expect(tlsCapableDatabaseTypesSource).toContain('"dameng"');
expect(dialogSource).toContain("<template v-if=\"form.db_type === 'dameng'\">");
expect(dialogSource).toContain('v-model="damengSslFilesPath"');
expect(dialogSource).toContain('v-model="damengSslKeystorePassword"');
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { applyParsedConnectionUrl, parseConnectionUrl } from "@/lib/connection/connectionUrl";
import type { ConnectionConfig } from "@/types/database";
describe("VictoriaMetrics connection URL", () => {
it("parses single-node HTTP URLs with Basic Auth", () => {
expect(parseConnectionUrl("http://vm_user:secret@vm.example.com:8428/prometheus", "victoriametrics")).toMatchObject({
dbType: "victoriametrics",
host: "vm.example.com",
port: 8428,
username: "vm_user",
password: "secret",
database: "metrics",
apiPath: "/prometheus",
ssl: false,
});
});
it("preserves cluster tenant API paths in external config", () => {
const parsed = parseConnectionUrl("https://vm.example.com/select/42/prometheus", "victoriametrics");
const config = applyParsedConnectionUrl(
{
db_type: "victoriametrics",
name: "VictoriaMetrics",
host: "",
port: 8428,
username: "",
password: "",
ssl: false,
external_config: { lookback: "7d" },
} as Omit<ConnectionConfig, "id">,
parsed,
);
expect(config.ssl).toBe(true);
expect(config.external_config).toEqual({ lookback: "7d", apiPath: "/select/42/prometheus" });
});
});
@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { filterModeIsSupportedForDatabase } from "@/lib/dataGrid/dataGridColumnFilter";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
describe("VictoriaMetrics data grid filters", () => {
it("does not expose SQL filter modes for MetricsQL results", () => {
const modes: DataGridContextFilterMode[] = ["equals", "not-equals", "is-null", "is-not-null", "like", "not-like", "less-than", "greater-than", "in", "not-in", "between", "not-between"];
expect(modes.every((mode) => !filterModeIsSupportedForDatabase(mode, "victoriametrics"))).toBe(true);
expect(filterModeIsSupportedForDatabase("equals", "postgres")).toBe(true);
});
});
@@ -169,6 +169,10 @@ describe("resolveNewQueryTable", () => {
});
describe("buildSelectAllSql", () => {
it("builds a MetricsQL range query for VictoriaMetrics metrics", () => {
expect(buildSelectAllSql("victoriametrics", { tableName: "flag" })).toBe('{__name__="flag"}[1h]');
});
it("quotes a MySQL table with backticks", () => {
expect(buildSelectAllSql("mysql", { tableName: "users" })).toBe("SELECT * FROM `users`");
});
@@ -1276,6 +1276,7 @@ describe("supportsExecutionTargetPicker", () => {
expect(supportsExecutionTargetPicker("zookeeper")).toBe(false);
expect(supportsExecutionTargetPicker("mq")).toBe(false);
expect(supportsExecutionTargetPicker("neo4j")).toBe(false);
expect(supportsExecutionTargetPicker("victoriametrics")).toBe(false);
expect(supportsExecutionTargetPicker(undefined)).toBe(false);
});
});
@@ -236,6 +236,9 @@ export function connectionUrlPlaceholder(dbType: DatabaseType): string {
case "influxdb":
return "influxdb://user:password@host:port/database";
case "victoriametrics":
return "http://user:password@host:port/prometheus";
case "jdbc":
return "jdbc:mysql://host:3306/database";
@@ -18,6 +18,7 @@ export interface ParsedConnectionUrl {
oracleConnectionType?: "service_name" | "sid";
useMongoUrl?: boolean;
portExplicit?: boolean;
apiPath?: string;
}
export type ConnectionProfile = {
@@ -68,6 +69,7 @@ const SCHEME_PROFILES: Record<string, ConnectionProfile> = {
xugu: { type: "xugu", profile: "xugu", label: "XuguDB", defaultPort: 5138 },
iotdb: { type: "iotdb", profile: "iotdb", label: "Apache IoTDB", defaultPort: 6667 },
iris: { type: "iris", profile: "iris", label: "IRIS", defaultPort: 1972 },
victoriametrics: { type: "victoriametrics", profile: "victoriametrics", label: "VictoriaMetrics", defaultPort: 8428 },
};
const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
@@ -78,6 +80,7 @@ const HTTP_SELECTED_PROFILES: Record<string, ConnectionProfile> = {
milvus: SCHEME_PROFILES.milvus,
weaviate: SCHEME_PROFILES.weaviate,
chromadb: SCHEME_PROFILES.chromadb,
victoriametrics: SCHEME_PROFILES.victoriametrics,
};
function decodeUrlPart(value: string): string {
@@ -640,9 +643,10 @@ export function parseConnectionUrl(value: string, preferredProfile?: string): Pa
...(profile.type === "sqlserver" && parsed.port ? { portExplicit: true } : {}),
username: mysqlCredentials?.username ?? decodeUrlPart(parsed.username),
password: mysqlCredentials?.password ?? decodeUrlPart(parsed.password),
database: databaseFromPath(parsed.pathname),
database: profile.type === "victoriametrics" ? "metrics" : databaseFromPath(parsed.pathname),
urlParams: effectiveUrlParams,
ssl: scheme === "rediss" || scheme === "https" || urlParamsRequireTls(profile.type, effectiveUrlParams) || (profile.type === "mysql" && isTidbCloudHost(parsed.hostname)),
...(profile.type === "victoriametrics" ? { apiPath: parsed.pathname.replace(/\/+$/, "") } : {}),
};
}
@@ -678,7 +682,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function sqlServerExternalConfig(existing: unknown, parsed: ParsedConnectionUrl): unknown {
function parsedExternalConfig(existing: unknown, parsed: ParsedConnectionUrl): unknown {
if (parsed.dbType === "victoriametrics") {
const next = isRecord(existing) ? { ...existing } : {};
next.apiPath = parsed.apiPath || "/prometheus";
return next;
}
if (parsed.dbType !== "sqlserver") return existing;
const next = isRecord(existing) ? { ...existing } : {};
@@ -707,6 +716,6 @@ export function applyParsedConnectionUrl(config: Omit<ConnectionConfig, "id">, p
ssl: parsed.ssl,
connection_string: parsed.connectionString,
oracle_connection_type: parsed.oracleConnectionType,
external_config: sqlServerExternalConfig(config.external_config, parsed),
external_config: parsedExternalConfig(config.external_config, parsed),
};
}
@@ -184,6 +184,7 @@ export function filterModeUsesRange(mode: DataGridContextFilterMode): boolean {
}
export function filterModeIsSupportedForDatabase(mode: DataGridContextFilterMode, databaseType?: DatabaseType): boolean {
if (databaseType === "victoriametrics") return false;
if (!filterModeUsesList(mode) && !filterModeUsesRange(mode)) return true;
// These targets do not support all four new SQL predicates reliably.
return databaseType !== "cassandra" && databaseType !== "influxdb" && databaseType !== "jdbc";
@@ -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", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos"]);
const NON_SQL_DATABASE_TYPES = new Set<DatabaseType>(["redis", "mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "victoriametrics"]);
// quoteTableIdentifier 对 jdbc/iotdb 原样返回(无引用字符可用)。
const UNQUOTABLE_DATABASE_TYPES = new Set<DatabaseType>(["jdbc", "iotdb"]);
@@ -1,3 +1,5 @@
import type { DatabaseType } from "@/types/database";
export interface CanGoNextDataGridPageOptions {
hasMore?: boolean;
rowCount: number;
@@ -31,6 +33,10 @@ export interface CanFetchNextDataGridSegmentOptions {
export type DataGridInexactTotalRowCountMode = "at-least" | "estimated";
export function dataGridTruncationHintKey(databaseType?: DatabaseType): "grid.truncatedHint" | "grid.victoriaMetricsTruncatedHint" {
return databaseType === "victoriametrics" ? "grid.victoriaMetricsTruncatedHint" : "grid.truncatedHint";
}
export function dataGridTotalRowCountLabelKey(totalRowCountIsExact: boolean, inexactMode: DataGridInexactTotalRowCountMode): "grid.totalRowCount" | "grid.totalRowCountAtLeast" | "grid.totalRowCountEstimated" {
if (totalRowCountIsExact) return "grid.totalRowCount";
return inexactMode === "estimated" ? "grid.totalRowCountEstimated" : "grid.totalRowCountAtLeast";
@@ -43,7 +43,7 @@ export const SCHEMA_AWARE_TYPES = new Set<DatabaseType>([
// query another database through a three-part name on the same connection.
export const DATABASE_SCHEMA_QUALIFIED_TYPES = new Set<DatabaseType>(["sqlserver", "trino", "prestosql"]);
export const SINGLE_DATABASE_TYPES = new Set<DatabaseType>(["oracle", "dameng", "firebird", "oceanbase-oracle", "access", "questdb"]);
export const SINGLE_DATABASE_TYPES = new Set<DatabaseType>(["oracle", "dameng", "firebird", "oceanbase-oracle", "access", "questdb", "victoriametrics"]);
export const CLEARABLE_QUERY_SCHEMA_TYPES = new Set<DatabaseType>(["oracle", "dameng", "gaussdb", "oceanbase-oracle"]);
@@ -140,7 +140,7 @@ export function supportsObjectBrowserTreeNode(dbType: DatabaseType | undefined,
}
export function supportsTableTruncate(dbType?: DatabaseType): boolean {
return !!dbType && dbType !== "sqlite" && dbType !== "rqlite" && dbType !== "turso" && dbType !== "cloudflare-d1" && dbType !== "duckdb" && dbType !== "influxdb" && dbType !== "manticoresearch";
return !!dbType && dbType !== "sqlite" && dbType !== "rqlite" && dbType !== "turso" && dbType !== "cloudflare-d1" && dbType !== "duckdb" && dbType !== "influxdb" && dbType !== "victoriametrics" && dbType !== "manticoresearch";
}
export function usesPostgresLikeStructureCopy(dbType?: DatabaseType): boolean {
@@ -81,6 +81,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
zookeeper: { deferred: "key-value namespaces are not databases" },
iris: { database: "schema" },
influxdb: { connection: "database" },
victoriametrics: { deferred: "metric namespaces are managed by VictoriaMetrics deployment configuration" },
jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" },
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" },
@@ -62,6 +62,7 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
["neo4j", TABLE_VIEW_OBJECTS],
// others
["influxdb", ["TABLE"]],
["victoriametrics", ["TABLE"]],
["hbase", ["TABLE"]],
["questdb", ["TABLE", "VIEW", "MATERIALIZED_VIEW"]],
["manticoresearch", ["TABLE", "FUNCTION"]],
@@ -81,6 +81,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
zookeeper: { deferred: "key-value namespaces are not databases" },
iris: { deferred: "schema properties need product-specific handling" },
influxdb: { deferred: "database retention policies need a dedicated workflow" },
victoriametrics: { deferred: "metric and retention settings are managed by VictoriaMetrics deployment configuration" },
jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" },
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" },
@@ -176,6 +176,18 @@ const DATABASE_CAPABILITY_OVERRIDES: Partial<Record<DatabaseType, Partial<Databa
readonly: true,
},
},
victoriametrics: {
tableData: {
insert: false,
updateRequiresPrimaryKey: false,
deleteRequiresPrimaryKey: true,
keylessRowPredicate: false,
requiresTransactionalTableForExistingRows: false,
existingRowsReadonly: true,
transaction: false,
readonly: true,
},
},
};
function defaultTableDataCapability(dbType?: DatabaseType): TableDataCapability {
+2 -1
View File
@@ -1,6 +1,6 @@
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
import { normalizeSqliteNamespace } from "@/lib/database/sqliteNamespace";
import { qualifiedTableName } from "@/lib/table/tableSelectSql";
import { metricRangeQuery, qualifiedTableName } from "@/lib/table/tableSelectSql";
import type { ConnectionConfig, DatabaseType, QueryTab, TreeNode } from "@/types/database";
export interface NewQueryTarget {
@@ -145,6 +145,7 @@ export function resolveNewQueryTable(input: ResolveNewQueryTableInput): NewQuery
* by the table-data view.
*/
export function buildSelectAllSql(databaseType: DatabaseType | undefined, table: Pick<NewQueryTable, "schema" | "catalog" | "tableName"> & Partial<Pick<NewQueryTable, "database">>): string {
if (databaseType === "victoriametrics") return metricRangeQuery(table.tableName);
const ref = qualifiedTableName({ databaseType, database: table.database, schema: table.schema, catalog: table.catalog, tableName: table.tableName });
return `SELECT * FROM ${ref}`;
}
@@ -22,7 +22,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", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j"]);
const NON_SQL_EXECUTION_TARGET_TYPES: ReadonlySet<DatabaseType> = new Set(["mongodb", "elasticsearch", "easysearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "neo4j", "victoriametrics"]);
export function supportsExecutionTargetPicker(databaseType?: DatabaseType): boolean {
return !!databaseType && (databaseType === "redis" || isElasticsearchCompatibleDatabaseType(databaseType) || !NON_SQL_EXECUTION_TARGET_TYPES.has(databaseType));
+1 -1
View File
@@ -56,7 +56,7 @@ export function tableClipboardMenuState(entries: TableClipboardTableContext[], t
}
export function supportsWholeRowTableDataCopy(databaseType: DatabaseType | undefined): boolean {
return !!databaseType;
return !!databaseType && databaseType !== "victoriametrics";
}
export function defaultPasteTableMode(databaseType: DatabaseType | undefined): PasteTableMode {
@@ -17,6 +17,18 @@ export interface FetchTableDataForExportOptions {
export async function fetchTableDataForExport(options: FetchTableDataForExportOptions): Promise<QueryResult> {
const pageSize = Math.max(1, options.pageSize ?? TABLE_DATA_EXPORT_PAGE_SIZE);
if (options.databaseType === "victoriametrics") {
const query = await (options.buildPageSql ?? buildTableSelectSql)({
databaseType: options.databaseType,
schema: options.schema,
tableName: options.tableName,
tableType: options.tableType,
columns: options.columns,
limit: pageSize,
offset: 0,
});
return options.executePage(query);
}
let offset = 0;
const rows: QueryResult["rows"] = [];
let columns: string[] = [];
@@ -79,6 +79,12 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
triggers: false,
ddl: false,
},
victoriametrics: {
indexes: false,
foreignKeys: false,
triggers: false,
ddl: false,
},
questdb: {
indexes: true,
foreignKeys: false,
@@ -96,11 +96,21 @@ export function qualifiedTableName(options: Pick<BuildTableSelectSqlOptions, "da
return quoteTableIdentifier(databaseType, tableName);
}
export function metricSelector(metricName: string): string {
const escaped = metricName.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\n", "\\n");
return `{__name__="${escaped}"}`;
}
export function metricRangeQuery(metricName: string, lookback = "1h"): string {
return `${metricSelector(metricName)}[${lookback}]`;
}
export function normalizeWhereInput(whereInput?: string): string {
const withoutSemicolon = whereInput?.trim().replace(/;+$/, "").trim() ?? "";
return withoutSemicolon.replace(/^where\b/i, "").trim();
}
export async function buildTableSelectSql(options: BuildTableSelectSqlOptions): Promise<string> {
if (options.databaseType === "victoriametrics") return metricRangeQuery(options.tableName);
return api.buildTableSelectSql(options);
}
@@ -1,5 +1,5 @@
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { qualifiedTableName, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
import { metricRangeQuery, qualifiedTableName, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql";
export interface TableSqlTemplateOptions {
databaseType?: DatabaseType;
@@ -13,6 +13,7 @@ export interface TableSqlTemplateOptions {
}
export function buildTableSelectTemplate(options: TableSqlTemplateOptions): string {
if (options.databaseType === "victoriametrics") return metricRangeQuery(options.tableName);
const tableName = templateTableName(options);
const columns = options.columns ?? [];
if (!columns.length) {
@@ -1011,6 +1011,7 @@ export const useConnectionStore = defineStore("connection", () => {
sundb: "科蓝 SUNDB",
oscar: "神通 OSCAR",
influxdb: "InfluxDB",
victoriametrics: "VictoriaMetrics",
};
const profile = config.driver_profile || config.db_type;
+1
View File
@@ -66,6 +66,7 @@ export type DatabaseType =
| "zookeeper"
| "iris"
| "influxdb"
| "victoriametrics"
| "jdbc"
| "mq"
| "mqtt"
@@ -0,0 +1,4 @@
export interface VictoriaMetricsExternalConfig {
apiPath: string;
lookback: string;
}