Merge pull request #97 from Syngnat/release/0.4.2

Release/0.4.2
This commit is contained in:
Syngnat
2026-02-11 11:18:45 +08:00
committed by GitHub
17 changed files with 680 additions and 83 deletions

View File

@@ -1 +1 @@
d0f9366af59a6367ad3c7e2d4185ead4
5b8157374dae5f9340e31b2d0bd2c00e

View File

@@ -705,7 +705,7 @@ function App() {
</Dropdown>
<Button type="text" icon={<InfoCircleOutlined />} title="关于" onClick={() => setIsAboutOpen(true)}></Button>
</div>
<Layout style={{ flex: 1, minHeight: 0 }}>
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>
<Sider
width={sidebarWidth}
style={{
@@ -756,8 +756,8 @@ function App() {
title="拖动调整宽度"
/>
</Sider>
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: 1, minHeight: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
<Content style={{ background: 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent }}>
<TabManager />
</div>
{isLogPanelOpen && (

View File

@@ -43,6 +43,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const [redisDbList, setRedisDbList] = useState<number[]>([]); // Redis databases 0-15
const [mongoMembers, setMongoMembers] = useState<MongoMemberInfo[]>([]);
const [discoveringMembers, setDiscoveringMembers] = useState(false);
const [uriFeedback, setUriFeedback] = useState<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
const testInFlightRef = useRef(false);
const testTimerRef = useRef<number | null>(null);
const addConnection = useStore((state) => state.addConnection);
@@ -393,9 +394,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const values = form.getFieldsValue(true);
const uri = buildUriFromValues(values);
form.setFieldValue('uri', uri);
message.success('URI 已生成');
setUriFeedback({ type: 'success', message: 'URI 已生成' });
} catch {
message.error('生成 URI 失败');
setUriFeedback({ type: 'error', message: '生成 URI 失败' });
}
};
@@ -404,21 +405,21 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
const uriText = String(form.getFieldValue('uri') || '').trim();
const type = String(form.getFieldValue('type') || dbType).trim().toLowerCase();
if (!uriText) {
message.warning('请先输入 URI');
setUriFeedback({ type: 'warning', message: '请先输入 URI' });
return;
}
const parsedValues = parseUriToValues(uriText, type);
if (!parsedValues) {
message.error('当前 URI 与数据源类型不匹配,或 URI 格式不支持');
setUriFeedback({ type: 'error', message: '当前 URI 与数据源类型不匹配,或 URI 格式不支持' });
return;
}
form.setFieldsValue({ ...parsedValues, uri: uriText });
if (testResult) {
setTestResult(null);
}
message.success('已根据 URI 回填连接参数');
setUriFeedback({ type: 'success', message: '已根据 URI 回填连接参数' });
} catch {
message.error('URI 解析失败,请检查格式后重试');
setUriFeedback({ type: 'error', message: 'URI 解析失败,请检查格式后重试' });
}
};
@@ -430,14 +431,14 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
form.setFieldValue('uri', uriText);
}
if (!uriText) {
message.warning('没有可复制的 URI');
setUriFeedback({ type: 'warning', message: '没有可复制的 URI' });
return;
}
try {
await navigator.clipboard.writeText(uriText);
message.success('URI 已复制');
setUriFeedback({ type: 'success', message: 'URI 已复制' });
} catch {
message.error('复制失败');
setUriFeedback({ type: 'error', message: '复制失败' });
}
};
@@ -448,6 +449,7 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
setDbList([]);
setRedisDbList([]);
setMongoMembers([]);
setUriFeedback(null);
if (initialValues) {
// Edit mode: Go directly to step 2
setStep(2);
@@ -925,6 +927,9 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
setTestResult(null); // Clear result on change
setTestErrorLogOpen(false);
}
if (changed.uri !== undefined || changed.type !== undefined) {
setUriFeedback(null);
}
if (changed.useSSH !== undefined) setUseSSH(changed.useSSH);
// Type change handled by step 1, but keep sync if select changes (hidden now)
if (changed.type !== undefined) setDbType(changed.type);
@@ -958,6 +963,16 @@ const ConnectionModal: React.FC<{ open: boolean; onClose: () => void; initialVal
<Button onClick={handleParseURI}> URI </Button>
<Button onClick={handleCopyURI}> URI</Button>
</Space>
{uriFeedback && (
<Alert
showIcon
closable
type={uriFeedback.type}
message={uriFeedback.message}
onClose={() => setUriFeedback(null)}
style={{ marginBottom: 12 }}
/>
)}
{isCustom ? (
<>

View File

@@ -9,7 +9,7 @@ import ImportPreviewModal from './ImportPreviewModal';
import { useStore } from '../store';
import { v4 as uuidv4 } from 'uuid';
import 'react-resizable/css/styles.css';
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance';
// --- Error Boundary ---
@@ -496,6 +496,7 @@ interface DataGridProps {
onSort?: (field: string, order: string) => void;
onPageChange?: (page: number, size: number) => void;
pagination?: { current: number, pageSize: number, total: number, totalKnown?: boolean };
sortInfoExternal?: { columnKey: string, order: string } | null;
// Filtering
showFilter?: boolean;
onToggleFilter?: () => void;
@@ -514,7 +515,7 @@ type GridViewMode = 'table' | 'json' | 'text';
const DataGrid: React.FC<DataGridProps> = ({
data, columnNames, loading, tableName, dbName, connectionId, pkColumns = [], readOnly = false,
onReload, onSort, onPageChange, pagination, showFilter, onToggleFilter, onApplyFilter
onReload, onSort, onPageChange, pagination, sortInfoExternal, showFilter, onToggleFilter, onApplyFilter
}) => {
const connections = useStore(state => state.connections);
const addSqlLog = useStore(state => state.addSqlLog);
@@ -661,6 +662,21 @@ const DataGrid: React.FC<DataGridProps> = ({
const [sortInfo, setSortInfo] = useState<{ columnKey: string, order: string } | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
useEffect(() => {
const nextOrder = sortInfoExternal?.order === 'ascend' || sortInfoExternal?.order === 'descend'
? sortInfoExternal.order
: '';
const nextColumn = nextOrder ? String(sortInfoExternal?.columnKey || '') : '';
const currColumn = String(sortInfo?.columnKey || '');
const currOrder = sortInfo?.order === 'ascend' || sortInfo?.order === 'descend' ? sortInfo.order : '';
if (nextColumn === currColumn && nextOrder === currOrder) return;
if (!nextColumn || !nextOrder) {
setSortInfo(null);
} else {
setSortInfo({ columnKey: nextColumn, order: nextOrder });
}
}, [sortInfoExternal, sortInfo]);
const closeCellEditor = useCallback(() => {
setCellEditorOpen(false);
setCellEditorMeta(null);
@@ -1113,9 +1129,16 @@ const DataGrid: React.FC<DataGridProps> = ({
const handleTableChange = (pag: any, filtersArg: any, sorter: any) => {
if (isResizingRef.current) return; // Block sort if resizing
if (sorter.field) {
const field = String(sorter.field);
const order = sorter.order as string;
setSortInfo({ columnKey: sorter.field as string, order });
if (onSort) onSort(sorter.field, order);
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
if (!normalizedOrder) {
setSortInfo(null);
if (onSort) onSort('', '');
return;
}
setSortInfo({ columnKey: field, order: normalizedOrder });
if (onSort) onSort(field, normalizedOrder);
} else {
setSortInfo(null);
if (onSort) onSort('', '');
@@ -1820,6 +1843,11 @@ const DataGrid: React.FC<DataGridProps> = ({
const whereSQL = buildWhereSQL(dbType, filterConditions);
let sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`;
sql += buildOrderBySQL(dbType, sortInfo, pkColumns);
const normalizedType = String(dbType || '').trim().toLowerCase();
const hasExplicitSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
if (hasExplicitSort && (normalizedType === 'mysql' || normalizedType === 'mariadb')) {
sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024);
}
const offset = (pagination.current - 1) * pagination.pageSize;
sql += ` LIMIT ${pagination.pageSize} OFFSET ${offset}`;
return sql;
@@ -2034,9 +2062,9 @@ const DataGrid: React.FC<DataGridProps> = ({
}, [viewMode, totalWidth, mergedDisplayData.length, recalculateTableMetrics]);
return (
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''}`} ref={containerRef} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, background: bgContent }}>
{/* Toolbar */}
<div style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center' }}>
<div className={`${gridId}${cellEditMode ? ' cell-edit-mode' : ''} data-grid-root`} style={{ flex: '1 1 auto', height: '100%', overflow: 'hidden', padding: 0, display: 'flex', flexDirection: 'column', minHeight: 0, minWidth: 0, background: bgContent }}>
{/* Toolbar */}
<div className="data-grid-toolbar-scroll" style={{ padding: '8px', borderBottom: '1px solid #eee', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'nowrap', minWidth: 0, overflowX: 'auto', overflowY: 'hidden', scrollbarGutter: 'stable', WebkitOverflowScrolling: 'touch' }}>
{onReload && <Button icon={<ReloadOutlined />} disabled={loading} onClick={() => {
setAddedRows([]);
setModifiedRows({});
@@ -2121,36 +2149,38 @@ const DataGrid: React.FC<DataGridProps> = ({
)}
<div style={{ marginLeft: 'auto' }} />
<Segmented
size="small"
value={viewMode}
options={[
{ label: '表格', value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' }
]}
onChange={(val) => {
const nextMode = String(val) as GridViewMode;
if (nextMode === 'json' && cellEditMode) {
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
updateCellSelection(new Set());
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
if (idx >= 0) {
setTextRecordIndex(idx);
}
}
}
setViewMode(nextMode);
}}
/>
</div>
<div style={{ flexShrink: 0 }}>
<Segmented
size="small"
value={viewMode}
options={[
{ label: '表格', value: 'table' },
{ label: 'JSON', value: 'json' },
{ label: '文本', value: 'text' }
]}
onChange={(val) => {
const nextMode = String(val) as GridViewMode;
if (nextMode === 'json' && cellEditMode) {
setCellEditMode(false);
setSelectedCells(new Set());
currentSelectionRef.current = new Set();
selectionStartRef.current = null;
updateCellSelection(new Set());
}
if (nextMode === 'text') {
const selectedKey = selectedRowKeys[0];
if (selectedKey !== undefined) {
const idx = mergedDisplayData.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === rowKeyStr(selectedKey));
if (idx >= 0) {
setTextRecordIndex(idx);
}
}
}
setViewMode(nextMode);
}}
/>
</div>
</div>
{/* Filter Panel */}
{showFilter && (
@@ -2448,8 +2478,8 @@ const DataGrid: React.FC<DataGridProps> = ({
/>
</div>
</div>
) : (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
) : (
<div style={{ height: '100%', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(0,0,0,0.08)', display: 'flex', alignItems: 'center', gap: 8 }}>
<Button size="small" onClick={() => setTextRecordIndex(i => Math.max(0, i - 1))} disabled={textViewRows.length === 0 || textRecordIndex <= 0}>
@@ -2466,7 +2496,7 @@ const DataGrid: React.FC<DataGridProps> = ({
</Button>
)}
</div>
<div className="custom-scrollbar" style={{ flex: 1, overflow: 'auto', padding: '8px 12px' }}>
<div className="custom-scrollbar" style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '8px 12px' }}>
{currentTextRow ? columnNames.map((col) => (
<div key={col} style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 10, padding: '6px 0', borderBottom: darkMode ? '1px solid rgba(255,255,255,0.06)' : '1px solid rgba(0,0,0,0.06)', alignItems: 'start' }}>
<div style={{ fontWeight: 600, color: darkMode ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.88)', wordBreak: 'break-all' }}>
@@ -2672,8 +2702,21 @@ const DataGrid: React.FC<DataGridProps> = ({
</div>
)}
<style>{`
.${gridId} .ant-table { background: transparent !important; }
<style>{`
.${gridId} .data-grid-toolbar-scroll > * {
flex-shrink: 0;
}
.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar {
height: 7px;
}
.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-thumb {
background: ${darkMode ? 'rgba(255,255,255,0.28)' : 'rgba(0,0,0,0.22)'};
border-radius: 999px;
}
.${gridId} .data-grid-toolbar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.${gridId} .ant-table { background: transparent !important; }
.${gridId} .ant-table-container { background: transparent !important; border: none !important; }
.${gridId} .ant-table-tbody > tr > td { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }
.${gridId} .ant-table-thead > tr > th { background: transparent !important; border-bottom: 1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'} !important; border-inline-end: 1px solid transparent !important; }

View File

@@ -4,7 +4,7 @@ import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
import { DBQuery, DBGetColumns } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, type FilterCondition } from '../utils/sql';
import { buildOrderBySQL, buildWhereSQL, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql';
const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const [data, setData] = useState<any[]>([]);
@@ -60,6 +60,8 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
};
const dbType = config.type || '';
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const isMySQLFamily = dbTypeLower === 'mysql' || dbTypeLower === 'mariadb';
const dbName = tab.dbName || '';
const tableName = tab.tableName || '';
@@ -74,24 +76,46 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
// 大表性能:打开表不阻塞在 COUNT(*),先通过多取 1 条判断是否还有下一页;总数在后台统计并异步回填。
sql += ` LIMIT ${size + 1} OFFSET ${offset}`;
const startTime = Date.now();
const requestStartTime = Date.now();
let executedSql = sql;
try {
const pData = DBQuery(config as any, dbName, sql);
const executeDataQuery = async (querySql: string, attemptLabel: string) => {
const startTime = Date.now();
const result = await DBQuery(config as any, dbName, querySql);
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: querySql,
status: result.success ? 'success' : 'error',
duration: Date.now() - startTime,
message: result.success ? '' : `${attemptLabel}: ${result.message}`,
affectedRows: Array.isArray(result.data) ? result.data.length : undefined,
dbName
});
return result;
};
const resData = await pData;
const duration = Date.now() - startTime;
// Log Execution
addSqlLog({
id: `log-${Date.now()}-data`,
timestamp: Date.now(),
sql: sql,
status: resData.success ? 'success' : 'error',
duration: duration,
message: resData.success ? '' : resData.message,
affectedRows: Array.isArray(resData.data) ? resData.data.length : undefined,
dbName
});
const hasSort = !!sortInfo?.columnKey && (sortInfo?.order === 'ascend' || sortInfo?.order === 'descend');
const isSortMemoryErr = (msg: string) => /error\s*1038|out of sort memory/i.test(String(msg || ''));
let resData = await executeDataQuery(sql, '主查询');
if (!resData.success && isMySQLFamily && hasSort && isSortMemoryErr(resData.message)) {
const retrySql32MB = withSortBufferTuningSQL(dbType, sql, 32 * 1024 * 1024);
if (retrySql32MB !== sql) {
executedSql = retrySql32MB;
resData = await executeDataQuery(retrySql32MB, '重试(32MB sort_buffer)');
}
if (!resData.success && isSortMemoryErr(resData.message)) {
const retrySql128MB = withSortBufferTuningSQL(dbType, sql, 128 * 1024 * 1024);
if (retrySql128MB !== executedSql) {
executedSql = retrySql128MB;
resData = await executeDataQuery(retrySql128MB, '重试(128MB sort_buffer)');
}
}
if (resData.success) {
message.warning('已自动提升排序缓冲并重试成功。');
}
}
if (pkColumns.length === 0) {
const pkKey = `${tab.connectionId}|${dbName}|${tableName}`;
@@ -187,7 +211,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}
}
} else {
message.error(resData.message);
message.error(String(resData.message || '查询失败'));
}
} catch (e: any) {
if (fetchSeqRef.current !== seq) return;
@@ -195,9 +219,9 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
addSqlLog({
id: `log-${Date.now()}-error`,
timestamp: Date.now(),
sql: sql,
sql: executedSql,
status: 'error',
duration: Date.now() - startTime,
duration: Date.now() - requestStartTime,
message: e.message,
dbName
});
@@ -211,7 +235,15 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
const handleReload = useCallback(() => {
fetchData(pagination.current, pagination.pageSize);
}, [fetchData, pagination.current, pagination.pageSize]);
const handleSort = useCallback((field: string, order: string) => setSortInfo({ columnKey: field, order }), []);
const handleSort = useCallback((field: string, order: string) => {
const normalizedOrder = order === 'ascend' || order === 'descend' ? order : '';
const normalizedField = String(field || '').trim();
if (!normalizedField || !normalizedOrder) {
setSortInfo(null);
return;
}
setSortInfo({ columnKey: normalizedField, order: normalizedOrder });
}, []);
const handlePageChange = useCallback((page: number, size: number) => fetchData(page, size), [fetchData]);
const handleToggleFilter = useCallback(() => setShowFilter(prev => !prev), []);
const handleApplyFilter = useCallback((conditions: FilterCondition[]) => setFilterConditions(conditions), []);
@@ -221,7 +253,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
}, [tab, sortInfo, filterConditions]); // Initial load and re-load on sort/filter
return (
<div style={{ flex: '1 1 auto', minHeight: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ flex: '1 1 auto', minHeight: 0, minWidth: 0, height: '100%', width: '100%', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<DataGrid
data={data}
columnNames={columnNames}
@@ -238,6 +270,7 @@ const DataViewer: React.FC<{ tab: TabData }> = ({ tab }) => {
onToggleFilter={handleToggleFilter}
onApplyFilter={handleApplyFilter}
readOnly={forceReadOnly}
sortInfoExternal={sortInfo}
/>
</div>
);

View File

@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Table, Input, Button, Space, Tag, Tree, Spin, message, Modal, Form, InputNumber, Popconfirm, Tooltip, Radio } from 'antd';
import { ReloadOutlined, DeleteOutlined, PlusOutlined, EditOutlined, SearchOutlined, ClockCircleOutlined, CopyOutlined, FolderOpenOutlined, KeyOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { RedisKeyInfo, RedisValue } from '../types';
import { RedisKeyInfo, RedisValue, StreamEntry } from '../types';
import Editor from '@monaco-editor/react';
import type { DataNode } from 'antd/es/tree';
@@ -625,6 +625,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
case 'list': return 'orange';
case 'set': return 'purple';
case 'zset': return 'magenta';
case 'stream': return 'cyan';
default: return 'default';
}
};
@@ -1468,6 +1469,212 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
);
};
const renderStreamValue = () => {
const processValue = (value: string) => {
if (viewMode === 'hex') {
return { displayValue: toHexDisplay(value), isBinary: true, isJson: false, encoding: 'HEX' };
} else if (viewMode === 'text') {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'Text' };
} else if (viewMode === 'utf8') {
try {
const bytes = new Uint8Array(value.length);
for (let i = 0; i < value.length; i++) {
bytes[i] = value.charCodeAt(i) & 0xFF;
}
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(bytes);
return { displayValue: decoded, isBinary: false, isJson: false, encoding: 'UTF-8' };
} catch (e) {
return { displayValue: value, isBinary: false, isJson: false, encoding: 'UTF-8 (失败)' };
}
} else {
return formatStringValue(value);
}
};
const data = (keyValue.value as StreamEntry[]).map((item, index) => {
const rawFieldsText = JSON.stringify(item.fields ?? {}, null, 2);
const { displayValue, isBinary, isJson, encoding } = processValue(rawFieldsText);
return {
index,
id: item.id,
rawFieldsText,
displayFields: displayValue,
isBinary,
isJson,
encoding,
};
});
const handleAddStreamEntry = async (fieldsText: string, id: string) => {
const config = getConfig();
if (!config) return;
let parsed: unknown;
try {
parsed = JSON.parse(fieldsText);
} catch (e) {
message.error('字段 JSON 格式不正确');
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('字段必须是 JSON 对象');
return;
}
const fieldMap: Record<string, string> = {};
Object.entries(parsed as Record<string, unknown>).forEach(([field, value]) => {
fieldMap[field] = value == null ? '' : String(value);
});
if (Object.keys(fieldMap).length === 0) {
message.error('至少提供一个字段');
return;
}
try {
const res = await (window as any).go.app.App.RedisStreamAdd(config, selectedKey, fieldMap, id || '*');
if (res.success) {
const newID = res.data?.id ? ` (${res.data.id})` : '';
message.success(`添加成功${newID}`);
loadKeyValue(selectedKey);
} else {
message.error('添加失败: ' + res.message);
}
} catch (e: any) {
message.error('添加失败: ' + (e?.message || String(e)));
}
};
const handleDeleteStreamEntry = async (id: string) => {
const config = getConfig();
if (!config) return;
try {
const res = await (window as any).go.app.App.RedisStreamDelete(config, selectedKey, [id]);
if (res.success) {
const deleted = Number(res.data?.deleted ?? 0);
if (deleted > 0) {
message.success('删除成功');
} else {
message.warning('未删除任何消息,可能已不存在');
}
loadKeyValue(selectedKey);
} else {
message.error('删除失败: ' + res.message);
}
} catch (e: any) {
message.error('删除失败: ' + (e?.message || String(e)));
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button size="small" icon={<PlusOutlined />} onClick={() => {
Modal.confirm({
title: '添加 Stream 消息',
width: 680,
content: (
<div>
<div style={{ marginBottom: 8 }}>
<label>ID *</label>
<Input id="new-stream-id" placeholder="例如: * 或 1723110000000-0" />
</div>
<div>
<label> JSON</label>
<Input.TextArea id="new-stream-fields" rows={8} defaultValue={'{\n "field": "value"\n}'} />
</div>
</div>
),
onOk: async () => {
const id = (document.getElementById('new-stream-id') as HTMLInputElement)?.value?.trim() || '*';
const fieldsText = (document.getElementById('new-stream-fields') as HTMLTextAreaElement)?.value || '{}';
await handleAddStreamEntry(fieldsText, id);
}
});
}}></Button>
<Radio.Group size="small" value={viewMode} onChange={(e) => setViewMode(e.target.value)}>
<Radio.Button value="auto"></Radio.Button>
<Radio.Button value="text"></Radio.Button>
<Radio.Button value="utf8">UTF-8</Radio.Button>
<Radio.Button value="hex"></Radio.Button>
</Radio.Group>
</div>
<Table
dataSource={data}
columns={[
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 240,
ellipsis: true,
},
{
title: '字段',
dataIndex: 'displayFields',
key: 'fields',
ellipsis: true,
render: (text: string, record: any) => {
const tooltipContent = record.encoding && record.encoding !== 'UTF-8'
? `[${record.encoding}]\n${text}`
: text;
return (
<Tooltip title={<pre style={{ maxHeight: 300, overflow: 'auto', margin: 0, fontSize: 12 }}>{tooltipContent}</pre>} styles={{ root: { maxWidth: 720 } }}>
<span style={{
color: record.isBinary ? '#d46b08' : (record.isJson ? '#1890ff' : undefined),
fontFamily: record.isBinary ? 'monospace' : undefined,
fontSize: record.isBinary ? 11 : undefined
}}>
{text}
</span>
</Tooltip>
);
}
},
{
title: '操作',
key: 'action',
width: 140,
render: (_: any, record: any) => (
<Space size="small">
<Tooltip title="复制 ID">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.id).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Tooltip title="复制字段 JSON">
<Button type="text" size="small" icon={<CopyOutlined />} onClick={() => {
navigator.clipboard.writeText(record.rawFieldsText).then(() => {
message.success('已复制');
}).catch(() => {
message.error('复制失败');
});
}} />
</Tooltip>
<Popconfirm title="确定删除此消息?" onConfirm={() => handleDeleteStreamEntry(record.id)}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
)
}
]}
rowKey="id"
size="small"
pagination={{ pageSize: 50 }}
scroll={{ y: 'calc(100vh - 350px)' }}
style={{ flex: 1 }}
/>
</div>
);
};
return (
<div style={{ padding: 12, height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexShrink: 0 }}>
@@ -1511,6 +1718,7 @@ const RedisViewer: React.FC<RedisViewerProps> = ({ connectionId, redisDB }) => {
{keyValue.type === 'list' && renderListValue()}
{keyValue.type === 'set' && renderSetValue()}
{keyValue.type === 'zset' && renderZSetValue()}
{keyValue.type === 'stream' && renderStreamValue()}
</div>
</div>
);

View File

@@ -53,6 +53,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const addTab = useStore(state => state.addTab);
const setActiveContext = useStore(state => state.setActiveContext);
const removeConnection = useStore(state => state.removeConnection);
const closeTabsByConnection = useStore(state => state.closeTabsByConnection);
const closeTabsByDatabase = useStore(state => state.closeTabsByDatabase);
const theme = useStore(state => state.theme);
const appearance = useStore(state => state.appearance);
const tableAccessCount = useStore(state => state.tableAccessCount);
@@ -1592,6 +1594,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
const res = await DropDatabase(config as any, dbName);
if (res.success) {
message.success("数据库删除成功");
closeTabsByDatabase(conn.id, dbName);
setExpandedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
setLoadedKeys(prev => prev.filter(k => !k.toString().startsWith(`${conn.id}-${dbName}`)));
await loadDatabases(getConnectionNodeRef(conn));
@@ -2095,6 +2098,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
closeTabsByConnection(String(node.key));
message.success("已断开连接");
}
},
@@ -2107,7 +2111,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
onOk: () => {
closeTabsByConnection(String(node.key));
removeConnection(node.key);
}
});
}
}
@@ -2177,6 +2184,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
// Clear children (undefined to trigger reload)
setTreeData(origin => updateTreeData(origin, node.key, undefined));
closeTabsByConnection(String(node.key));
message.success("已断开连接");
}
},
@@ -2189,7 +2197,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
Modal.confirm({
title: '确认删除',
content: `确定要删除连接 "${node.title}" 吗?`,
onOk: () => removeConnection(node.key)
onOk: () => {
closeTabsByConnection(String(node.key));
removeConnection(node.key);
}
});
}
}
@@ -2276,6 +2287,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
label: '关闭数据库',
icon: <DisconnectOutlined />,
onClick: () => {
const dbConnId = String(node.dataRef?.id || '');
const dbName = String(node.dataRef?.dbName || node.title || '').trim();
setConnectionStates(prev => {
const next = { ...prev };
delete next[node.key];
@@ -2284,6 +2297,10 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
setExpandedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setLoadedKeys(prev => prev.filter(k => k !== node.key && !k.toString().startsWith(`${node.key}-`)));
setTreeData(origin => updateTreeData(origin, node.key, undefined));
if (dbConnId && dbName) {
closeTabsByDatabase(dbConnId, dbName);
}
message.success("已关闭数据库");
}
},
{

View File

@@ -116,6 +116,7 @@ const TabManager: React.FC = () => {
height: 100%;
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -126,6 +127,7 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-content-holder {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
@@ -133,12 +135,14 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-content {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.main-tabs .ant-tabs-tabpane {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -146,6 +150,7 @@ const TabManager: React.FC = () => {
.main-tabs .ant-tabs-tabpane > div {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
}
.main-tabs .ant-tabs-tabpane-hidden {
display: none !important;

View File

@@ -269,6 +269,8 @@ interface AppState {
closeOtherTabs: (id: string) => void;
closeTabsToLeft: (id: string) => void;
closeTabsToRight: (id: string) => void;
closeTabsByConnection: (connectionId: string) => void;
closeTabsByDatabase: (connectionId: string, dbName: string) => void;
closeAllTabs: () => void;
setActiveTab: (id: string) => void;
setActiveContext: (context: { connectionId: string; dbName: string } | null) => void;
@@ -428,6 +430,45 @@ export const useStore = create<AppState>()(
return { tabs: newTabs, activeTabId: activeStillExists ? state.activeTabId : id };
}),
closeTabsByConnection: (connectionId) => set((state) => {
const targetConnectionId = String(connectionId || '').trim();
if (!targetConnectionId) return state;
const newTabs = state.tabs.filter(t => String(t.connectionId || '').trim() !== targetConnectionId);
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
const nextActiveTabId = activeStillExists
? state.activeTabId
: (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
const nextActiveContext = state.activeContext?.connectionId === targetConnectionId ? null : state.activeContext;
return {
tabs: newTabs,
activeTabId: nextActiveTabId,
activeContext: nextActiveContext,
};
}),
closeTabsByDatabase: (connectionId, dbName) => set((state) => {
const targetConnectionId = String(connectionId || '').trim();
const targetDbName = String(dbName || '').trim();
if (!targetConnectionId || !targetDbName) return state;
const newTabs = state.tabs.filter((tab) => {
const sameConnection = String(tab.connectionId || '').trim() === targetConnectionId;
const sameDb = String(tab.dbName || '').trim() === targetDbName;
return !(sameConnection && sameDb);
});
const activeStillExists = state.activeTabId ? newTabs.some(t => t.id === state.activeTabId) : false;
const nextActiveTabId = activeStillExists
? state.activeTabId
: (newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null);
const sameActiveContext = state.activeContext
&& state.activeContext.connectionId === targetConnectionId
&& state.activeContext.dbName === targetDbName;
return {
tabs: newTabs,
activeTabId: nextActiveTabId,
activeContext: sameActiveContext ? null : state.activeContext,
};
}),
closeAllTabs: () => set(() => ({ tabs: [], activeTabId: null })),
setActiveTab: (id) => set({ activeTabId: id }),

View File

@@ -131,7 +131,7 @@ export interface RedisScanResult {
}
export interface RedisValue {
type: 'string' | 'hash' | 'list' | 'set' | 'zset';
type: 'string' | 'hash' | 'list' | 'set' | 'zset' | 'stream';
ttl: number;
value: any;
length: number;
@@ -146,3 +146,8 @@ export interface ZSetMember {
member: string;
score: number;
}
export interface StreamEntry {
id: string;
fields: Record<string, string>;
}

View File

@@ -68,11 +68,39 @@ type SortInfo = {
order?: string;
} | null | undefined;
// 为排序查询按库类型注入 sort_buffer 提升参数(仅影响当前语句)。
// MySQL: 使用 Optimizer Hint `SET_VAR`。
// MariaDB: 使用 `SET STATEMENT ... FOR` 包装当前查询。
export const withSortBufferTuningSQL = (
dbType: string,
sql: string,
sortBufferBytes: number,
) => {
const rawSql = String(sql || '');
const trimmed = rawSql.trim();
if (!trimmed) return rawSql;
if (!/^select\b/i.test(trimmed)) return rawSql;
const normalizedType = String(dbType || '').trim().toLowerCase();
const bytes = Math.max(256 * 1024, Math.floor(Number(sortBufferBytes) || 0));
if (normalizedType === 'mysql') {
return rawSql.replace(
/^\s*select\b/i,
(matched) => `${matched} /*+ SET_VAR(sort_buffer_size=${bytes}) */`,
);
}
if (normalizedType === 'mariadb') {
return `SET STATEMENT sort_buffer_size=${bytes} FOR ${rawSql}`;
}
return rawSql;
};
export const buildOrderBySQL = (
dbType: string,
sortInfo: SortInfo,
fallbackColumns: string[] = [],
) => {
const dbTypeLower = String(dbType || '').trim().toLowerCase();
const sortColumn = normalizeIdentPart(String(sortInfo?.columnKey || ''));
const sortOrder = String(sortInfo?.order || '');
const direction = sortOrder === 'ascend' ? 'ASC' : sortOrder === 'descend' ? 'DESC' : '';
@@ -80,6 +108,13 @@ export const buildOrderBySQL = (
return ` ORDER BY ${quoteIdentPart(dbType, sortColumn)} ${direction}`;
}
// MySQL/MariaDB 大表在无显式排序需求时强制 ORDER BY即使按主键可能触发 filesort
// 导致 `Error 1038 (HY001): Out of sort memory`。
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb') {
return '';
}
const seen = new Set<string>();
const stableColumns = (fallbackColumns || [])
.map((col) => normalizeIdentPart(String(col || '')))

View File

@@ -120,6 +120,10 @@ export function RedisSetString(arg1:connection.ConnectionConfig,arg2:string,arg3
export function RedisSetTTL(arg1:connection.ConnectionConfig,arg2:string,arg3:number):Promise<connection.QueryResult>;
export function RedisStreamAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Record<string, string>,arg4:string):Promise<connection.QueryResult>;
export function RedisStreamDelete(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<string>):Promise<connection.QueryResult>;
export function RedisTestConnection(arg1:connection.ConnectionConfig):Promise<connection.QueryResult>;
export function RedisZSetAdd(arg1:connection.ConnectionConfig,arg2:string,arg3:Array<redis.ZSetMember>):Promise<connection.QueryResult>;

View File

@@ -234,6 +234,14 @@ export function RedisSetTTL(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisSetTTL'](arg1, arg2, arg3);
}
export function RedisStreamAdd(arg1, arg2, arg3, arg4) {
return window['go']['app']['App']['RedisStreamAdd'](arg1, arg2, arg3, arg4);
}
export function RedisStreamDelete(arg1, arg2, arg3) {
return window['go']['app']['App']['RedisStreamDelete'](arg1, arg2, arg3);
}
export function RedisTestConnection(arg1) {
return window['go']['app']['App']['RedisTestConnection'](arg1);
}

View File

@@ -450,6 +450,40 @@ func (a *App) RedisZSetRemove(config connection.ConnectionConfig, key string, me
return connection.QueryResult{Success: true, Message: "删除成功"}
}
// RedisStreamAdd adds an entry to a stream
func (a *App) RedisStreamAdd(config connection.ConnectionConfig, key string, fields map[string]string, id string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
newID, err := client.StreamAdd(key, fields, id)
if err != nil {
logger.Error(err, "RedisStreamAdd 添加失败key=%s id=%s", key, id)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "添加成功", Data: map[string]string{"id": newID}}
}
// RedisStreamDelete deletes stream entries by IDs
func (a *App) RedisStreamDelete(config connection.ConnectionConfig, key string, ids []string) connection.QueryResult {
config.Type = "redis"
client, err := a.getRedisClient(config)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
deleted, err := client.StreamDelete(key, ids...)
if err != nil {
logger.Error(err, "RedisStreamDelete 删除失败key=%s ids=%v", key, ids)
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Message: "删除成功", Data: map[string]int64{"deleted": deleted}}
}
// RedisFlushDB flushes the current database
func (a *App) RedisFlushDB(config connection.ConnectionConfig) connection.QueryResult {
config.Type = "redis"

View File

@@ -1,6 +1,7 @@
package db
import (
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
@@ -67,7 +68,39 @@ func (s *SphinxDB) GetDatabases() ([]string, error) {
}
func (s *SphinxDB) GetTables(dbName string) ([]string, error) {
return s.MySQLDB.GetTables(s.resolveDatabaseName(dbName))
tables, err := s.MySQLDB.GetTables(s.resolveDatabaseName(dbName))
if err == nil {
return tables, nil
}
if !isSphinxUnsupportedFeatureError(err) {
return nil, err
}
// Sphinx/Manticore 常见返回列名为 `Index`,并且不支持 `SHOW TABLES FROM <db>` 语法。
data, fields, fallbackErr := s.MySQLDB.Query("SHOW TABLES")
if fallbackErr != nil {
return nil, fallbackErr
}
fallbackTables := make([]string, 0, len(data))
for _, row := range data {
if val, ok := row["Index"]; ok {
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
continue
}
if val, ok := row["index"]; ok {
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
continue
}
for _, field := range fields {
if val, ok := row[field]; ok {
fallbackTables = append(fallbackTables, fmt.Sprintf("%v", val))
break
}
}
}
return fallbackTables, nil
}
func (s *SphinxDB) GetCreateStatement(dbName, tableName string) (string, error) {

View File

@@ -4,7 +4,7 @@ import "GoNavi-Wails/internal/connection"
// RedisValue represents a Redis value with its type and metadata
type RedisValue struct {
Type string `json:"type"` // string, hash, list, set, zset
Type string `json:"type"` // string, hash, list, set, zset, stream
TTL int64 `json:"ttl"` // TTL in seconds, -1 means no expiry, -2 means key doesn't exist
Value interface{} `json:"value"` // The actual value
Length int64 `json:"length"` // Length/size of the value
@@ -72,6 +72,11 @@ type RedisClient interface {
ZSetAdd(key string, members ...ZSetMember) error
ZSetRemove(key string, members ...string) error
// Stream operations
GetStream(key, start, stop string, count int64) ([]StreamEntry, error)
StreamAdd(key string, fields map[string]string, id string) (string, error)
StreamDelete(key string, ids ...string) (int64, error)
// Command execution
ExecuteCommand(args []string) (interface{}, error)
@@ -88,3 +93,9 @@ type ZSetMember struct {
Member string `json:"member"`
Score float64 `json:"score"`
}
// StreamEntry represents a single stream message
type StreamEntry struct {
ID string `json:"id"`
Fields map[string]string `json:"fields"`
}

View File

@@ -334,6 +334,26 @@ func (r *RedisClientImpl) GetValue(key string) (*RedisValue, error) {
result.Value = members
result.Length = length
case "stream":
length, err := r.client.XLen(ctx, key).Result()
if err != nil {
return nil, err
}
result.Length = length
if length == 0 {
result.Value = []StreamEntry{}
break
}
limit := int64(1000)
if length < limit {
limit = length
}
val, err := r.client.XRangeN(ctx, key, "-", "+", limit).Result()
if err != nil {
return nil, err
}
result.Value = toStreamEntries(val)
default:
return nil, fmt.Errorf("不支持的 Redis 数据类型: %s", keyType)
}
@@ -523,6 +543,91 @@ func (r *RedisClientImpl) ZSetRemove(key string, members ...string) error {
return r.client.ZRem(ctx, key, args...).Err()
}
// GetStream gets stream entries in a range
func (r *RedisClientImpl) GetStream(key, start, stop string, count int64) ([]StreamEntry, error) {
if r.client == nil {
return nil, fmt.Errorf("Redis 客户端未连接")
}
if start == "" {
start = "-"
}
if stop == "" {
stop = "+"
}
if count <= 0 {
count = 1000
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
val, err := r.client.XRangeN(ctx, key, start, stop, count).Result()
if err != nil {
return nil, err
}
return toStreamEntries(val), nil
}
// StreamAdd adds an entry to a stream
func (r *RedisClientImpl) StreamAdd(key string, fields map[string]string, id string) (string, error) {
if r.client == nil {
return "", fmt.Errorf("Redis 客户端未连接")
}
if len(fields) == 0 {
return "", fmt.Errorf("Stream 字段不能为空")
}
if id == "" {
id = "*"
}
values := make(map[string]interface{}, len(fields))
for field, value := range fields {
values[field] = value
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
newID, err := r.client.XAdd(ctx, &redis.XAddArgs{
Stream: key,
ID: id,
Values: values,
}).Result()
if err != nil {
return "", err
}
return newID, nil
}
// StreamDelete deletes entries from a stream by IDs
func (r *RedisClientImpl) StreamDelete(key string, ids ...string) (int64, error) {
if r.client == nil {
return 0, fmt.Errorf("Redis 客户端未连接")
}
if len(ids) == 0 {
return 0, fmt.Errorf("Stream ID 不能为空")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return r.client.XDel(ctx, key, ids...).Result()
}
func toStreamEntries(messages []redis.XMessage) []StreamEntry {
entries := make([]StreamEntry, 0, len(messages))
for _, msg := range messages {
fields := make(map[string]string, len(msg.Values))
for field, value := range msg.Values {
fields[field] = fmt.Sprint(value)
}
entries = append(entries, StreamEntry{
ID: msg.ID,
Fields: fields,
})
}
return entries
}
// ExecuteCommand executes a raw Redis command
func (r *RedisClientImpl) ExecuteCommand(args []string) (interface{}, error) {
if r.client == nil {