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

@@ -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) {