feat: harden query read-only workflow
This commit is contained in:
1 parent
58bbc394e9
commit
617c15dbfe
29 files changed
+1417
-395
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import { Copy, Eye } from 'lucide-react';
|
||||
import type { RedisLineEditorState } from '../types';
|
||||
import {
|
||||
middleEllipsis,
|
||||
parseRedisKeyFromCommand,
|
||||
} from '../utils';
|
||||
|
||||
interface RedisHashQueryResultProps {
|
||||
rows: Record<string, unknown>[];
|
||||
rawKey?: string;
|
||||
rawType?: string;
|
||||
rawTtl?: string;
|
||||
command: string;
|
||||
onView: (state: RedisLineEditorState) => void;
|
||||
}
|
||||
|
||||
function compactPreview(value: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function endEllipsis(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) return value;
|
||||
return value.slice(0, Math.max(0, maxLength - 3)) + '...';
|
||||
}
|
||||
|
||||
export function RedisHashQueryResult({ rows, rawKey, rawType, rawTtl, command, onView }: RedisHashQueryResultProps) {
|
||||
var redisKey = String(rawKey || parseRedisKeyFromCommand(command) || '');
|
||||
var redisKeyLabel = redisKey ? middleEllipsis(redisKey, 20) : '-';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
|
||||
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-semibold text-blue-400">
|
||||
{String(rawType || 'hash').replace(/^./, function (c) { return c.toUpperCase(); })}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 max-w-[220px] flex-1 truncate rounded border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 py-1.5 font-mono text-[var(--dg-text-primary)]"
|
||||
title={redisKey}
|
||||
>
|
||||
{redisKeyLabel}
|
||||
</span>
|
||||
<span className="rounded border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-3 py-1.5 font-mono text-[var(--dg-text-muted)]">
|
||||
TTL {String(rawTtl ?? '-1')}
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full table-fixed border-collapse text-sm">
|
||||
<colgroup>
|
||||
<col className="w-14" />
|
||||
<col className="w-36" />
|
||||
<col />
|
||||
<col className="w-20" />
|
||||
</colgroup>
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-[var(--dg-text-muted)]">ID</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-[var(--dg-text-muted)]">Key</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-[var(--dg-text-muted)]">Value</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-[var(--dg-text-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(function (row, i) {
|
||||
var field = String(row.field ?? '');
|
||||
var fieldLabel = middleEllipsis(field, 14);
|
||||
var value = String(row.value ?? '');
|
||||
var preview = compactPreview(value);
|
||||
var previewLabel = endEllipsis(preview, 180);
|
||||
return (
|
||||
<tr key={i} className="h-10 border-b border-[var(--dg-border)] transition-colors hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{i + 1}</td>
|
||||
<td className="max-w-0 px-4 py-2 font-mono text-xs text-[var(--dg-text-primary)]">
|
||||
<div className="truncate" title={field}>{fieldLabel}</div>
|
||||
</td>
|
||||
<td className="max-w-0 px-4 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">
|
||||
<div className="truncate" title={preview}>{previewLabel}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button onClick={function () { navigator.clipboard.writeText(preview); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="复制格式化值">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={function () { onView({ open: true, field: field, value: value, format: 'Json' }); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="查看/格式化">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { FormDialog } from '@/components/FormDialog';
|
||||
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState } from '../types';
|
||||
import { formatStringByMode } from '../utils';
|
||||
|
||||
interface RedisLineEditorDialogProps {
|
||||
state: RedisLineEditorState;
|
||||
readOnlyField?: boolean;
|
||||
saving?: boolean;
|
||||
onChange: (state: RedisLineEditorState) => void;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onCopy?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function RedisLineEditorDialog({
|
||||
state,
|
||||
readOnlyField,
|
||||
saving,
|
||||
onChange,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onCopy,
|
||||
}: RedisLineEditorDialogProps) {
|
||||
return (
|
||||
<FormDialog open={state.open} onClose={onClose} title="编辑字段" width="max-w-4xl"
|
||||
footer={<>
|
||||
<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">取消</button>
|
||||
<button onClick={onConfirm} disabled={saving} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm disabled:opacity-50">保存</button>
|
||||
</>}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[var(--dg-text-muted)]">Field</label>
|
||||
<input
|
||||
readOnly={readOnlyField}
|
||||
value={state.field}
|
||||
onChange={function (e) { onChange(Object.assign({}, state, { field: e.target.value })); }}
|
||||
className="mt-1 h-10 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label className="text-sm text-[var(--dg-text-muted)]">Value</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md border border-blue-400/30 bg-blue-500/10 px-2 py-1 text-xs text-blue-400">
|
||||
Size: {new TextEncoder().encode(state.value).length}B
|
||||
</span>
|
||||
<button onClick={function () { onCopy ? onCopy(state.value) : navigator.clipboard.writeText(state.value); }} className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300">
|
||||
<Copy className="h-3.5 w-3.5" />Copy
|
||||
</button>
|
||||
<select
|
||||
value={state.format}
|
||||
onChange={function (e) { onChange(Object.assign({}, state, { format: e.target.value as RedisLineEditorState['format'] })); }}
|
||||
className="h-8 w-44 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 text-xs text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
{STRING_FORMAT_OPTIONS.map(function (format) {
|
||||
return <option key={format} value={format}>{format}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={state.value}
|
||||
onChange={function (e) { onChange(Object.assign({}, state, { value: e.target.value })); }}
|
||||
spellCheck={false}
|
||||
className="min-h-[160px] w-full resize-y rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]"
|
||||
/>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||
{formatStringByMode(state.value, state.format)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Code2, Copy, Eye, Pencil, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
compactValuePreview,
|
||||
normalizeZSetRows,
|
||||
normalizeValueForEdit,
|
||||
redisCommandForHashField,
|
||||
tryFormatJsonText,
|
||||
} from '../utils';
|
||||
|
||||
interface RedisStructuredValueProps {
|
||||
type: string;
|
||||
value: any;
|
||||
redisKey: string;
|
||||
search: string;
|
||||
onCopy: (text: string, message: string) => void;
|
||||
onInspect: (title: string, value: any) => void;
|
||||
onEditHashLine: (field: string, value: any) => void;
|
||||
onDeleteHashLine: (field: string) => void;
|
||||
}
|
||||
|
||||
export function RedisStructuredValue({
|
||||
type,
|
||||
value,
|
||||
redisKey,
|
||||
search,
|
||||
onCopy,
|
||||
onInspect,
|
||||
onEditHashLine,
|
||||
onDeleteHashLine,
|
||||
}: RedisStructuredValueProps) {
|
||||
var normalizedValue = normalizeValueForEdit(type, value);
|
||||
var normalizedSearch = search.trim().toLowerCase();
|
||||
|
||||
if (type === 'hash') {
|
||||
var entries = Object.entries(normalizedValue || {}).filter(function (entry) {
|
||||
return !normalizedSearch || entry[0].toLowerCase().includes(normalizedSearch) || String(entry[1]).toLowerCase().includes(normalizedSearch);
|
||||
});
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
|
||||
<th className="w-28 px-3 py-2 font-medium">ID (Total: {entries.length})</th>
|
||||
<th className="w-[30%] px-3 py-2 font-medium">Key</th>
|
||||
<th className="px-3 py-2 font-medium">Value</th>
|
||||
<th className="w-36 px-3 py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(function (entry, index) {
|
||||
var field = String(entry[0]);
|
||||
var preview = compactValuePreview(entry[1]);
|
||||
return (
|
||||
<tr key={field} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-primary)]">
|
||||
<div className="max-w-[320px] truncate" title={field}>{field}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">
|
||||
<div className="truncate" title={preview}>{preview}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button onClick={function () { onCopy(String(entry[1] ?? ''), '已复制格式化值'); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="格式化复制">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={function () { onEditHashLine(field, entry[1]); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="编辑">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={function () { onDeleteHashLine(field); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-red-400 hover:bg-red-500/10" title="删除字段">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={function () { onCopy(redisCommandForHashField(redisKey, field, entry[1]), '已复制 HSET 命令'); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-blue-400 hover:bg-blue-500/10" title="复制命令行">
|
||||
<Code2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'list' || type === 'set') {
|
||||
var list = Array.isArray(normalizedValue) ? normalizedValue.filter(function (item) {
|
||||
return !normalizedSearch || String(item).toLowerCase().includes(normalizedSearch);
|
||||
}) : [];
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
|
||||
<th className="w-20 px-3 py-2 font-medium">Index</th>
|
||||
<th className="px-3 py-2 font-medium">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map(function (item, index) {
|
||||
var pretty = typeof item === 'string' ? (tryFormatJsonText(item) || item) : JSON.stringify(item, null, 2);
|
||||
return (
|
||||
<tr key={index} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index}</td>
|
||||
<td className="px-3 py-2"><pre className="max-h-28 overflow-auto whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] px-2 py-1.5 font-mono text-xs text-[var(--dg-text-secondary)]">{pretty}</pre></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'zset') {
|
||||
var zRows = normalizeZSetRows(normalizedValue).filter(function (row) {
|
||||
return !normalizedSearch || row.member.toLowerCase().includes(normalizedSearch) || String(row.score).includes(normalizedSearch);
|
||||
});
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)] text-left text-xs text-[var(--dg-text-muted)]">
|
||||
<th className="w-28 px-3 py-2 font-medium">ID (Total: {zRows.length})</th>
|
||||
<th className="w-36 px-3 py-2 font-medium">Score</th>
|
||||
<th className="px-3 py-2 font-medium">Member</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zRows.map(function (row, index) {
|
||||
var memberPreview = compactValuePreview(row.member);
|
||||
return (
|
||||
<tr key={index} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<td className="px-3 py-2 text-xs text-[var(--dg-text-muted)]">{index + 1}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">{row.score}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-primary)]">
|
||||
<div className="truncate" title={memberPreview}>{memberPreview}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
<button onClick={function () { onCopy(row.member, '已复制 Member'); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-tertiary)] hover:text-[var(--dg-text-primary)]" title="复制 Member">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={function () { onInspect('score ' + row.score, row.member); }} className="inline-flex h-7 w-7 items-center justify-center rounded text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-tertiary)] hover:text-[var(--dg-text-primary)]" title="格式化查看">
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
var formatted = tryFormatJsonText(String(normalizedValue ?? ''));
|
||||
return (
|
||||
<pre className="min-h-[240px] whitespace-pre-wrap break-all rounded bg-[var(--dg-bg-primary)] p-4 font-mono text-xs text-[var(--dg-text-secondary)]">
|
||||
{formatted || String(normalizedValue ?? '')}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Copy } from 'lucide-react';
|
||||
import { FormDialog } from '@/components/FormDialog';
|
||||
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState } from '../types';
|
||||
import { formatStringByMode } from '../utils';
|
||||
|
||||
interface RedisValueViewerDialogProps {
|
||||
state: RedisLineEditorState;
|
||||
onChange: (state: RedisLineEditorState) => void;
|
||||
onClose: () => void;
|
||||
onCopy?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function RedisValueViewerDialog({
|
||||
state,
|
||||
onChange,
|
||||
onClose,
|
||||
onCopy,
|
||||
}: RedisValueViewerDialogProps) {
|
||||
return (
|
||||
<FormDialog open={state.open} onClose={onClose} title="查看值" width="max-w-4xl"
|
||||
footer={<button onClick={onClose} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] rounded-lg text-sm">关闭</button>}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-[var(--dg-text-muted)]">Field</label>
|
||||
<input
|
||||
readOnly
|
||||
value={state.field}
|
||||
className="mt-1 h-10 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 font-mono text-sm text-[var(--dg-text-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<label className="text-sm text-[var(--dg-text-muted)]">Value</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] px-2 py-1 text-xs text-[var(--dg-text-muted)]">
|
||||
Size: {new TextEncoder().encode(state.value).length}B
|
||||
</span>
|
||||
<button onClick={function () { onCopy ? onCopy(state.value) : navigator.clipboard.writeText(state.value); }} className="inline-flex items-center gap-1.5 text-xs text-blue-400 hover:text-blue-300">
|
||||
<Copy className="h-3.5 w-3.5" />复制
|
||||
</button>
|
||||
<select
|
||||
value={state.format}
|
||||
onChange={function (e) { onChange(Object.assign({}, state, { format: e.target.value as RedisLineEditorState['format'] })); }}
|
||||
className="h-8 w-44 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-2 text-xs text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
{STRING_FORMAT_OPTIONS.map(function (format) {
|
||||
return <option key={format} value={format}>{format}</option>;
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
value={state.value}
|
||||
spellCheck={false}
|
||||
className="min-h-[160px] w-full resize-y rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)] outline-none"
|
||||
/>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
|
||||
{formatStringByMode(state.value, state.format)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user