mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
@@ -96,6 +96,7 @@ function preloadDataGridComponent() {
|
||||
const QueryEditor = defineAsyncComponent({ loader: () => import("@/components/editor/QueryEditor.vue"), loadingComponent: QueryLoadingState, delay: 0 });
|
||||
const DataGrid = defineAsyncComponent(loadDataGridComponent);
|
||||
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
|
||||
const RedisQueryConsoleOutput = defineAsyncComponent(() => import("@/components/redis/RedisQueryConsoleOutput.vue"));
|
||||
const RedisDashboard = defineAsyncComponent(() => import("@/components/redis/RedisDashboard.vue"));
|
||||
const EtcdKeyBrowser = defineAsyncComponent(() => import("@/components/etcd/EtcdKeyBrowser.vue"));
|
||||
const EtcdDashboard = defineAsyncComponent(() => import("@/components/etcd/EtcdDashboard.vue"));
|
||||
@@ -186,7 +187,7 @@ import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
|
||||
import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay";
|
||||
import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation";
|
||||
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
|
||||
import type { QueryMessage, QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
|
||||
import type { QueryMessage, QueryTab, RedisResultViewMode, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database";
|
||||
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||
@@ -613,6 +614,9 @@ const hasTabularResult = computed(() => {
|
||||
if (props.activeTab.result?.columns.length && props.activeTab.result.server_message !== true) return true;
|
||||
return visibleResultItems.value.length > 0;
|
||||
});
|
||||
const redisConsoleResults = computed(() => (props.activeTab.results?.length ? props.activeTab.results : props.activeTab.result ? [props.activeTab.result] : []));
|
||||
const canShowRedisConsoleOutput = computed(() => activeEffectiveDatabaseType.value === "redis" && (props.activeTab.isExecuting || redisConsoleResults.value.some((result) => result.execution_error === true || typeof result.redis_console_output === "string")));
|
||||
const redisResultViewMode = computed<RedisResultViewMode>(() => (activeEffectiveDatabaseType.value === "redis" ? (props.activeTab.uiState?.redisResultViewMode ?? "grid") : "grid"));
|
||||
const canShowResultOutput = computed(() => hasTabularResult.value || props.activeTab.isExecuting);
|
||||
const canShowExplainOutput = computed(() => !!props.activeTab.explainPlan || !!props.activeTab.explainError || !!props.activeTab.explainTableResult || !!props.activeTab.explainTableError || props.activeTab.isExplaining === true);
|
||||
// A batch can attach server messages to more than one statement result (for
|
||||
@@ -626,7 +630,7 @@ const resultMessages = computed<QueryMessage[]>(() => {
|
||||
});
|
||||
const resultMessageCount = computed(() => resultMessages.value.length);
|
||||
const canShowMessagesOutput = computed(() => resultMessageCount.value > 0);
|
||||
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || !props.activeTab.result || !hasTabularResult.value);
|
||||
const showStandaloneResultToolbar = computed(() => activeElasticsearchJsonResponse.value || props.activeOutputView !== "result" || (redisResultViewMode.value === "console" && canShowRedisConsoleOutput.value) || !props.activeTab.result || !hasTabularResult.value);
|
||||
const standaloneResultToolbarCompact = computed(() => isDataGridToolbarCompact(standaloneResultToolbarWidth.value, standaloneResultToolbarViewportWidth.value));
|
||||
let standaloneResultToolbarResizeObserver: ResizeObserver | undefined;
|
||||
|
||||
@@ -635,6 +639,11 @@ function updateStandaloneResultToolbarDimensions() {
|
||||
standaloneResultToolbarViewportWidth.value = typeof window === "undefined" ? 0 : window.innerWidth;
|
||||
}
|
||||
|
||||
function setRedisResultViewMode(mode: RedisResultViewMode) {
|
||||
if (activeEffectiveDatabaseType.value !== "redis") return;
|
||||
queryStore.updateTabUiState(props.activeTab.id, { redisResultViewMode: mode });
|
||||
}
|
||||
|
||||
function observeStandaloneResultToolbar() {
|
||||
standaloneResultToolbarResizeObserver?.disconnect();
|
||||
standaloneResultToolbarResizeObserver = undefined;
|
||||
@@ -1817,7 +1826,7 @@ defineExpose({
|
||||
<ResultSetNavigator v-if="visibleResultItems.length > 0" :key="`${activeTab.id}:${activeTab.activeResultRunId ?? 'current'}`" :items="visibleResultItems" :active-index="activeTab.activeResultIndex ?? 0" :active="activeOutputView === 'result'" @select="selectResultItem" />
|
||||
</template>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<Popover v-if="activeOutputView === 'result' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
|
||||
<Popover v-if="activeOutputView === 'result' && redisResultViewMode === 'grid' && activeTab.result && hasTabularResult && !activeElasticsearchJsonResponse" v-model:open="dataGridViewOptionsOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-6 w-7 shrink-0 text-foreground hover:bg-accent" :title="t('grid.viewOptions')" :aria-label="t('grid.viewOptions')">
|
||||
<Wrench class="h-4 w-4" />
|
||||
@@ -2096,9 +2105,12 @@ defineExpose({
|
||||
:can-show-summary="hasExecutionSummary"
|
||||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||||
:can-show-messages="canShowMessagesOutput"
|
||||
:can-show-redis-console="canShowRedisConsoleOutput"
|
||||
:result-mode="redisResultViewMode"
|
||||
:message-count="resultMessageCount"
|
||||
:compact="standaloneResultToolbarCompact"
|
||||
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
|
||||
@select-result-mode="setRedisResultViewMode"
|
||||
/>
|
||||
<QueryResultToolbarActions
|
||||
class="ml-auto"
|
||||
@@ -2250,6 +2262,8 @@ defineExpose({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RedisQueryConsoleOutput v-else-if="activeOutputView === 'result' && redisResultViewMode === 'console' && canShowRedisConsoleOutput" :result="activeTab.result" :results="activeTab.results" :loading="activeTab.isExecuting" />
|
||||
|
||||
<QueryMessagesView v-else-if="activeOutputView === 'messages'" class="flex-1 min-h-0" :messages="resultMessages" />
|
||||
|
||||
<template v-else>
|
||||
@@ -2337,9 +2351,12 @@ defineExpose({
|
||||
:can-show-summary="hasExecutionSummary"
|
||||
:can-show-chart="hasNumericData && !activeElasticsearchJsonResponse"
|
||||
:can-show-messages="canShowMessagesOutput"
|
||||
:can-show-redis-console="canShowRedisConsoleOutput"
|
||||
:result-mode="redisResultViewMode"
|
||||
:message-count="resultMessageCount"
|
||||
:compact="compact"
|
||||
@select-view="emit('update:activeOutputView', activeTab.id, $event)"
|
||||
@select-result-mode="setRedisResultViewMode"
|
||||
/>
|
||||
<template v-if="activeElasticsearchRawBody">
|
||||
<div class="mx-1 h-4 w-px bg-border" />
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { BarChart3, ListChecks, MessageSquareText } from "@lucide/vue";
|
||||
import { BarChart3, ListChecks, MessageSquareText, SquareTerminal } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import type { RedisResultViewMode } from "@/types/database";
|
||||
|
||||
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
type PrimaryResultView = Exclude<OutputView, "explain" | "profile">;
|
||||
@@ -15,32 +16,65 @@ const props = withDefaults(
|
||||
canShowSummary: boolean;
|
||||
canShowChart: boolean;
|
||||
canShowMessages: boolean;
|
||||
canShowRedisConsole?: boolean;
|
||||
resultMode?: RedisResultViewMode;
|
||||
messageCount?: number;
|
||||
compact?: boolean;
|
||||
}>(),
|
||||
{ compact: false, messageCount: 0 },
|
||||
{ compact: false, messageCount: 0, canShowRedisConsole: false, resultMode: "grid" },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectView: [view: PrimaryResultView];
|
||||
selectResultMode: [mode: RedisResultViewMode];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const messagesTooltip = computed(() => (props.messageCount > 0 ? `${t("tabs.messages")} (${props.messageCount})` : t("tabs.messages")));
|
||||
const effectiveResultMode = computed<RedisResultViewMode>(() => (props.canShowRedisConsole ? props.resultMode : "grid"));
|
||||
|
||||
function selectView(view: PrimaryResultView) {
|
||||
if (props.activeView === view) return;
|
||||
emit("selectView", view);
|
||||
}
|
||||
|
||||
function selectResultMode(mode: RedisResultViewMode) {
|
||||
if (props.activeView === "result" && effectiveResultMode.value === mode) return;
|
||||
if (props.canShowRedisConsole && props.resultMode !== mode) emit("selectResultMode", mode);
|
||||
if (props.activeView !== "result") emit("selectView", "result");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-query-result-view-switcher class="flex shrink-0 items-center gap-1 px-1">
|
||||
<Button size="sm" :variant="activeView === 'result' ? 'secondary' : 'ghost'" class="h-5 shrink-0 px-2 text-xs leading-none" :disabled="!canShowResult" :aria-pressed="activeView === 'result'" @click="selectView('result')">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'result' && effectiveResultMode === 'grid' ? 'secondary' : 'ghost'"
|
||||
class="h-5 shrink-0 px-2 text-xs leading-none"
|
||||
:disabled="!canShowResult"
|
||||
:aria-pressed="activeView === 'result' && effectiveResultMode === 'grid'"
|
||||
@click="canShowRedisConsole ? selectResultMode('grid') : selectView('result')"
|
||||
>
|
||||
<span class="inline-flex h-4 items-center leading-none">{{ t("tabs.tableData") }}</span>
|
||||
</Button>
|
||||
|
||||
<LightTooltip v-if="canShowRedisConsole" :text="t('redis.commandLine')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeView === 'result' && effectiveResultMode === 'console' ? 'secondary' : 'ghost'"
|
||||
class="h-5 shrink-0 text-xs leading-none"
|
||||
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
|
||||
:title="t('redis.commandLine')"
|
||||
:aria-label="t('redis.commandLine')"
|
||||
:aria-pressed="activeView === 'result' && effectiveResultMode === 'console'"
|
||||
@click="selectResultMode('console')"
|
||||
>
|
||||
<SquareTerminal class="block h-3.5 w-3.5 self-center" />
|
||||
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("redis.commandLine") }}</span>
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
|
||||
<LightTooltip :text="t('tabs.executionSummary')" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import QueryResultViewSwitcher from "@/components/layout/QueryResultViewSwitcher.vue";
|
||||
import type { RedisResultViewMode, TabOutputView } from "@/types/database";
|
||||
|
||||
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mounted.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
async function mountSwitcher(canShowRedisConsole: boolean) {
|
||||
const state = reactive<{ view: TabOutputView; mode?: RedisResultViewMode }>({ view: "result" });
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(QueryResultViewSwitcher, {
|
||||
activeView: state.view,
|
||||
canShowResult: true,
|
||||
canShowSummary: false,
|
||||
canShowChart: false,
|
||||
canShowMessages: false,
|
||||
canShowRedisConsole,
|
||||
resultMode: state.mode,
|
||||
onSelectView: (view: TabOutputView) => {
|
||||
state.view = view;
|
||||
},
|
||||
onSelectResultMode: (mode: RedisResultViewMode) => {
|
||||
state.mode = mode;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
messages: { en: { tabs: { tableData: "Table Data", executionSummary: "Summary", messages: "Messages" }, chart: { title: "Chart" }, redis: { commandLine: "Command line" } } },
|
||||
}),
|
||||
);
|
||||
app.mount(host);
|
||||
mounted.push({ unmount: () => app.unmount(), host });
|
||||
await nextTick();
|
||||
return { host, state };
|
||||
}
|
||||
|
||||
describe("QueryResultViewSwitcher Redis mode", () => {
|
||||
it("defaults Redis results to the grid and switches through the existing toolbar contract", async () => {
|
||||
const { host, state } = await mountSwitcher(true);
|
||||
const grid = [...host.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.includes("Table Data"));
|
||||
const console = host.querySelector<HTMLButtonElement>('button[aria-label="Command line"]');
|
||||
|
||||
expect(state.mode).toBeUndefined();
|
||||
expect(grid?.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(console?.getAttribute("aria-pressed")).toBe("false");
|
||||
|
||||
console?.click();
|
||||
await nextTick();
|
||||
|
||||
expect(state.mode).toBe("console");
|
||||
expect(state.view).toBe("result");
|
||||
expect(console?.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
grid?.click();
|
||||
await nextTick();
|
||||
expect(state.mode).toBe("grid");
|
||||
expect(grid?.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not expose the Redis-only mode on generic result toolbars", async () => {
|
||||
const { host } = await mountSwitcher(false);
|
||||
expect(host.querySelector('button[aria-label="Command line"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2026,7 +2026,7 @@ async function runRedisCommand(command: string) {
|
||||
const entryId = appendCommandHistory({ prompt, command, output: "", error: false });
|
||||
try {
|
||||
const result = await api.redisExecuteCommand(props.connectionId, commandDb.value, command, !props.blockDangerousRedisCommands);
|
||||
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value), error: false });
|
||||
updateCommandHistory(entryId, { output: formatRedisConsoleValue(result.value, command), error: false });
|
||||
// The db this command ran on — capture before nextRedisCommandDb() advances it.
|
||||
const executedDb = commandDb.value;
|
||||
commandDb.value = nextRedisCommandDb(commandDb.value, command, result.value);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { createApp, h, nextTick } from "vue";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import RedisQueryConsoleOutput from "@/components/redis/RedisQueryConsoleOutput.vue";
|
||||
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mounted.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("RedisQueryConsoleOutput", () => {
|
||||
it("renders every command result and keeps failures visually distinct", async () => {
|
||||
const results: QueryResult[] = [
|
||||
{ ...redisCommandResultToQueryResult("PONG", 1, "PING"), sourceStatement: "PING" },
|
||||
{ ...redisCommandResultToQueryResult(["one", "two"], 1, "LRANGE items 0 -1"), sourceStatement: "LRANGE items 0 -1" },
|
||||
{ columns: ["Error"], rows: [["ERR unknown command"]], affected_rows: 0, execution_time_ms: 0, execution_error: true, sourceStatement: "NOPE" },
|
||||
];
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp({ render: () => h(RedisQueryConsoleOutput, { results }) });
|
||||
app.use(createI18n({ legacy: false, locale: "en", messages: { en: { redis: { commandLine: "Command line", commandEmptyResult: "No output" } } } }));
|
||||
app.mount(host);
|
||||
mounted.push({ unmount: () => app.unmount(), host });
|
||||
await nextTick();
|
||||
|
||||
expect([...host.querySelectorAll("div.flex.min-w-0")].map((node) => node.textContent?.trim())).toEqual(["redis>PING", "redis>LRANGE items 0 -1", "redis>NOPE"]);
|
||||
expect([...host.querySelectorAll("pre")].map((node) => node.textContent)).toEqual(["PONG", '1) "one"\n2) "two"', "(error) ERR unknown command"]);
|
||||
expect(host.querySelectorAll("pre")[2]?.className).toContain("text-[#ff6b6b]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Loader2 } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { redisQueryResultsToConsoleEntries } from "@/lib/redis/redisQueryResult";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
loading?: boolean;
|
||||
}>(),
|
||||
{ loading: false },
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const entries = computed(() => redisQueryResultsToConsoleEntries(props.results?.length ? props.results : props.result ? [props.result] : []));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-redis-query-console role="region" class="dbx-editor-font-family flex min-h-0 flex-1 flex-col bg-[#171b21] text-[13px] leading-5 text-slate-200" :aria-label="t('redis.commandLine')">
|
||||
<div class="min-h-0 flex-1 overflow-auto px-4 py-4">
|
||||
<div v-for="(entry, index) in entries" :key="`${index}:${entry.command}`" class="mb-4 last:mb-0">
|
||||
<div v-if="entry.command" class="flex min-w-0 items-start gap-2 whitespace-pre-wrap break-words">
|
||||
<span class="shrink-0 text-[#d7ba7d]">redis></span>
|
||||
<span class="min-w-0 text-slate-200">{{ entry.command }}</span>
|
||||
</div>
|
||||
<pre data-native-clipboard class="m-0 whitespace-pre-wrap break-words" :class="entry.error ? 'text-[#ff6b6b]' : 'text-slate-300'">{{ entry.output }}</pre>
|
||||
</div>
|
||||
<div v-if="loading && entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="entries.length === 0" class="flex h-full items-center justify-center text-slate-400">
|
||||
{{ t("redis.commandEmptyResult") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1567,6 +1567,30 @@ SELECT @value AS Message;`;
|
||||
expect(executeCurrentSql).toHaveBeenCalledWith(sql, { tabId: "tab-1", skipRedisSafetyCheck: false });
|
||||
});
|
||||
|
||||
it("keeps an explicitly selected Redis console visible for multi-command execution", async () => {
|
||||
const sql = "GET user:1\nDBSIZE";
|
||||
const activeTab = ref<QueryTab | undefined>({ ...queryTab("0"), sql, uiState: { redisResultViewMode: "console" } });
|
||||
const activeConnection = ref<ConnectionConfig | undefined>(connection("redis"));
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("summary");
|
||||
const queryStore = useQueryStore();
|
||||
useSettingsStore().editorSettings.multiStatementDefaultView = "summary";
|
||||
vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
|
||||
if (activeTab.value) activeTab.value.result = { columns: ["result"], rows: [["value"]], affected_rows: 0, execution_time_ms: 1, redis_console_output: "value" };
|
||||
});
|
||||
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
|
||||
|
||||
const execution = useSqlExecution({
|
||||
activeTab: computed(() => activeTab.value),
|
||||
activeConnection: computed(() => activeConnection.value),
|
||||
executableSql: computed(() => sql),
|
||||
activeOutputView,
|
||||
});
|
||||
|
||||
await execution.tryExecute();
|
||||
|
||||
expect(activeOutputView.value).toBe("result");
|
||||
});
|
||||
|
||||
it("distinguishes read-only and mutating Meilisearch REST requests", () => {
|
||||
expect(isDangerousSql("GET /health", "meilisearch")).toBe(false);
|
||||
expect(isDangerousSql('POST /indexes/movies/documents/fetch\n{"limit":10}', "meilisearch")).toBe(false);
|
||||
|
||||
@@ -424,10 +424,11 @@ export function useSqlExecution(deps: {
|
||||
return;
|
||||
}
|
||||
const statementCount = splitSqlStatementRanges(sql, executionDatabaseType, sqlStatementParameterOptionsForCompatibility(executionDatabaseType, executionDatabaseType === "opengauss" ? connectionStore.databaseCompatibilityMode(tab.connectionId, tab.database) : undefined)).length;
|
||||
const redisConsoleSelected = executionDatabaseType === "redis" && tab.uiState?.redisResultViewMode === "console";
|
||||
// Output-view switching belongs to the tab the user is looking at — both
|
||||
// when the query starts and when it finishes.
|
||||
if (deps.activeTab.value?.id === executionTabId) {
|
||||
deps.activeOutputView.value = statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
|
||||
deps.activeOutputView.value = redisConsoleSelected ? "result" : statementCount > 1 ? settingsStore.editorSettings.multiStatementDefaultView : "result";
|
||||
}
|
||||
const connName = executionConnection?.name || "";
|
||||
const start = Date.now();
|
||||
|
||||
@@ -22,9 +22,9 @@ function roundTrip(tabs: QueryTab[]) {
|
||||
|
||||
describe("openTabsPersistence originalSql round-trip", () => {
|
||||
it("preserves per-tab output view state across a round-trip", () => {
|
||||
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", resultPaneOpen: false } })]);
|
||||
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false } })]);
|
||||
|
||||
expect(restored.uiState).toEqual({ activeOutputView: "chart", resultPaneOpen: false });
|
||||
expect(restored.uiState).toEqual({ activeOutputView: "chart", redisResultViewMode: "console", resultPaneOpen: false });
|
||||
});
|
||||
|
||||
it("preserves namespaced special-page state across a round-trip", () => {
|
||||
|
||||
@@ -17,6 +17,19 @@ function backend(name: ResultCacheBackend["name"], overrides: Partial<ResultCach
|
||||
}
|
||||
|
||||
describe("tab result cache statement execution metadata", () => {
|
||||
it("retains Redis console output through cache restore", () => {
|
||||
const result = {
|
||||
columns: ["result"],
|
||||
rows: [["PONG"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
sourceStatement: "PING",
|
||||
redis_console_output: "PONG",
|
||||
};
|
||||
|
||||
expect(decodeTabResultSnapshot(encodeTabResultSnapshot({ result, cachedAt: 1 }))?.result).toMatchObject({ sourceStatement: "PING", redis_console_output: "PONG" });
|
||||
});
|
||||
|
||||
it("retains timing details for active, array and historical results through cache restore", async () => {
|
||||
const { buildTabResultSnapshot } = await import("@/lib/tabs/tabResultCache");
|
||||
const timing = { query_timings_ms: { agent_total: 40, pool_acquire: 20, pool_release: 2 }, client_prepare_ms: 3, client_result_ms: 4, timing_page_count: 2 };
|
||||
|
||||
@@ -97,4 +97,10 @@ describe("tabUiState", () => {
|
||||
page: { query: { Panel: { count: 3 } } },
|
||||
});
|
||||
});
|
||||
|
||||
it("persists only supported Redis result view modes", () => {
|
||||
expect(sanitizeTabUiState({ redisResultViewMode: "console" })).toEqual({ redisResultViewMode: "console" });
|
||||
expect(sanitizeTabUiState({ redisResultViewMode: "grid" })).toEqual({ redisResultViewMode: "grid" });
|
||||
expect(sanitizeTabUiState({ redisResultViewMode: "invalid" })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,9 +152,11 @@ const TAB_OUTPUT_VIEWS = new Set<TabOutputView>(["result", "summary", "explain",
|
||||
|
||||
function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] {
|
||||
const activeOutputView = tab.uiState?.activeOutputView;
|
||||
const redisResultViewMode = tab.uiState?.redisResultViewMode;
|
||||
const resultPaneOpen = tab.uiState?.resultPaneOpen;
|
||||
const restored: NonNullable<QueryTab["uiState"]> = {};
|
||||
if (activeOutputView && TAB_OUTPUT_VIEWS.has(activeOutputView)) restored.activeOutputView = activeOutputView;
|
||||
if (redisResultViewMode === "grid" || redisResultViewMode === "console") restored.redisResultViewMode = redisResultViewMode;
|
||||
if (typeof resultPaneOpen === "boolean") restored.resultPaneOpen = resultPaneOpen;
|
||||
if (tab.uiState?.page) {
|
||||
const sanitized = sanitizeTabUiState({ page: tab.uiState.page });
|
||||
|
||||
+65
-1
@@ -1,7 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
|
||||
import { redisCommandResultToQueryResult, redisQueryResultsToConsoleEntries } from "@/lib/redis/redisQueryResult";
|
||||
import { formatRedisConsoleError, formatRedisConsoleValue } from "@/lib/redis/redisValuePresentation";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
describe("formatRedisConsoleValue", () => {
|
||||
it("formats scalar, nil, integer, double, boolean and bulk-string replies", () => {
|
||||
expect(formatRedisConsoleValue(null)).toBe("(nil)");
|
||||
expect(formatRedisConsoleValue(42)).toBe("(integer) 42");
|
||||
expect(formatRedisConsoleValue(1.5)).toBe("(double) 1.5");
|
||||
expect(formatRedisConsoleValue(true)).toBe("(true)");
|
||||
expect(formatRedisConsoleValue("hello")).toBe("hello");
|
||||
expect(formatRedisConsoleValue("")).toBe('""');
|
||||
});
|
||||
|
||||
it("numbers ordinary and nested arrays without collapsing nested values into JSON", () => {
|
||||
expect(formatRedisConsoleValue(["one", 2, null, ["nested", false]])).toBe(['1) "one"', "2) (integer) 2", "3) (nil)", '4) 1) "nested"', " 2) (false)"].join("\n"));
|
||||
});
|
||||
|
||||
it("renders HGETALL, WITHSCORES and RESP3 map replies as readable pairs", () => {
|
||||
expect(formatRedisConsoleValue(["name", "alice", "age", "30"], "HGETALL profile")).toBe(['1) "name" => "alice"', '2) "age" => "30"'].join("\n"));
|
||||
expect(formatRedisConsoleValue(["alice", "1.5", "bob", "2"], "ZRANGE scores 0 -1 WITHSCORES")).toBe(['1) "alice" => "1.5"', '2) "bob" => "2"'].join("\n"));
|
||||
expect(
|
||||
formatRedisConsoleValue([
|
||||
{ key: "name", value: "alice" },
|
||||
{ key: "visits", value: 3 },
|
||||
]),
|
||||
).toBe(['1) "name" => "alice"', '2) "visits" => (integer) 3'].join("\n"));
|
||||
});
|
||||
|
||||
it("keeps INFO text readable and prefixes errors once", () => {
|
||||
expect(formatRedisConsoleValue("# Server\nredis_version:7.4.2")).toBe("# Server\nredis_version:7.4.2");
|
||||
expect(formatRedisConsoleError("ERR wrong number of arguments")).toBe("(error) ERR wrong number of arguments");
|
||||
expect(formatRedisConsoleError("(error) already formatted")).toBe("(error) already formatted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redisCommandResultToQueryResult", () => {
|
||||
it("keeps the existing scalar grid shape as the default while retaining console output", () => {
|
||||
const result = redisCommandResultToQueryResult(7, 4.6, "DBSIZE");
|
||||
expect(result).toMatchObject({
|
||||
columns: ["result"],
|
||||
rows: [["7"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 5,
|
||||
redis_console_output: "(integer) 7",
|
||||
});
|
||||
});
|
||||
|
||||
it("pairs member/score rows for ZREVRANGE ... WITHSCORES instead of one row per array element", () => {
|
||||
const flat = ["carol", "300", "bob", "200", "alice", "100"];
|
||||
const result = redisCommandResultToQueryResult(flat, 5, "ZREVRANGE issue7229_repro:zset 0 -1 WITHSCORES");
|
||||
@@ -71,4 +116,23 @@ describe("redisCommandResultToQueryResult", () => {
|
||||
["bob", "2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds ordered console entries for multiple commands and errors", () => {
|
||||
const first = { ...redisCommandResultToQueryResult("PONG", 1, "PING"), sourceStatement: "PING" };
|
||||
const second = { ...redisCommandResultToQueryResult(["a", "b"], 2, "LRANGE items 0 -1"), sourceStatement: "LRANGE items 0 -1" };
|
||||
const failed: QueryResult = {
|
||||
columns: ["Error"],
|
||||
rows: [["ERR invalid command"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 0,
|
||||
execution_error: true,
|
||||
sourceStatement: "BROKEN",
|
||||
};
|
||||
|
||||
expect(redisQueryResultsToConsoleEntries([first, second, failed])).toEqual([
|
||||
{ command: "PING", output: "PONG", error: false },
|
||||
{ command: "LRANGE items 0 -1", output: ['1) "a"', '2) "b"'].join("\n"), error: false },
|
||||
{ command: "BROKEN", output: "(error) ERR invalid command", error: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { formatRedisCommandResult } from "@/lib/redis/redisValuePresentation";
|
||||
import { formatRedisCommandResult, formatRedisConsoleError, formatRedisConsoleValue, redisCommandResultPairKind } from "@/lib/redis/redisValuePresentation";
|
||||
|
||||
const KEY_VALUE_COMMANDS = new Set(["HGETALL"]);
|
||||
// Sorted-set commands whose WITHSCORES modifier returns a flat member/score array.
|
||||
const WITHSCORES_COMMANDS = new Set(["ZRANGE", "ZREVRANGE", "ZRANGEBYSCORE", "ZREVRANGEBYSCORE", "ZRANDMEMBER", "ZDIFF", "ZINTER", "ZUNION"]);
|
||||
const WITHSCORES_MODIFIER = /\bWITHSCORES\b/i;
|
||||
|
||||
function commandHead(command: string): string {
|
||||
return command.trim().split(/\s+/, 1)[0]?.toUpperCase() ?? "";
|
||||
export interface RedisQueryConsoleEntry {
|
||||
command: string;
|
||||
output: string;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function isKeyValueCommand(command: string): boolean {
|
||||
return KEY_VALUE_COMMANDS.has(commandHead(command));
|
||||
}
|
||||
|
||||
// ZRANGE/ZREVRANGE/ZRANGEBYSCORE/ZDIFF/ZINTER/ZUNION/... with a WITHSCORES modifier
|
||||
// return a flat [member1, score1, member2, score2, ...] array — pair it up instead of
|
||||
// dumping each element as its own row. Gate on the command head so a key or argument
|
||||
// that merely contains the token WITHSCORES cannot trigger pairing.
|
||||
function hasWithScoresModifier(command: string): boolean {
|
||||
return WITHSCORES_COMMANDS.has(commandHead(command)) && WITHSCORES_MODIFIER.test(command);
|
||||
export function redisQueryResultsToConsoleEntries(results: readonly QueryResult[]): RedisQueryConsoleEntry[] {
|
||||
return results.flatMap((result) => {
|
||||
const error = result.execution_error === true;
|
||||
const output = error ? formatRedisConsoleError(result.rows[0]?.[0]) : result.redis_console_output;
|
||||
if (output === undefined) return [];
|
||||
return [{ command: result.sourceStatement?.trim() ?? "", output, error }];
|
||||
});
|
||||
}
|
||||
|
||||
export function redisCommandResultToQueryResult(value: unknown, elapsedMs: number, command?: string): QueryResult {
|
||||
if (Array.isArray(value) && command && isKeyValueCommand(command)) {
|
||||
const execution_time_ms = Math.max(0, Math.round(elapsedMs));
|
||||
const redis_console_output = formatRedisConsoleValue(value, command);
|
||||
const pairKind = redisCommandResultPairKind(command);
|
||||
if (Array.isArray(value) && pairKind === "field-value") {
|
||||
const rows: (string | number | boolean | null)[][] = [];
|
||||
for (let i = 0; i + 1 < value.length; i += 2) {
|
||||
rows.push([formatRedisCommandResult(value[i]), formatRedisCommandResult(value[i + 1])]);
|
||||
@@ -32,10 +29,11 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
|
||||
columns: ["field", "value"],
|
||||
rows,
|
||||
affected_rows: value.length / 2,
|
||||
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
|
||||
execution_time_ms,
|
||||
redis_console_output,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(value) && command && hasWithScoresModifier(command)) {
|
||||
if (Array.isArray(value) && pairKind === "member-score") {
|
||||
const rows: (string | number | boolean | null)[][] = [];
|
||||
for (let i = 0; i + 1 < value.length; i += 2) {
|
||||
rows.push([formatRedisCommandResult(value[i]), formatRedisCommandResult(value[i + 1])]);
|
||||
@@ -44,7 +42,8 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
|
||||
columns: ["member", "score"],
|
||||
rows,
|
||||
affected_rows: rows.length,
|
||||
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
|
||||
execution_time_ms,
|
||||
redis_console_output,
|
||||
};
|
||||
}
|
||||
// INFO commands in cluster mode → [[node_addr, info_text], ...] pairs.
|
||||
@@ -55,7 +54,8 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
|
||||
columns: ["(index)", "value"],
|
||||
rows,
|
||||
affected_rows: value.length,
|
||||
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
|
||||
execution_time_ms,
|
||||
redis_console_output,
|
||||
};
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
@@ -64,13 +64,15 @@ export function redisCommandResultToQueryResult(value: unknown, elapsedMs: numbe
|
||||
columns: ["(index)", "value"],
|
||||
rows,
|
||||
affected_rows: value.length,
|
||||
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
|
||||
execution_time_ms,
|
||||
redis_console_output,
|
||||
};
|
||||
}
|
||||
return {
|
||||
columns: ["result"],
|
||||
rows: [[formatRedisCommandResult(value)]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: Math.max(0, Math.round(elapsedMs)),
|
||||
execution_time_ms,
|
||||
redis_console_output,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,6 +207,24 @@ export function formatRedisCommandResult(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
export type RedisCommandResultPairKind = "field-value" | "member-score";
|
||||
|
||||
const REDIS_KEY_VALUE_COMMANDS = new Set(["HGETALL"]);
|
||||
// Sorted-set commands whose WITHSCORES modifier returns member/score pairs.
|
||||
const REDIS_WITHSCORES_COMMANDS = new Set(["ZRANGE", "ZREVRANGE", "ZRANGEBYSCORE", "ZREVRANGEBYSCORE", "ZRANDMEMBER", "ZDIFF", "ZINTER", "ZUNION"]);
|
||||
const REDIS_WITHSCORES_MODIFIER = /\bWITHSCORES\b/i;
|
||||
|
||||
function redisCommandHead(command: string | undefined): string {
|
||||
return command?.trim().split(/\s+/, 1)[0]?.toUpperCase() ?? "";
|
||||
}
|
||||
|
||||
export function redisCommandResultPairKind(command: string | undefined): RedisCommandResultPairKind | undefined {
|
||||
const head = redisCommandHead(command);
|
||||
if (REDIS_KEY_VALUE_COMMANDS.has(head)) return "field-value";
|
||||
if (REDIS_WITHSCORES_COMMANDS.has(head) && command && REDIS_WITHSCORES_MODIFIER.test(command)) return "member-score";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** RedisJSON source text stays out of JavaScript's numeric representation. */
|
||||
export function redisJsonValueText(value: { value: string }): string {
|
||||
return value.value;
|
||||
@@ -222,6 +240,84 @@ function isRedisClusterInfoValue(value: unknown): value is [string, string][] {
|
||||
return Array.isArray(value) && value.length > 0 && (value as unknown[]).every((item) => Array.isArray(item) && item.length === 2 && typeof item[0] === "string" && typeof item[1] === "string" && item[1].startsWith("# "));
|
||||
}
|
||||
|
||||
interface RedisMapEntry {
|
||||
key: unknown;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
function isRedisMapEntry(value: unknown): value is RedisMapEntry {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, "key") && Object.prototype.hasOwnProperty.call(value, "value");
|
||||
}
|
||||
|
||||
function isRedisPair(value: unknown): value is [unknown, unknown] {
|
||||
return Array.isArray(value) && value.length === 2;
|
||||
}
|
||||
|
||||
function quoteRedisString(value: string): string {
|
||||
return JSON.stringify(formatRedisStringValue(value)) ?? '""';
|
||||
}
|
||||
|
||||
function formatRedisConsoleScalar(value: unknown, nested: boolean): string {
|
||||
if (value == null) return "(nil)";
|
||||
if (typeof value === "string") {
|
||||
const text = formatRedisStringValue(value);
|
||||
if (nested) return quoteRedisString(value);
|
||||
return text.length > 0 ? text : '""';
|
||||
}
|
||||
if (typeof value === "number") return Number.isInteger(value) ? `(integer) ${value}` : `(double) ${value}`;
|
||||
if (typeof value === "bigint") return `(integer) ${value}`;
|
||||
if (typeof value === "boolean") return value ? "(true)" : "(false)";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function prefixRedisConsoleLines(prefix: string, lines: string[]): string[] {
|
||||
if (lines.length === 0) return [prefix.trimEnd()];
|
||||
const padding = " ".repeat(prefix.length);
|
||||
return [`${prefix}${lines[0]}`, ...lines.slice(1).map((line) => `${padding}${line}`)];
|
||||
}
|
||||
|
||||
function formatRedisConsoleInline(value: unknown): string {
|
||||
if (typeof value === "string") return quoteRedisString(value);
|
||||
if (value == null || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return formatRedisConsoleScalar(value, true);
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function redisConsolePairs(value: unknown[], command: string | undefined): Array<[unknown, unknown]> | undefined {
|
||||
if (value.length > 0 && value.every(isRedisMapEntry)) return (value as RedisMapEntry[]).map((entry): [unknown, unknown] => [entry.key, entry.value]);
|
||||
if (!redisCommandResultPairKind(command)) return undefined;
|
||||
if (value.length > 0 && value.every(isRedisPair)) return (value as Array<[unknown, unknown]>).map((entry): [unknown, unknown] => [entry[0], entry[1]]);
|
||||
if (value.length % 2 !== 0) return undefined;
|
||||
const pairs: Array<[unknown, unknown]> = [];
|
||||
for (let index = 0; index < value.length; index += 2) pairs.push([value[index], value[index + 1]]);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
function formatRedisConsolePairs(pairs: Array<[unknown, unknown]>): string[] {
|
||||
if (pairs.length === 0) return ["(empty array)"];
|
||||
return pairs.flatMap(([key, value], index) => prefixRedisConsoleLines(`${index + 1}) ${formatRedisConsoleInline(key)} => `, formatRedisConsoleLines(value, undefined, true)));
|
||||
}
|
||||
|
||||
function formatRedisConsoleLines(value: unknown, command: string | undefined, nested: boolean): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return ["(empty array)"];
|
||||
const pairs = redisConsolePairs(value, command);
|
||||
if (pairs) return formatRedisConsolePairs(pairs);
|
||||
return value.flatMap((entry, index) => prefixRedisConsoleLines(`${index + 1}) `, formatRedisConsoleLines(entry, undefined, true)));
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
try {
|
||||
return (JSON.stringify(value, null, 2) ?? String(value)).split("\n");
|
||||
} catch {
|
||||
return [String(value)];
|
||||
}
|
||||
}
|
||||
return [formatRedisConsoleScalar(value, nested)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Redis command result for the **command console terminal** (RedisKeyBrowser.vue).
|
||||
* Unlike `formatRedisCommandResult` (used by the query result table UI), this function
|
||||
@@ -230,14 +326,21 @@ function isRedisClusterInfoValue(value: unknown): value is [string, string][] {
|
||||
*
|
||||
* - Cluster INFO `[[addr, infoText], ...]` → `"{addr}\n{infoText}"` per node, joined by newlines.
|
||||
* - Plain string → passthrough (handles single-node INFO text correctly).
|
||||
* - Everything else → JSON.stringify (arrays, objects, etc.).
|
||||
* - Arrays use redis-cli-style numbered items, including nested arrays.
|
||||
* - RESP3 maps plus HGETALL/WITHSCORES responses use readable key/value pairs.
|
||||
* - Integers, doubles, booleans and nil retain visible Redis-style type markers.
|
||||
*/
|
||||
export function formatRedisConsoleValue(value: unknown): string {
|
||||
export function formatRedisConsoleValue(value: unknown, command?: string): string {
|
||||
if (isRedisClusterInfoValue(value)) {
|
||||
return value.map(([addr, info]) => `${addr}\n${info}`).join("\n");
|
||||
}
|
||||
if (typeof value === "string") return formatRedisStringValue(value);
|
||||
return JSON.stringify(value, null, 2);
|
||||
return formatRedisConsoleLines(value, command, false).join("\n");
|
||||
}
|
||||
|
||||
export function formatRedisConsoleError(message: unknown): string {
|
||||
const text = formatRedisStringValue(message).trim();
|
||||
if (/^\(error\)(?:\s|$)/i.test(text)) return text;
|
||||
return `(error) ${text || "Unknown Redis error"}`;
|
||||
}
|
||||
|
||||
export function parseRedisJsonDetail(value: unknown): RedisJsonDetail | null {
|
||||
|
||||
@@ -67,6 +67,7 @@ interface ColumnarQueryResult {
|
||||
rowCount: number;
|
||||
mongo_documents?: unknown[];
|
||||
mongo_copy_documents?: unknown[];
|
||||
redis_console_output?: string;
|
||||
affected_rows: number;
|
||||
execution_time_ms: number;
|
||||
server_execute_time_us?: number;
|
||||
@@ -361,6 +362,7 @@ function stripSessionIds(result: QueryResult | undefined): QueryResult | undefin
|
||||
rows: result.rows.map((row) => [...row]),
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
|
||||
redis_console_output: result.redis_console_output,
|
||||
affected_rows: result.affected_rows,
|
||||
execution_time_ms: result.execution_time_ms,
|
||||
server_execute_time_us: result.server_execute_time_us,
|
||||
@@ -424,6 +426,7 @@ function toColumnarResult(result: QueryResult | undefined): ColumnarQueryResult
|
||||
rowCount,
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
|
||||
redis_console_output: result.redis_console_output,
|
||||
affected_rows: result.affected_rows,
|
||||
execution_time_ms: result.execution_time_ms,
|
||||
server_execute_time_us: result.server_execute_time_us,
|
||||
@@ -459,6 +462,7 @@ function fromColumnarResult(result: ColumnarQueryResult | undefined): QueryResul
|
||||
rows,
|
||||
mongo_documents: result.mongo_documents ? clonePlain(result.mongo_documents) : undefined,
|
||||
mongo_copy_documents: result.mongo_copy_documents ? clonePlain(result.mongo_copy_documents) : undefined,
|
||||
redis_console_output: result.redis_console_output,
|
||||
affected_rows: result.affected_rows,
|
||||
execution_time_ms: result.execution_time_ms,
|
||||
server_execute_time_us: result.server_execute_time_us,
|
||||
|
||||
@@ -105,6 +105,7 @@ export function sanitizeTabUiState(value: unknown): TabUiState | undefined {
|
||||
const state = value as TabUiState;
|
||||
const normalized: TabUiState = {};
|
||||
if (state.activeOutputView && ["result", "summary", "explain", "chart", "messages", "profile"].includes(state.activeOutputView)) normalized.activeOutputView = state.activeOutputView;
|
||||
if (state.redisResultViewMode === "grid" || state.redisResultViewMode === "console") normalized.redisResultViewMode = state.redisResultViewMode;
|
||||
if (typeof state.resultPaneOpen === "boolean") normalized.resultPaneOpen = state.resultPaneOpen;
|
||||
const sanitizedPage = sanitizeTabPageUiState(state.page);
|
||||
if (sanitizedPage) {
|
||||
|
||||
@@ -61,6 +61,7 @@ import { refreshLoadedMongoIndexes } from "@/lib/mongo/mongoIndexMetadata";
|
||||
import { redisCommandResultToQueryResult } from "@/lib/redis/redisQueryResult";
|
||||
import { nextRedisCommandDb } from "@/lib/redis/redisCommandSession";
|
||||
import { isRedisMutatingCommand } from "@/lib/redis/redisCommandTable";
|
||||
import { formatRedisConsoleValue } from "@/lib/redis/redisValuePresentation";
|
||||
import { usesAgentCursorForQuery } from "@/lib/database/databaseDriverManifest";
|
||||
import { connectionIsDorisFamilyCatalogCapable, defaultAutoCommitForDbType, supportsClearableQuerySchema, supportsTransaction, usesOracleStickyTransactionState, usesProvenReadOnlyStickyTransactionState } from "@/lib/database/databaseFeatureSupport";
|
||||
import { canInsertTableRows, canUseKeylessRowPredicate, DBX_ROWID_COLUMN, editablePrimaryKeys, shouldIncludeSyntheticRowId, usesSyntheticRowIdKey } from "@/lib/table/tableEditing";
|
||||
@@ -388,6 +389,7 @@ function releaseResultObjectPayload(result: QueryResult): void {
|
||||
result.mongo_copy_documents = undefined;
|
||||
result.large_value_cells = undefined;
|
||||
result.elasticsearch_raw_body = undefined;
|
||||
result.redis_console_output = undefined;
|
||||
result.messages = undefined;
|
||||
result.error = undefined;
|
||||
result.sourceLabel = undefined;
|
||||
@@ -6884,7 +6886,18 @@ export const useQueryStore = defineStore("query", () => {
|
||||
(rows) => {
|
||||
const current = findExecutionTab(id);
|
||||
if (current?.executionId !== executionId) return;
|
||||
current.result = markQueryResultRowsRaw(annotateQueryResultSource({ columns: ["MONITOR"], rows: rows.map((message) => [message]), affected_rows: 0, execution_time_ms: performance.now() - startedAt }, "MONITOR"));
|
||||
current.result = markQueryResultRowsRaw(
|
||||
annotateQueryResultSource(
|
||||
{
|
||||
columns: ["MONITOR"],
|
||||
rows: rows.map((message) => [message]),
|
||||
affected_rows: 0,
|
||||
execution_time_ms: performance.now() - startedAt,
|
||||
redis_console_output: rows.map((message) => formatRedisConsoleValue(message)).join("\n"),
|
||||
},
|
||||
"MONITOR",
|
||||
),
|
||||
);
|
||||
current.results = undefined;
|
||||
current.activeResultIndex = undefined;
|
||||
current.queryEditabilityReason = undefined;
|
||||
|
||||
@@ -1310,6 +1310,8 @@ export interface QueryResult {
|
||||
* this carries the raw HTTP response body so the UI can toggle between
|
||||
* the tabular view and the original JSON. */
|
||||
elasticsearch_raw_body?: string;
|
||||
/** Preformatted Redis command output retained alongside the default grid rows. */
|
||||
redis_console_output?: string;
|
||||
sourceLabel?: string;
|
||||
/** 结果集来源的库名 / schema(与 sourceLabel 同时写入),供结果集页签按设置决定是否展示。 */
|
||||
sourceQualifier?: string;
|
||||
@@ -1822,11 +1824,15 @@ export interface QueryPageJumpProgress {
|
||||
|
||||
export type TabOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
|
||||
export type RedisResultViewMode = "grid" | "console";
|
||||
|
||||
export type TabPageUiState = Record<string, unknown>;
|
||||
|
||||
/** UI-only state that must survive an inactive tab's component being unmounted. */
|
||||
export interface TabUiState {
|
||||
activeOutputView?: TabOutputView;
|
||||
/** Redis query results default to grid; a per-tab override selects command-line output. */
|
||||
redisResultViewMode?: RedisResultViewMode;
|
||||
resultPaneOpen?: boolean;
|
||||
/** Small JSON-compatible snapshots owned by special-page components. */
|
||||
page?: Record<string, TabPageUiState>;
|
||||
|
||||
Reference in New Issue
Block a user