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

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