feat(hbase): add HBase REST support
This commit is contained in:
48 files changed
+2455
-19
No files matched your search
@@ -826,6 +826,7 @@ const driverProfiles: Record<
|
||||
label: "Elasticsearch",
|
||||
icon: "elasticsearch",
|
||||
},
|
||||
hbase: { type: "hbase", port: 8080, user: "", label: "Apache HBase", icon: "hbase" },
|
||||
qdrant: { type: "qdrant", port: 6333, user: "", label: "Qdrant", icon: "qdrant" },
|
||||
milvus: { type: "milvus", port: 19530, user: "root", label: "Milvus", icon: "milvus" },
|
||||
weaviate: { type: "weaviate", port: 8080, user: "", label: "Weaviate", icon: "weaviate" },
|
||||
@@ -2200,6 +2201,7 @@ const iconTypeMap: Record<string, string> = {
|
||||
sqlserver: "sqlserver",
|
||||
oracle: "oracle",
|
||||
elasticsearch: "elasticsearch",
|
||||
hbase: "hbase",
|
||||
qdrant: "qdrant",
|
||||
milvus: "milvus",
|
||||
weaviate: "weaviate",
|
||||
@@ -2279,6 +2281,7 @@ const dbOptions: DbOption[] = [
|
||||
{ value: "sqlite", label: "SQLite" },
|
||||
{ value: "sqlserver", label: "SQL Server" },
|
||||
{ value: "elasticsearch", label: "Elasticsearch" },
|
||||
{ value: "hbase", label: "Apache HBase" },
|
||||
{ value: "qdrant", label: "Qdrant" },
|
||||
{ value: "milvus", label: "Milvus" },
|
||||
{ value: "weaviate", label: "Weaviate" },
|
||||
@@ -2380,7 +2383,7 @@ const dbCategoryDefinitions: Array<{
|
||||
{
|
||||
key: "document",
|
||||
titleKey: "connection.databaseCategoryDocument",
|
||||
optionValues: ["mongodb", "redis", "elasticsearch", "manticoresearch", "cassandra"],
|
||||
optionValues: ["mongodb", "redis", "elasticsearch", "hbase", "manticoresearch", "cassandra"],
|
||||
},
|
||||
{
|
||||
key: "graph_ai",
|
||||
@@ -2495,10 +2498,10 @@ 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", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]);
|
||||
const tlsCapableDatabaseTypes = new Set<DatabaseType>(["mysql", "starrocks", "postgres", "redshift", "gaussdb", "kwdb", "opengauss", "questdb", "dameng", "redis", "etcd", "clickhouse", "elasticsearch", "hbase", "qdrant", "milvus", "weaviate", "chromadb", "influxdb"]);
|
||||
const supportsTlsToggle = computed(() => tlsCapableDatabaseTypes.has(form.value.db_type));
|
||||
const supportsCaCertificatePath = computed(() => form.value.db_type === "clickhouse");
|
||||
const supportsGenericUrlParams = computed(() => form.value.db_type !== "manticoresearch");
|
||||
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)));
|
||||
const supportsMysqlCleartextPasswordAuth = computed(() => form.value.db_type === "mysql" && !bareMysqlProfiles.has(selectedType.value));
|
||||
@@ -5902,7 +5905,7 @@ function openExternalUrl(url: string) {
|
||||
<PasswordInput v-model="form.password" class="col-span-3" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-4 items-center gap-4">
|
||||
<div v-if="form.db_type !== 'hbase'" class="grid grid-cols-4 items-center gap-4">
|
||||
<Label :class="connectionLabelClass">{{ databaseLabel }}</Label>
|
||||
<Input v-model="form.database" class="col-span-3" :placeholder="databasePlaceholder" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Braces, DatabaseZap, ListFilter, Loader2, Plus, RefreshCw, ScanSearch, TableProperties, Trash2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
|
||||
import ErrorBanner from "@/components/ui/ErrorBanner.vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
|
||||
|
||||
const props = defineProps<{
|
||||
tabId: string;
|
||||
connectionId: string;
|
||||
namespace: string;
|
||||
table: string;
|
||||
createTableOnOpen?: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
type LookupMode = "prefix" | "exact";
|
||||
|
||||
const rows = ref<HBaseRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const lookupMode = ref<LookupMode>("prefix");
|
||||
const rowKeyInput = ref("");
|
||||
const rowLimit = ref("100");
|
||||
const truncated = ref(false);
|
||||
const elapsedMs = ref(0);
|
||||
const schema = ref<HBaseTableSchema>();
|
||||
const schemaLoading = ref(false);
|
||||
const schemaDialogOpen = ref(false);
|
||||
const writeDialogOpen = ref(false);
|
||||
const writeJson = ref("");
|
||||
const writeLoading = ref(false);
|
||||
const writeError = ref("");
|
||||
const createTableDialogOpen = ref(false);
|
||||
const createTableName = ref("");
|
||||
const createColumnFamilies = ref("");
|
||||
const createTableLoading = ref(false);
|
||||
const createTableError = ref("");
|
||||
const deleteTableDialogOpen = ref(false);
|
||||
const deleteTableLoading = ref(false);
|
||||
const deleteTableError = ref("");
|
||||
|
||||
const hasTable = computed(() => props.table.trim().length > 0);
|
||||
const readOnly = computed(() => connectionStore.getConfig(props.connectionId)?.read_only === true);
|
||||
const qualifiedTableLabel = computed(() => {
|
||||
if (!hasTable.value) return props.namespace;
|
||||
return props.namespace && props.namespace !== "default" ? `${props.namespace}:${props.table}` : props.table;
|
||||
});
|
||||
|
||||
const gridColumns = computed(() => {
|
||||
const columns = new Set<string>();
|
||||
for (const row of rows.value) {
|
||||
for (const cell of row.cells) columns.add(cell.column);
|
||||
}
|
||||
return ["_row_key", ...Array.from(columns).sort((left, right) => left.localeCompare(right))];
|
||||
});
|
||||
|
||||
const gridResult = computed<QueryResult>(() => {
|
||||
const columns = gridColumns.value;
|
||||
const data = rows.value.map((row) => {
|
||||
const cells = new Map(row.cells.map((cell) => [cell.column, cell.value]));
|
||||
return columns.map((column) => (column === "_row_key" ? row.rowKey : (cells.get(column) ?? null)));
|
||||
});
|
||||
return {
|
||||
columns,
|
||||
column_types: columns.map((column) => (column === "_row_key" ? "ROW_KEY" : "HBASE_CELL")),
|
||||
column_sortables: columns.map(() => false),
|
||||
rows: data,
|
||||
affected_rows: data.length,
|
||||
execution_time_ms: elapsedMs.value,
|
||||
total_is_exact: !truncated.value,
|
||||
};
|
||||
});
|
||||
|
||||
const customSaveHandler = computed<CustomSaveHandler>(() => ({
|
||||
save: saveGridChanges,
|
||||
preview: previewGridChanges,
|
||||
supportsInsert: false,
|
||||
canInsert: false,
|
||||
canDelete: !readOnly.value,
|
||||
readonlyColumns: ["_row_key"],
|
||||
targetLabel: qualifiedTableLabel.value,
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => [props.connectionId, props.namespace, props.table] as const,
|
||||
() => {
|
||||
rows.value = [];
|
||||
schema.value = undefined;
|
||||
rowKeyInput.value = "";
|
||||
if (hasTable.value) void refreshRows();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.createTableOnOpen,
|
||||
(requested) => {
|
||||
if (!requested) return;
|
||||
openCreateTableDialog();
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === props.tabId);
|
||||
if (tab) tab.hbaseCreateTableOnOpen = undefined;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function refreshRows() {
|
||||
if (loading.value || !hasTable.value) return;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
if (lookupMode.value === "exact" && rowKeyInput.value) {
|
||||
const rowKey = encodeHBaseTextInput(rowKeyInput.value);
|
||||
const row = await api.hbaseGetRow(props.connectionId, props.namespace, props.table, rowKey.value, rowKey.encoding);
|
||||
rows.value = row ? [row] : [];
|
||||
truncated.value = false;
|
||||
} else {
|
||||
const result = await api.hbaseScanRows(props.connectionId, props.namespace, props.table, rowKeyInput.value || undefined, Number(rowLimit.value));
|
||||
rows.value = result.rows;
|
||||
truncated.value = result.truncated;
|
||||
}
|
||||
} catch (caught) {
|
||||
error.value = errorMessage(caught);
|
||||
} finally {
|
||||
elapsedMs.value = Math.round(performance.now() - startedAt);
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openSchemaDialog() {
|
||||
schemaDialogOpen.value = true;
|
||||
if (schema.value) return;
|
||||
schemaLoading.value = true;
|
||||
try {
|
||||
schema.value = await api.hbaseGetTableSchema(props.connectionId, props.namespace, props.table);
|
||||
} catch (caught) {
|
||||
error.value = errorMessage(caught);
|
||||
schemaDialogOpen.value = false;
|
||||
} finally {
|
||||
schemaLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openWriteDialog() {
|
||||
if (schemaLoading.value) return;
|
||||
error.value = "";
|
||||
schemaLoading.value = true;
|
||||
try {
|
||||
schema.value ??= await api.hbaseGetTableSchema(props.connectionId, props.namespace, props.table);
|
||||
} catch (caught) {
|
||||
error.value = errorMessage(caught);
|
||||
return;
|
||||
} finally {
|
||||
schemaLoading.value = false;
|
||||
}
|
||||
const family = schema.value.columnFamilies[0]?.name;
|
||||
if (!family) {
|
||||
error.value = t("hbase.noColumnFamilies");
|
||||
return;
|
||||
}
|
||||
writeJson.value = JSON.stringify(
|
||||
{
|
||||
rowKey: "",
|
||||
cells: {
|
||||
[`${family}:qualifier`]: "value",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
writeError.value = "";
|
||||
writeDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function openCreateTableDialog() {
|
||||
createTableError.value = "";
|
||||
createTableName.value = "";
|
||||
createColumnFamilies.value = "";
|
||||
createTableDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function writeRow() {
|
||||
if (writeLoading.value) return;
|
||||
writeLoading.value = true;
|
||||
writeError.value = "";
|
||||
try {
|
||||
const input = parseWriteInput(writeJson.value);
|
||||
await api.hbasePutRow(props.connectionId, props.namespace, props.table, input);
|
||||
writeDialogOpen.value = false;
|
||||
toast(t("hbase.rowSaved"));
|
||||
await refreshRows();
|
||||
} catch (caught) {
|
||||
writeError.value = errorMessage(caught);
|
||||
} finally {
|
||||
writeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createTable() {
|
||||
if (createTableLoading.value) return;
|
||||
const families = createColumnFamilies.value
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
createTableLoading.value = true;
|
||||
createTableError.value = "";
|
||||
try {
|
||||
const createdTable = createTableName.value.trim();
|
||||
await api.hbaseCreateTable(props.connectionId, props.namespace, createdTable, families);
|
||||
createTableDialogOpen.value = false;
|
||||
toast(t("hbase.tableCreated", { table: createdTable }));
|
||||
await connectionStore.loadTables(props.connectionId, props.namespace);
|
||||
if (!hasTable.value) {
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === props.tabId);
|
||||
if (tab) tab.title = createdTable;
|
||||
queryStore.updateSql(props.tabId, createdTable);
|
||||
}
|
||||
} catch (caught) {
|
||||
createTableError.value = errorMessage(caught);
|
||||
} finally {
|
||||
createTableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTable() {
|
||||
if (deleteTableLoading.value) return;
|
||||
deleteTableLoading.value = true;
|
||||
deleteTableError.value = "";
|
||||
try {
|
||||
await api.hbaseDeleteTable(props.connectionId, props.namespace, props.table);
|
||||
deleteTableDialogOpen.value = false;
|
||||
await connectionStore.loadTables(props.connectionId, props.namespace);
|
||||
queryStore.closeTab(props.tabId);
|
||||
toast(t("hbase.tableDeleted", { table: qualifiedTableLabel.value }));
|
||||
} catch (caught) {
|
||||
deleteTableError.value = errorMessage(caught);
|
||||
} finally {
|
||||
deleteTableLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGridChanges(changes: { dirtyRows: Map<number, Map<number, CellValue>>; deletedRows: Set<number>; columns: string[] }) {
|
||||
for (const [rowIndex, dirtyColumns] of changes.dirtyRows) {
|
||||
const row = rows.value[rowIndex];
|
||||
if (!row) continue;
|
||||
const cells: HBaseCellInput[] = [];
|
||||
for (const [columnIndex, value] of dirtyColumns) {
|
||||
const column = changes.columns[columnIndex];
|
||||
if (!column || column === "_row_key") continue;
|
||||
cells.push(hbaseCellInput(column, value));
|
||||
}
|
||||
if (cells.length > 0) {
|
||||
await api.hbasePutRow(props.connectionId, props.namespace, props.table, {
|
||||
rowKey: row.rowKeyBase64,
|
||||
rowKeyEncoding: "base64",
|
||||
cells,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const rowIndex of changes.deletedRows) {
|
||||
const row = rows.value[rowIndex];
|
||||
if (!row) continue;
|
||||
await api.hbaseDeleteRow(props.connectionId, props.namespace, props.table, row.rowKeyBase64, "base64");
|
||||
}
|
||||
}
|
||||
|
||||
async function previewGridChanges(changes: { dirtyRows: Map<number, Map<number, CellValue>>; deletedRows: Set<number>; columns: string[] }) {
|
||||
const statements: string[] = [];
|
||||
for (const [rowIndex, dirtyColumns] of changes.dirtyRows) {
|
||||
const row = rows.value[rowIndex];
|
||||
if (!row) continue;
|
||||
const columns = Array.from(dirtyColumns.keys())
|
||||
.map((index) => changes.columns[index])
|
||||
.filter((column) => column && column !== "_row_key");
|
||||
if (columns.length > 0) statements.push(`PUT ${qualifiedTableLabel.value}/${row.rowKey} (${columns.join(", ")})`);
|
||||
}
|
||||
for (const rowIndex of changes.deletedRows) {
|
||||
const row = rows.value[rowIndex];
|
||||
if (row) statements.push(`DELETE ${qualifiedTableLabel.value}/${row.rowKey}`);
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
function parseWriteInput(source: string): HBasePutRowInput {
|
||||
const value = JSON.parse(source) as { rowKey?: unknown; cells?: unknown };
|
||||
const rowKey = encodedJsonValue(value.rowKey, "rowKey");
|
||||
if (!value.cells || typeof value.cells !== "object" || Array.isArray(value.cells)) {
|
||||
throw new Error(t("hbase.cellsObjectRequired"));
|
||||
}
|
||||
const cells = Object.entries(value.cells as Record<string, unknown>).map(([column, cellValue]) => {
|
||||
const encoded = encodedJsonValue(cellValue, column);
|
||||
return { column, value: encoded.value, valueEncoding: encoded.encoding };
|
||||
});
|
||||
if (cells.length === 0) throw new Error(t("hbase.cellRequired"));
|
||||
return { rowKey: rowKey.value, rowKeyEncoding: rowKey.encoding, cells };
|
||||
}
|
||||
|
||||
function encodedJsonValue(value: unknown, label: string): { value: string; encoding: HBaseValueEncoding } {
|
||||
if (typeof value === "string") {
|
||||
return value.startsWith("base64:") ? { value: value.slice(7), encoding: "base64" } : { value, encoding: "utf8" };
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value) && typeof (value as { base64?: unknown }).base64 === "string") {
|
||||
return { value: (value as { base64: string }).base64, encoding: "base64" };
|
||||
}
|
||||
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
||||
return { value: value === null ? "" : String(value), encoding: "utf8" };
|
||||
}
|
||||
throw new Error(t("hbase.invalidJsonValue", { label }));
|
||||
}
|
||||
|
||||
function errorMessage(value: unknown): string {
|
||||
return value instanceof Error ? value.message : String(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col bg-background">
|
||||
<div class="flex min-h-10 shrink-0 flex-wrap items-center gap-2 border-b px-2 py-1.5">
|
||||
<div class="flex min-w-0 items-center gap-1.5 pr-1">
|
||||
<DatabaseZap class="h-4 w-4 shrink-0 text-primary" />
|
||||
<span class="truncate text-xs font-medium">{{ qualifiedTableLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasTable" class="flex h-7 shrink-0 items-center rounded border bg-muted/40 p-0.5">
|
||||
<button type="button" class="flex h-6 items-center gap-1 rounded-sm px-2 text-xs transition-colors" :class="lookupMode === 'prefix' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="lookupMode = 'prefix'">
|
||||
<ListFilter class="h-3.5 w-3.5" />
|
||||
{{ t("hbase.prefix") }}
|
||||
</button>
|
||||
<button type="button" class="flex h-6 items-center gap-1 rounded-sm px-2 text-xs transition-colors" :class="lookupMode === 'exact' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'" @click="lookupMode = 'exact'">
|
||||
<ScanSearch class="h-3.5 w-3.5" />
|
||||
{{ t("hbase.exact") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input v-if="hasTable" v-model="rowKeyInput" class="h-7 min-w-40 flex-1 text-xs sm:max-w-80" :placeholder="lookupMode === 'prefix' ? t('hbase.rowKeyPrefix') : t('hbase.rowKey')" @keydown.enter="refreshRows" />
|
||||
|
||||
<Select v-if="hasTable && lookupMode === 'prefix'" v-model="rowLimit">
|
||||
<SelectTrigger class="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50 {{ t("hbase.rows") }}</SelectItem>
|
||||
<SelectItem value="100">100 {{ t("hbase.rows") }}</SelectItem>
|
||||
<SelectItem value="200">200 {{ t("hbase.rows") }}</SelectItem>
|
||||
<SelectItem value="500">500 {{ t("hbase.rows") }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button v-if="hasTable" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="loading" @click="refreshRows">
|
||||
<Loader2 v-if="loading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<RefreshCw v-else class="h-3.5 w-3.5" />
|
||||
{{ t("grid.refresh") }}
|
||||
</Button>
|
||||
<Button v-if="hasTable" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="openSchemaDialog">
|
||||
<Braces class="h-3.5 w-3.5" />
|
||||
{{ t("hbase.schema") }}
|
||||
</Button>
|
||||
<Button v-if="hasTable && !readOnly" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" :disabled="schemaLoading" @click="openWriteDialog">
|
||||
<Loader2 v-if="schemaLoading" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Plus v-else class="h-3.5 w-3.5" />
|
||||
{{ t("hbase.writeRow") }}
|
||||
</Button>
|
||||
<Button v-if="hasTable && !readOnly" variant="outline" size="sm" class="h-7 gap-1.5 px-2 text-xs" @click="openCreateTableDialog">
|
||||
<TableProperties class="h-3.5 w-3.5" />
|
||||
{{ t("hbase.createTable") }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasTable && !readOnly"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="h-7 w-7 text-destructive"
|
||||
:aria-label="t('hbase.deleteTable')"
|
||||
@click="
|
||||
deleteTableError = '';
|
||||
deleteTableDialogOpen = true;
|
||||
"
|
||||
>
|
||||
<Trash2 class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="hasTable" class="flex h-7 shrink-0 items-center gap-2 border-b px-3 text-[11px] text-muted-foreground">
|
||||
<span>{{ t("hbase.loadedRows", { count: rows.length }) }}</span>
|
||||
<span>{{ elapsedMs }} ms</span>
|
||||
<span v-if="truncated" class="text-amber-600 dark:text-amber-400">{{ t("hbase.resultTruncated") }}</span>
|
||||
</div>
|
||||
|
||||
<ErrorBanner v-if="error" :message="error" dismissible @dismiss="error = ''" />
|
||||
<DataGrid
|
||||
v-if="hasTable"
|
||||
class="min-h-0 flex-1"
|
||||
:result="gridResult"
|
||||
context="results"
|
||||
database-type="hbase"
|
||||
:editable="!readOnly"
|
||||
:custom-save-handler="customSaveHandler"
|
||||
:allow-insert-rows="false"
|
||||
:allow-delete-rows="!readOnly"
|
||||
:loading="loading"
|
||||
:page-limit="Number(rowLimit)"
|
||||
:total-row-count="rows.length"
|
||||
:total-row-count-is-exact="!truncated"
|
||||
@reload="refreshRows"
|
||||
/>
|
||||
<div v-else class="flex min-h-0 flex-1 items-center justify-center">
|
||||
<Button v-if="!readOnly" variant="outline" class="gap-2" @click="openCreateTableDialog">
|
||||
<TableProperties class="h-4 w-4" />
|
||||
{{ t("hbase.createTable") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog v-model:open="schemaDialogOpen">
|
||||
<DialogContent class="max-h-[80vh] max-w-2xl overflow-hidden p-0">
|
||||
<DialogHeader class="border-b px-4 py-3">
|
||||
<DialogTitle class="text-sm">{{ t("hbase.tableSchema", { table: qualifiedTableLabel }) }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div class="min-h-0 overflow-auto px-4 py-3">
|
||||
<div v-if="schemaLoading" class="flex min-h-32 items-center justify-center">
|
||||
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<div v-else-if="schema" class="space-y-4 text-xs">
|
||||
<section v-for="family in schema.columnFamilies" :key="family.name" class="border-b pb-3 last:border-b-0">
|
||||
<div class="mb-2 font-medium">{{ family.name }}</div>
|
||||
<dl class="grid grid-cols-[minmax(8rem,auto)_1fr] gap-x-4 gap-y-1 font-mono text-[11px]">
|
||||
<template v-for="(value, key) in family.properties" :key="key">
|
||||
<dt class="text-muted-foreground">{{ key }}</dt>
|
||||
<dd class="break-all">{{ value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="writeDialogOpen">
|
||||
<DialogContent class="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="text-sm">{{ t("hbase.writeRow") }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ErrorBanner v-if="writeError" :message="writeError" dismissible @dismiss="writeError = ''" />
|
||||
<textarea v-model="writeJson" class="min-h-64 w-full resize-y rounded border bg-muted/20 p-3 font-mono text-xs outline-none focus:ring-1 focus:ring-ring" spellcheck="false" />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="writeLoading" @click="writeDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="writeLoading" @click="writeRow">
|
||||
<Loader2 v-if="writeLoading" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
{{ t("common.save") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog v-model:open="createTableDialogOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="text-sm">{{ t("hbase.createTableIn", { namespace }) }}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ErrorBanner v-if="createTableError" :message="createTableError" dismissible @dismiss="createTableError = ''" />
|
||||
<div class="space-y-3">
|
||||
<Input v-model="createTableName" :placeholder="t('hbase.tableName')" />
|
||||
<Input v-model="createColumnFamilies" :placeholder="t('hbase.columnFamiliesPlaceholder')" @keydown.enter="createTable" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="createTableLoading" @click="createTableDialogOpen = false">{{ t("common.cancel") }}</Button>
|
||||
<Button :disabled="createTableLoading || !createTableName.trim() || !createColumnFamilies.trim()" @click="createTable">
|
||||
<Loader2 v-if="createTableLoading" class="mr-1.5 h-4 w-4 animate-spin" />
|
||||
{{ t("hbase.create") }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DangerConfirmDialog
|
||||
v-model:open="deleteTableDialogOpen"
|
||||
:title="t('hbase.deleteTable')"
|
||||
:message="t('hbase.deleteTableConfirm', { table: qualifiedTableLabel })"
|
||||
:details="qualifiedTableLabel"
|
||||
:confirm-label="t('common.delete')"
|
||||
:loading="deleteTableLoading"
|
||||
:close-on-confirm="false"
|
||||
@confirm="deleteTable"
|
||||
>
|
||||
<template #options>
|
||||
<ErrorBanner v-if="deleteTableError" :message="deleteTableError" dismissible class="mb-3" @dismiss="deleteTableError = ''" />
|
||||
</template>
|
||||
</DangerConfirmDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -50,6 +50,7 @@ const DocumentBrowser = defineAsyncComponent(() => import("@/components/document
|
||||
const MongoGridFsBrowser = defineAsyncComponent(() => import("@/components/document/MongoGridFsBrowser.vue"));
|
||||
const MongoBucketBrowser = defineAsyncComponent(() => import("@/components/document/MongoBucketBrowser.vue"));
|
||||
const VectorBrowser = defineAsyncComponent(() => import("@/components/vector/VectorBrowser.vue"));
|
||||
const HBaseBrowser = defineAsyncComponent(() => import("@/components/hbase/HBaseBrowser.vue"));
|
||||
const ElasticsearchJsonResponsePanel = defineAsyncComponent(() => import("@/components/common/ElasticsearchJsonResponsePanel.vue"));
|
||||
const MqAdminConsole = defineAsyncComponent(() => import("@/components/mq/MqAdminConsole.vue"));
|
||||
const NacosAdminConsole = defineAsyncComponent(() => import("@/components/nacos/NacosAdminConsole.vue"));
|
||||
@@ -1812,6 +1813,12 @@ defineExpose({ focusSearch, refreshData, refreshQueryEditorCompletionCache, hand
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'hbase'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<HBaseBrowser :key="activeTab.id" :tab-id="activeTab.id" :connection-id="activeTab.connectionId" :namespace="activeTab.database" :table="activeTab.sql" :create-table-on-open="activeTab.hbaseCreateTableOnOpen" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab.mode === 'mq'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<MqAdminConsole :key="activeTab.id" :connection-id="activeTab.connectionId" :initial-tenant="activeTab.mqTenant" :initial-tab="activeTab.mqInitialTab" :read-only="activeConnection?.read_only ?? false" />
|
||||
|
||||
@@ -2044,7 +2044,13 @@ async function confirmBatchEmpty() {
|
||||
|
||||
const canCreateTable = computed(() => {
|
||||
const config = activeNode.value.connectionId ? connectionStore.getConfig(activeNode.value.connectionId) : undefined;
|
||||
return (activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") && !isSqlServerLinkedNode(activeNode.value) && !!activeNode.value.database && supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config));
|
||||
const supportsHBaseTableCreation = config?.db_type === "hbase" && !config.read_only;
|
||||
return (
|
||||
(activeNode.value.type === "database" || activeNode.value.type === "schema" || activeNode.value.type === "group-tables") &&
|
||||
!isSqlServerLinkedNode(activeNode.value) &&
|
||||
!!activeNode.value.database &&
|
||||
(supportsHBaseTableCreation || supportsTableStructureEditing(tableStructureDatabaseTypeForConnection(config)))
|
||||
);
|
||||
});
|
||||
|
||||
const canCreateDatabase = computed(() => {
|
||||
@@ -2905,6 +2911,12 @@ function openPasteTableDialog() {
|
||||
function createTable() {
|
||||
const node = activeNode.value;
|
||||
if (!node.connectionId || !node.database) return;
|
||||
if (connectionStore.getConfig(node.connectionId)?.db_type === "hbase") {
|
||||
const tabId = queryStore.createTab(node.connectionId, node.database, node.database, "hbase", undefined, "");
|
||||
const tab = queryStore.tabs.find((candidate) => candidate.id === tabId);
|
||||
if (tab) tab.hbaseCreateTableOnOpen = true;
|
||||
return;
|
||||
}
|
||||
queryStore.openTableStructure(node.connectionId, node.database, node.schema, "");
|
||||
}
|
||||
|
||||
@@ -3694,6 +3706,20 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
const { node, items } = context;
|
||||
// 4. Database / Schema
|
||||
if (node.type === "database" || node.type === "schema") {
|
||||
if (currentDatabaseType() === "hbase") {
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
if (canCreateTable.value) {
|
||||
items.push({ label: "", separator: true });
|
||||
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
|
||||
}
|
||||
items.push({
|
||||
label: t("contextMenu.refreshChildren"),
|
||||
action: refresh,
|
||||
icon: RefreshCw,
|
||||
shortcut: shortcutRefresh,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (canCloseDatabaseConnection.value) {
|
||||
items.push({ label: t("contextMenu.closeDatabaseConnection"), action: closeDatabaseConnection, icon: Unplug });
|
||||
items.push({ label: "", separator: true });
|
||||
@@ -3703,9 +3729,11 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
if (canOpenObjectBrowser.value) {
|
||||
items.push({ label: t("contextMenu.openObjectBrowser"), action: openObjectBrowser, icon: TableProperties });
|
||||
}
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
const sqlHistoryMenu = savedSqlHistorySubmenu();
|
||||
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
|
||||
if (supportsConnectionQueryActions(currentDatabaseType())) {
|
||||
items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare });
|
||||
const sqlHistoryMenu = savedSqlHistorySubmenu();
|
||||
if (sqlHistoryMenu) items.push(sqlHistoryMenu);
|
||||
}
|
||||
if (node.type === "database" && currentDatabaseType() !== "cloudflare-d1") {
|
||||
if (!isNodeDefaultDatabase.value) {
|
||||
items.push({ label: t("contextMenu.setDefaultDatabase"), action: setNodeAsDefaultDatabase, icon: Database });
|
||||
@@ -3789,6 +3817,40 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean {
|
||||
const { node, items } = context;
|
||||
// 5. Redis DB / Mongo DB
|
||||
if (currentDatabaseType() === "hbase" && node.type === "group-tables") {
|
||||
if (canCreateTable.value) {
|
||||
items.push({ label: t("contextMenu.createTable"), action: createTable, icon: Plus });
|
||||
items.push({ label: "", separator: true });
|
||||
}
|
||||
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
|
||||
items.push({
|
||||
label: t("contextMenu.refreshChildren"),
|
||||
action: refresh,
|
||||
icon: RefreshCw,
|
||||
shortcut: shortcutRefresh,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (currentDatabaseType() === "hbase" && 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,
|
||||
shortcut: shortcutOpenDataInNewTab.value,
|
||||
});
|
||||
items.push({
|
||||
label: t("contextMenu.refreshChildren"),
|
||||
action: refresh,
|
||||
icon: RefreshCw,
|
||||
shortcut: shortcutRefresh,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (node.type === "etcd-root" || node.type === "etcd-dashboard" || node.type === "zookeeper-root") {
|
||||
items.push({ label: t("contextMenu.openConnection"), action: toggle, icon: Database });
|
||||
return true;
|
||||
|
||||
@@ -30,6 +30,12 @@ export function useSidebarDataOpenRuntime() {
|
||||
async function openData(node: TreeNode, request?: SidebarDataOpenRequest, openMode: DataTabOpenMode = "default") {
|
||||
if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return;
|
||||
const config = connectionStore.getConfig(node.connectionId);
|
||||
if (config?.db_type === "hbase") {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
const tabId = queryStore.createTab(node.connectionId, node.database, node.label, "hbase", undefined, node.label, undefined, { forceNew: openMode === "new-tab" });
|
||||
queryStore.updateSql(tabId, node.label);
|
||||
return;
|
||||
}
|
||||
const traceId = uuid().slice(0, 8);
|
||||
const startedAt = performance.now();
|
||||
let lastPhaseAt = startedAt;
|
||||
|
||||
@@ -3124,6 +3124,32 @@ export default {
|
||||
vectorLabel: "Vector",
|
||||
vectorDimensionRequired: "Collection dimensions are not available yet. Enter a vector matching the collection dimension.",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "Prefix",
|
||||
exact: "Exact",
|
||||
rowKeyPrefix: "Row key prefix",
|
||||
rowKey: "Row key",
|
||||
rows: "rows",
|
||||
schema: "Schema",
|
||||
writeRow: "Write row",
|
||||
loadedRows: "{count} rows loaded",
|
||||
resultTruncated: "More rows are available",
|
||||
tableSchema: "Schema: {table}",
|
||||
rowSaved: "HBase row saved",
|
||||
cellsObjectRequired: "cells must be a JSON object",
|
||||
cellRequired: "At least one cell is required",
|
||||
noColumnFamilies: "This HBase table has no column families",
|
||||
invalidJsonValue: "Invalid value for {label}; use text, a primitive, or base64:...",
|
||||
createTable: "Create table",
|
||||
create: "Create",
|
||||
tableCreated: "HBase table {table} created",
|
||||
tableDeleted: "HBase table {table} deleted",
|
||||
createTableIn: "Create table in {namespace}",
|
||||
tableName: "Table name",
|
||||
columnFamiliesPlaceholder: "Column families, comma-separated",
|
||||
deleteTable: "Delete table",
|
||||
deleteTableConfirm: "Delete HBase table {table}? All rows will be permanently removed.",
|
||||
},
|
||||
history: {
|
||||
title: "History",
|
||||
search: "Search history...",
|
||||
|
||||
@@ -2981,6 +2981,32 @@ export default withEnglishFallback({
|
||||
vectorLabel: "Vector",
|
||||
vectorDimensionRequired: "La dimensión de la colección aún no está disponible. Introduce un vector con la dimensión correcta.",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "Prefijo",
|
||||
exact: "Exacta",
|
||||
rowKeyPrefix: "Prefijo de clave de fila",
|
||||
rowKey: "Clave de fila",
|
||||
rows: "filas",
|
||||
schema: "Esquema",
|
||||
writeRow: "Escribir fila",
|
||||
loadedRows: "{count} filas cargadas",
|
||||
resultTruncated: "Hay más filas disponibles",
|
||||
tableSchema: "Esquema: {table}",
|
||||
rowSaved: "Fila de HBase guardada",
|
||||
cellsObjectRequired: "cells debe ser un objeto JSON",
|
||||
cellRequired: "Se requiere al menos una celda",
|
||||
noColumnFamilies: "Esta tabla HBase no tiene familias de columnas",
|
||||
invalidJsonValue: "Valor no válido para {label}; usa texto, un valor primitivo o base64:...",
|
||||
createTable: "Crear tabla",
|
||||
create: "Crear",
|
||||
tableCreated: "Tabla HBase {table} creada",
|
||||
tableDeleted: "Tabla HBase {table} eliminada",
|
||||
createTableIn: "Crear tabla en {namespace}",
|
||||
tableName: "Nombre de tabla",
|
||||
columnFamiliesPlaceholder: "Familias de columnas, separadas por comas",
|
||||
deleteTable: "Eliminar tabla",
|
||||
deleteTableConfirm: "¿Eliminar la tabla HBase {table}? Todas las filas se eliminarán permanentemente.",
|
||||
},
|
||||
history: {
|
||||
title: "Historial",
|
||||
search: "Buscar en historial...",
|
||||
|
||||
@@ -2979,6 +2979,32 @@ export default withEnglishFallback({
|
||||
vectorLabel: "Vettore",
|
||||
vectorDimensionRequired: "La dimensione della collezione non è ancora disponibile. Inserisci un vettore con la dimensione corretta.",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "Prefisso",
|
||||
exact: "Esatta",
|
||||
rowKeyPrefix: "Prefisso chiave riga",
|
||||
rowKey: "Chiave riga",
|
||||
rows: "righe",
|
||||
schema: "Schema",
|
||||
writeRow: "Scrivi riga",
|
||||
loadedRows: "{count} righe caricate",
|
||||
resultTruncated: "Sono disponibili altre righe",
|
||||
tableSchema: "Schema: {table}",
|
||||
rowSaved: "Riga HBase salvata",
|
||||
cellsObjectRequired: "cells deve essere un oggetto JSON",
|
||||
cellRequired: "È richiesta almeno una cella",
|
||||
noColumnFamilies: "Questa tabella HBase non ha famiglie di colonne",
|
||||
invalidJsonValue: "Valore non valido per {label}; usa testo, un valore primitivo o base64:...",
|
||||
createTable: "Crea tabella",
|
||||
create: "Crea",
|
||||
tableCreated: "Tabella HBase {table} creata",
|
||||
tableDeleted: "Tabella HBase {table} eliminata",
|
||||
createTableIn: "Crea tabella in {namespace}",
|
||||
tableName: "Nome tabella",
|
||||
columnFamiliesPlaceholder: "Famiglie di colonne, separate da virgole",
|
||||
deleteTable: "Elimina tabella",
|
||||
deleteTableConfirm: "Eliminare la tabella HBase {table}? Tutte le righe verranno eliminate definitivamente.",
|
||||
},
|
||||
history: {
|
||||
title: "Cronologia",
|
||||
search: "Cerca nella cronologia...",
|
||||
|
||||
@@ -2980,6 +2980,32 @@ export default withEnglishFallback({
|
||||
vectorLabel: "ベクトル",
|
||||
vectorDimensionRequired: "コレクションの次元をまだ取得できません。次元が一致するベクトルを入力してください。",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "プレフィックス",
|
||||
exact: "完全一致",
|
||||
rowKeyPrefix: "行キープレフィックス",
|
||||
rowKey: "行キー",
|
||||
rows: "行",
|
||||
schema: "スキーマ",
|
||||
writeRow: "行を書き込む",
|
||||
loadedRows: "{count} 行を読み込みました",
|
||||
resultTruncated: "さらに行があります",
|
||||
tableSchema: "スキーマ: {table}",
|
||||
rowSaved: "HBase 行を保存しました",
|
||||
cellsObjectRequired: "cells は JSON オブジェクトである必要があります",
|
||||
cellRequired: "少なくとも 1 つのセルが必要です",
|
||||
noColumnFamilies: "この HBase テーブルには列ファミリーがありません",
|
||||
invalidJsonValue: "{label} の値が無効です。テキスト、プリミティブ値、または base64:... を使用してください",
|
||||
createTable: "テーブルを作成",
|
||||
create: "作成",
|
||||
tableCreated: "HBase テーブル {table} を作成しました",
|
||||
tableDeleted: "HBase テーブル {table} を削除しました",
|
||||
createTableIn: "{namespace} にテーブルを作成",
|
||||
tableName: "テーブル名",
|
||||
columnFamiliesPlaceholder: "列ファミリー(カンマ区切り)",
|
||||
deleteTable: "テーブルを削除",
|
||||
deleteTableConfirm: "HBase テーブル {table} を削除しますか?すべての行が完全に削除されます。",
|
||||
},
|
||||
history: {
|
||||
title: "履歴",
|
||||
search: "履歴を検索...",
|
||||
|
||||
@@ -2981,6 +2981,32 @@ export default withEnglishFallback({
|
||||
vectorLabel: "Vetor",
|
||||
vectorDimensionRequired: "A dimensão da coleção ainda não está disponível. Insira um vetor com a dimensão correta.",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "Prefixo",
|
||||
exact: "Exata",
|
||||
rowKeyPrefix: "Prefixo da chave da linha",
|
||||
rowKey: "Chave da linha",
|
||||
rows: "linhas",
|
||||
schema: "Esquema",
|
||||
writeRow: "Gravar linha",
|
||||
loadedRows: "{count} linhas carregadas",
|
||||
resultTruncated: "Há mais linhas disponíveis",
|
||||
tableSchema: "Esquema: {table}",
|
||||
rowSaved: "Linha do HBase salva",
|
||||
cellsObjectRequired: "cells deve ser um objeto JSON",
|
||||
cellRequired: "É necessária pelo menos uma célula",
|
||||
noColumnFamilies: "Esta tabela HBase não possui famílias de colunas",
|
||||
invalidJsonValue: "Valor inválido para {label}; use texto, um valor primitivo ou base64:...",
|
||||
createTable: "Criar tabela",
|
||||
create: "Criar",
|
||||
tableCreated: "Tabela HBase {table} criada",
|
||||
tableDeleted: "Tabela HBase {table} excluída",
|
||||
createTableIn: "Criar tabela em {namespace}",
|
||||
tableName: "Nome da tabela",
|
||||
columnFamiliesPlaceholder: "Famílias de colunas, separadas por vírgulas",
|
||||
deleteTable: "Excluir tabela",
|
||||
deleteTableConfirm: "Excluir a tabela HBase {table}? Todas as linhas serão removidas permanentemente.",
|
||||
},
|
||||
history: {
|
||||
title: "Histórico",
|
||||
search: "Pesquisar histórico...",
|
||||
|
||||
@@ -3124,6 +3124,32 @@ export default withEnglishFallback({
|
||||
vectorLabel: "向量",
|
||||
vectorDimensionRequired: "暂未获取到集合维度,请先输入与集合维度一致的向量。",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "前缀",
|
||||
exact: "精确",
|
||||
rowKeyPrefix: "Row Key 前缀",
|
||||
rowKey: "Row Key",
|
||||
rows: "行",
|
||||
schema: "Schema",
|
||||
writeRow: "写入行",
|
||||
loadedRows: "已加载 {count} 行",
|
||||
resultTruncated: "仍有更多数据",
|
||||
tableSchema: "Schema:{table}",
|
||||
rowSaved: "HBase 行已保存",
|
||||
cellsObjectRequired: "cells 必须是 JSON 对象",
|
||||
cellRequired: "至少需要一个单元格",
|
||||
noColumnFamilies: "当前 HBase 表没有列族",
|
||||
invalidJsonValue: "{label} 的值无效,请使用文本、基础类型或 base64:...",
|
||||
createTable: "新建表",
|
||||
create: "创建",
|
||||
tableCreated: "HBase 表 {table} 已创建",
|
||||
tableDeleted: "HBase 表 {table} 已删除",
|
||||
createTableIn: "在 {namespace} 中新建表",
|
||||
tableName: "表名",
|
||||
columnFamiliesPlaceholder: "列族,使用逗号分隔",
|
||||
deleteTable: "删除表",
|
||||
deleteTableConfirm: "确定删除 HBase 表 {table} 吗?其中所有行都会被永久删除。",
|
||||
},
|
||||
history: {
|
||||
title: "历史",
|
||||
search: "搜索历史...",
|
||||
|
||||
@@ -2652,6 +2652,32 @@ export default withEnglishFallback({
|
||||
vectorLabel: "向量",
|
||||
vectorDimensionRequired: "尚未取得集合維度,請先輸入與集合維度一致的向量。",
|
||||
},
|
||||
hbase: {
|
||||
prefix: "前綴",
|
||||
exact: "精確",
|
||||
rowKeyPrefix: "Row Key 前綴",
|
||||
rowKey: "Row Key",
|
||||
rows: "列",
|
||||
schema: "Schema",
|
||||
writeRow: "寫入資料列",
|
||||
loadedRows: "已載入 {count} 列",
|
||||
resultTruncated: "仍有更多資料",
|
||||
tableSchema: "Schema:{table}",
|
||||
rowSaved: "HBase 資料列已儲存",
|
||||
cellsObjectRequired: "cells 必須是 JSON 物件",
|
||||
cellRequired: "至少需要一個儲存格",
|
||||
noColumnFamilies: "目前 HBase 資料表沒有欄族",
|
||||
invalidJsonValue: "{label} 的值無效,請使用文字、基本型別或 base64:...",
|
||||
createTable: "建立資料表",
|
||||
create: "建立",
|
||||
tableCreated: "HBase 資料表 {table} 已建立",
|
||||
tableDeleted: "HBase 資料表 {table} 已刪除",
|
||||
createTableIn: "在 {namespace} 中建立資料表",
|
||||
tableName: "資料表名稱",
|
||||
columnFamiliesPlaceholder: "欄族,以逗號分隔",
|
||||
deleteTable: "刪除資料表",
|
||||
deleteTableConfirm: "確定要刪除 HBase 資料表 {table} 嗎?其中所有資料列都會永久刪除。",
|
||||
},
|
||||
history: {
|
||||
title: "歷史",
|
||||
search: "搜尋歷史……",
|
||||
|
||||
@@ -98,6 +98,7 @@ describe("database property editing", () => {
|
||||
expect(editableDatabasePropertyGroups({ db_type: "mysql", read_only: true }, { type: "database", database: "app" })).toEqual([]);
|
||||
expect(editableDatabasePropertyGroups({ db_type: "sqlite" }, { type: "database", database: "main" })).toEqual([]);
|
||||
expect(editableDatabasePropertyGroups({ db_type: "sqlserver" }, { type: "database", database: "master" })).toEqual([]);
|
||||
expect(editableDatabasePropertyGroups({ db_type: "hbase" }, { type: "database", database: "default" })).toEqual([]);
|
||||
expect(editableDatabasePropertyGroups({ db_type: "postgres" }, { type: "connection" })).toEqual([]);
|
||||
expect(editableSchemaPropertyGroups({ db_type: "postgres", read_only: true }, { type: "schema", database: "postgres", schema: "public" })).toEqual([]);
|
||||
expect(editableSchemaPropertyGroups({ db_type: "postgres" }, { type: "database", database: "postgres" })).toEqual([]);
|
||||
@@ -137,6 +138,7 @@ describe("database namespace creation", () => {
|
||||
expect(connectionNamespaceCreationTarget({ db_type: "sqlite", read_only: true })).toBeNull();
|
||||
expect(connectionNamespaceCreationTarget({ db_type: "jdbc" })).toBeNull();
|
||||
expect(connectionNamespaceCreationTarget({ db_type: "oracle" })).toBeNull();
|
||||
expect(connectionNamespaceCreationTarget({ db_type: "hbase" })).toBeNull();
|
||||
});
|
||||
|
||||
it("allows schema creation only on writable database nodes with schema targets", () => {
|
||||
|
||||
@@ -6,6 +6,10 @@ describe("databaseObjectCapabilities", () => {
|
||||
expect(sidebarObjectKindsForDatabase("dameng")).toContain("MATERIALIZED_VIEW");
|
||||
});
|
||||
|
||||
it("exposes only tables for HBase namespaces", () => {
|
||||
expect(sidebarObjectKindsForDatabase("hbase")).toEqual(["TABLE"]);
|
||||
});
|
||||
|
||||
it("exposes materialized views for StarRocks only", () => {
|
||||
// StarRocks has a dedicated MV listing/classification path in
|
||||
// crates/dbx-core/src/db/mysql.rs (`list_starrocks_tables` +
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
|
||||
|
||||
describe("HBase value conversion", () => {
|
||||
it("keeps UTF-8 input unchanged", () => {
|
||||
expect(encodeHBaseTextInput("customer#001")).toEqual({ value: "customer#001", encoding: "utf8" });
|
||||
expect(hbaseCellInput("profile:name", "Alice")).toEqual({
|
||||
column: "profile:name",
|
||||
value: "Alice",
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the display prefix before sending Base64 input", () => {
|
||||
expect(encodeHBaseTextInput("base64:AAEC")).toEqual({ value: "AAEC", encoding: "base64" });
|
||||
expect(hbaseCellInput("profile:avatar", "base64:AP8=")).toEqual({
|
||||
column: "profile:avatar",
|
||||
value: "AP8=",
|
||||
valueEncoding: "base64",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes null cells as empty UTF-8 values", () => {
|
||||
expect(hbaseCellInput("profile:note", null)).toEqual({
|
||||
column: "profile:note",
|
||||
value: "",
|
||||
valueEncoding: "utf8",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -424,6 +424,15 @@ export const zookeeperGet = forward("zookeeperGet");
|
||||
export const zookeeperPut = forward("zookeeperPut");
|
||||
export const zookeeperDelete = forward("zookeeperDelete");
|
||||
|
||||
// HBase
|
||||
export const hbaseGetTableSchema = forward("hbaseGetTableSchema");
|
||||
export const hbaseScanRows = forward("hbaseScanRows");
|
||||
export const hbaseGetRow = forward("hbaseGetRow");
|
||||
export const hbasePutRow = forward("hbasePutRow");
|
||||
export const hbaseDeleteRow = forward("hbaseDeleteRow");
|
||||
export const hbaseCreateTable = forward("hbaseCreateTable");
|
||||
export const hbaseDeleteTable = forward("hbaseDeleteTable");
|
||||
|
||||
// Message Queue
|
||||
export const mqTestConnection = forward("mqTestConnection");
|
||||
export const mqListTenants = forward("mqListTenants");
|
||||
|
||||
@@ -2295,6 +2295,38 @@ export async function nacosRawRequest(connectionId: string, req: NacosRawRequest
|
||||
return post("/api/nacos/raw", { connectionId, req });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HBase
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function hbaseGetTableSchema(connectionId: string, namespace: string, table: string): Promise<import("@/types/hbase").HBaseTableSchema> {
|
||||
return post("/api/hbase/table-schema", { connectionId, namespace, table });
|
||||
}
|
||||
|
||||
export async function hbaseScanRows(connectionId: string, namespace: string, table: string, rowKeyPrefix: string | undefined, limit: number): Promise<import("@/types/hbase").HBaseScanResult> {
|
||||
return post("/api/hbase/scan-rows", { connectionId, namespace, table, rowKeyPrefix, limit });
|
||||
}
|
||||
|
||||
export async function hbaseGetRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<import("@/types/hbase").HBaseRow | null> {
|
||||
return post("/api/hbase/get-row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
|
||||
}
|
||||
|
||||
export async function hbasePutRow(connectionId: string, namespace: string, table: string, input: import("@/types/hbase").HBasePutRowInput): Promise<void> {
|
||||
return post("/api/hbase/put-row", { connectionId, namespace, table, input });
|
||||
}
|
||||
|
||||
export async function hbaseDeleteRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<void> {
|
||||
return post("/api/hbase/delete-row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
|
||||
}
|
||||
|
||||
export async function hbaseCreateTable(connectionId: string, namespace: string, table: string, columnFamilies: string[]): Promise<void> {
|
||||
return post("/api/hbase/create-table", { connectionId, namespace, table, columnFamilies });
|
||||
}
|
||||
|
||||
export async function hbaseDeleteTable(connectionId: string, namespace: string, table: string): Promise<void> {
|
||||
return post("/api/hbase/delete-table", { connectionId, namespace, table });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MongoDB
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2062,6 +2062,35 @@ export async function zookeeperDelete(connectionId: string, key: string): Promis
|
||||
return invoke("zookeeper_delete", { connectionId, key });
|
||||
}
|
||||
|
||||
// --- HBase ---
|
||||
export async function hbaseGetTableSchema(connectionId: string, namespace: string, table: string): Promise<import("@/types/hbase").HBaseTableSchema> {
|
||||
return invoke("hbase_get_table_schema", { connectionId, namespace, table });
|
||||
}
|
||||
|
||||
export async function hbaseScanRows(connectionId: string, namespace: string, table: string, rowKeyPrefix: string | undefined, limit: number): Promise<import("@/types/hbase").HBaseScanResult> {
|
||||
return invoke("hbase_scan_rows", { connectionId, namespace, table, rowKeyPrefix, limit });
|
||||
}
|
||||
|
||||
export async function hbaseGetRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<import("@/types/hbase").HBaseRow | null> {
|
||||
return invoke("hbase_get_row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
|
||||
}
|
||||
|
||||
export async function hbasePutRow(connectionId: string, namespace: string, table: string, input: import("@/types/hbase").HBasePutRowInput): Promise<void> {
|
||||
return invoke("hbase_put_row", { connectionId, namespace, table, input });
|
||||
}
|
||||
|
||||
export async function hbaseDeleteRow(connectionId: string, namespace: string, table: string, rowKey: string, rowKeyEncoding?: import("@/types/hbase").HBaseValueEncoding): Promise<void> {
|
||||
return invoke("hbase_delete_row", { connectionId, namespace, table, rowKey, rowKeyEncoding });
|
||||
}
|
||||
|
||||
export async function hbaseCreateTable(connectionId: string, namespace: string, table: string, columnFamilies: string[]): Promise<void> {
|
||||
return invoke("hbase_create_table", { connectionId, namespace, table, columnFamilies });
|
||||
}
|
||||
|
||||
export async function hbaseDeleteTable(connectionId: string, namespace: string, table: string): Promise<void> {
|
||||
return invoke("hbase_delete_table", { connectionId, namespace, table });
|
||||
}
|
||||
|
||||
// --- Document stores ---
|
||||
export interface DocumentQueryResult {
|
||||
documents: any[];
|
||||
|
||||
@@ -74,7 +74,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
|
||||
}
|
||||
|
||||
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
|
||||
return dbType !== "nacos";
|
||||
return dbType !== "nacos" && dbType !== "hbase";
|
||||
}
|
||||
|
||||
export function usesFetchFirst(dbType?: DatabaseType): boolean {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
|
||||
mongodb: { connection: "special" },
|
||||
oracle: { deferred: "Oracle schemas are users; database creation is not a normal connected DDL action" },
|
||||
elasticsearch: { deferred: "index creation is not modeled as database creation" },
|
||||
hbase: { deferred: "namespace creation needs dedicated HBase namespace options" },
|
||||
qdrant: { deferred: "collection creation is separate from database creation" },
|
||||
milvus: { deferred: "collection/database lifecycle needs a dedicated vector workflow" },
|
||||
weaviate: { deferred: "collection creation is separate from database creation" },
|
||||
|
||||
@@ -61,6 +61,7 @@ const DATABASE_TYPE_OBJECTS = new Map<DatabaseType, SidebarObjectKind[]>([
|
||||
["neo4j", TABLE_VIEW_OBJECTS],
|
||||
// others
|
||||
["influxdb", ["TABLE"]],
|
||||
["hbase", ["TABLE"]],
|
||||
["questdb", ["TABLE", "VIEW", "MATERIALIZED_VIEW"]],
|
||||
["manticoresearch", ["TABLE", "FUNCTION"]],
|
||||
["databend", ["TABLE", "VIEW", "PROCEDURE"]],
|
||||
|
||||
@@ -29,6 +29,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
|
||||
mongodb: { deferred: "database options are not modeled as SQL database properties" },
|
||||
oracle: { deferred: "Oracle database properties are instance/user/tablespace administration" },
|
||||
elasticsearch: { deferred: "index settings are not database properties" },
|
||||
hbase: { deferred: "namespace and table properties need a dedicated HBase workflow" },
|
||||
qdrant: { deferred: "collection settings are not database properties" },
|
||||
milvus: { deferred: "collection/database settings need a dedicated vector workflow" },
|
||||
weaviate: { deferred: "collection settings are not database properties" },
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { HBaseCellInput, HBaseValueEncoding } from "@/types/hbase";
|
||||
|
||||
export interface HBaseEncodedText {
|
||||
value: string;
|
||||
encoding: HBaseValueEncoding;
|
||||
}
|
||||
|
||||
export function encodeHBaseTextInput(value: string): HBaseEncodedText {
|
||||
return value.startsWith("base64:") ? { value: value.slice(7), encoding: "base64" } : { value, encoding: "utf8" };
|
||||
}
|
||||
|
||||
export function hbaseCellInput(column: string, value: CellValue): HBaseCellInput {
|
||||
const encoded = encodeHBaseTextInput(value == null ? "" : String(value));
|
||||
return { column, value: encoded.value, valueEncoding: encoded.encoding };
|
||||
}
|
||||
@@ -26,6 +26,12 @@ export type ActiveTabSidebarTarget =
|
||||
database: string;
|
||||
collectionName: string;
|
||||
}
|
||||
| {
|
||||
type: "hbase-table";
|
||||
connectionId: string;
|
||||
namespace: string;
|
||||
tableName: string;
|
||||
}
|
||||
| {
|
||||
type: "etcd-root";
|
||||
connectionId: string;
|
||||
@@ -112,6 +118,16 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active
|
||||
};
|
||||
}
|
||||
|
||||
if (tab.mode === "hbase") {
|
||||
if (!tab.sql) return null;
|
||||
return {
|
||||
type: "hbase-table",
|
||||
connectionId: tab.connectionId,
|
||||
namespace: tab.database,
|
||||
tableName: tab.sql || tab.title,
|
||||
};
|
||||
}
|
||||
|
||||
if (tab.mode === "etcd") {
|
||||
return { type: "etcd-root", connectionId: tab.connectionId };
|
||||
}
|
||||
@@ -176,6 +192,10 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b
|
||||
return node.type === "vector-collection" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.collectionName;
|
||||
}
|
||||
|
||||
if (target.type === "hbase-table") {
|
||||
return node.type === "table" && node.connectionId === target.connectionId && node.database === target.namespace && node.label === target.tableName;
|
||||
}
|
||||
|
||||
if (target.type === "query-context") {
|
||||
if (target.schema) {
|
||||
return node.type === "schema" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.schema;
|
||||
|
||||
@@ -31,6 +31,12 @@ const capabilityByType: Partial<Record<DatabaseType, Partial<TableMetadataCapabi
|
||||
triggers: false,
|
||||
ddl: false,
|
||||
},
|
||||
hbase: {
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
triggers: false,
|
||||
ddl: false,
|
||||
},
|
||||
qdrant: {
|
||||
indexes: false,
|
||||
foreignKeys: false,
|
||||
|
||||
@@ -94,6 +94,10 @@ export function tabDisplayTitle(tab: QueryTab, t: Translate): string {
|
||||
if (compact) return tab.sql;
|
||||
return `${tab.sql}@${database}`;
|
||||
}
|
||||
if (tab.mode === "hbase" && tab.sql) {
|
||||
if (compact) return tab.sql;
|
||||
return `${tab.sql}@${database}`;
|
||||
}
|
||||
if (tab.mode === "redis") {
|
||||
if (compact) return connectionDisplayName(tab.connectionId);
|
||||
return `${connectionDisplayName(tab.connectionId)}@${database}`;
|
||||
@@ -148,6 +152,9 @@ export function tabTooltipLines(tab: QueryTab, t: Translate): { label: string; v
|
||||
if (tab.mode === "vector" && tab.sql) {
|
||||
lines.push({ label: t("tabs.tooltipCollection"), value: tab.sql });
|
||||
}
|
||||
if (tab.mode === "hbase" && tab.sql) {
|
||||
lines.push({ label: t("tabs.tooltipTable"), value: tab.sql });
|
||||
}
|
||||
if (tab.mode === "objects" && tab.objectBrowser?.schema) {
|
||||
lines.push({ label: t("tabs.tooltipSchema"), value: tab.objectBrowser.schema });
|
||||
}
|
||||
@@ -333,6 +340,7 @@ export function tabModeLabel(tab: QueryTab, t: Translate): string {
|
||||
if (tab.mode === "mongo") return t("tabs.mongo");
|
||||
if (tab.mode === "mongo-gridfs" || tab.mode === "mongo-bucket") return t("tabs.gridfs");
|
||||
if (tab.mode === "vector") return t("tabs.vector");
|
||||
if (tab.mode === "hbase") return "HBase";
|
||||
if (tab.mode === "redis") return t("tabs.redis");
|
||||
if (tab.mode === "etcd") return t("tabs.etcd");
|
||||
if (tab.mode === "etcd-dashboard") return t("tabs.etcdDashboard");
|
||||
|
||||
@@ -12,6 +12,7 @@ export type DatabaseType =
|
||||
| "mongodb"
|
||||
| "oracle"
|
||||
| "elasticsearch"
|
||||
| "hbase"
|
||||
| "qdrant"
|
||||
| "milvus"
|
||||
| "weaviate"
|
||||
@@ -906,7 +907,31 @@ export interface QueryTab {
|
||||
explainClientSessionId?: string;
|
||||
/** Invalidates tab-scoped completion metadata after session context changes. */
|
||||
completionContextVersion?: number;
|
||||
mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "etcd-dashboard" | "zookeeper" | "mq" | "nacos" | "nacos-dashboard" | "objects" | "structure" | "users" | "dameng-jobs" | "processlist" | "mysql-dashboard" | "postgres-dashboard";
|
||||
mode:
|
||||
| "data"
|
||||
| "query"
|
||||
| "redis"
|
||||
| "redis-dashboard"
|
||||
| "mongo"
|
||||
| "mongo-gridfs"
|
||||
| "mongo-bucket"
|
||||
| "vector"
|
||||
| "hbase"
|
||||
| "etcd"
|
||||
| "etcd-dashboard"
|
||||
| "zookeeper"
|
||||
| "mq"
|
||||
| "nacos"
|
||||
| "nacos-dashboard"
|
||||
| "objects"
|
||||
| "structure"
|
||||
| "users"
|
||||
| "dameng-jobs"
|
||||
| "processlist"
|
||||
| "mysql-dashboard"
|
||||
| "postgres-dashboard";
|
||||
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
|
||||
hbaseCreateTableOnOpen?: boolean;
|
||||
mqTenant?: string;
|
||||
mqInitialTab?: "topics";
|
||||
nacosNamespace?: string;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export type HBaseValueEncoding = "utf8" | "base64";
|
||||
|
||||
export interface HBaseColumnFamily {
|
||||
name: string;
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HBaseTableSchema {
|
||||
name: string;
|
||||
columnFamilies: HBaseColumnFamily[];
|
||||
properties: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface HBaseCell {
|
||||
column: string;
|
||||
value: string;
|
||||
valueEncoding: HBaseValueEncoding;
|
||||
valueBase64: string;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
export interface HBaseRow {
|
||||
rowKey: string;
|
||||
rowKeyEncoding: HBaseValueEncoding;
|
||||
rowKeyBase64: string;
|
||||
cells: HBaseCell[];
|
||||
}
|
||||
|
||||
export interface HBaseScanResult {
|
||||
rows: HBaseRow[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface HBaseCellInput {
|
||||
column: string;
|
||||
value: string;
|
||||
valueEncoding?: HBaseValueEncoding;
|
||||
}
|
||||
|
||||
export interface HBasePutRowInput {
|
||||
rowKey: string;
|
||||
rowKeyEncoding?: HBaseValueEncoding;
|
||||
cells: HBaseCellInput[];
|
||||
}
|
||||
Reference in new issue
Block a user