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(