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:
1 parent
930aa48c20
commit
2ede64f1cb
64 files changed
+1293
-64
No files matched your search
+2
-1
@@ -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"');
|
||||
|
||||
+38
@@ -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" });
|
||||
});
|
||||
});
|
||||
+12
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user