feat: harden query read-only workflow

This commit is contained in:
Logan.Li committed 2026-07-07 17:20:44 +08:00
1 parent 58bbc394e9
commit 617c15dbfe
29 files changed
+1417 -395

No files matched your search

+27 -25
View File
@@ -840,6 +840,10 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
} }
cmd := strings.ToUpper(parts[0]) cmd := strings.ToUpper(parts[0])
keyName := ""
if len(parts) > 1 {
keyName = parts[1]
}
readOnly := map[string]bool{ readOnly := map[string]bool{
"GET": true, "MGET": true, "HGET": true, "HGETALL": true, "GET": true, "MGET": true, "HGET": true, "HGETALL": true,
"LRANGE": true, "SMEMBERS": true, "ZRANGE": true, "LRANGE": true, "SMEMBERS": true, "ZRANGE": true,
@@ -967,6 +971,21 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
return nil, fmt.Errorf("command failed: %v", err) return nil, fmt.Errorf("command failed: %v", err)
} }
keyMeta := gin.H{}
if keyName != "" && cmd != "TYPE" && cmd != "TTL" && cmd != "PTTL" && cmd != "EXISTS" && cmd != "DBSIZE" && cmd != "INFO" {
keyMeta["raw_key"] = keyName
if t, err := client.Type(ctx, keyName).Result(); err == nil {
keyMeta["raw_type"] = t
}
if ttl, err := client.TTL(ctx, keyName).Result(); err == nil {
if ttl < 0 {
keyMeta["raw_ttl"] = "-1"
} else {
keyMeta["raw_ttl"] = fmt.Sprintf("%d", int(ttl.Seconds()))
}
}
}
// Format result into rows/columns for frontend table display // Format result into rows/columns for frontend table display
columns := []string{"key", "value"} columns := []string{"key", "value"}
var rows []interface{} var rows []interface{}
@@ -1128,10 +1147,6 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
columns = []string{"field", "value"} columns = []string{"field", "value"}
} else { } else {
// Single value result (GET, HGET, TYPE, TTL, etc.) // Single value result (GET, HGET, TYPE, TTL, etc.)
keyName := ""
if len(parts) > 1 {
keyName = parts[1]
}
rawValue := fmt.Sprintf("%v", v) rawValue := fmt.Sprintf("%v", v)
// Try to pretty-format JSON with Chinese characters preserved // Try to pretty-format JSON with Chinese characters preserved
var prettyJSON string var prettyJSON string
@@ -1147,20 +1162,6 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
prettyJSON = strings.TrimRight(buf.String(), "\n") prettyJSON = strings.TrimRight(buf.String(), "\n")
} }
} }
// ARDM-style: auto-fetch key type and TTL for KeyHeader
var keyType, keyTTL string
if keyName != "" && cmd != "TYPE" && cmd != "TTL" && cmd != "PTTL" && cmd != "EXISTS" && cmd != "DBSIZE" && cmd != "INFO" {
if t, err := client.Type(ctx, keyName).Result(); err == nil {
keyType = t
}
if ttl, err := client.TTL(ctx, keyName).Result(); err == nil {
if ttl < 0 {
keyTTL = "永不过期"
} else {
keyTTL = fmt.Sprintf("%vs", int(ttl.Seconds()))
}
}
}
result := gin.H{ result := gin.H{
"columns": []string{}, "columns": []string{},
"rows": []interface{}{}, "rows": []interface{}{},
@@ -1169,22 +1170,23 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string
"raw_key": keyName, "raw_key": keyName,
"raw_text": rawValue, "raw_text": rawValue,
} }
if keyType != "" { for k, v := range keyMeta {
result["raw_type"] = keyType result[k] = v
}
if keyTTL != "" {
result["raw_ttl"] = keyTTL
} }
return result, nil return result, nil
} }
} }
return gin.H{ response := gin.H{
"columns": columns, "columns": columns,
"rows": rowsOrDefault(rows), "rows": rowsOrDefault(rows),
"total": len(rows), "total": len(rows),
"count": len(rows), "count": len(rows),
}, nil }
for k, v := range keyMeta {
response[k] = v
}
return response, nil
} }
// executeMongoQuery — MongoDB query with pagination // executeMongoQuery — MongoDB query with pagination
+29 -6
View File
@@ -396,25 +396,41 @@ func RedisInfo(c *gin.Context) {
defer client.Close() defer client.Close()
ctx := context.Background() ctx := context.Background()
info, err := client.Info(ctx, "server", "memory", "clients", "stats", "keyspace").Result() info, err := client.Info(ctx).Result()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()})
return return
} }
// Parse info into structured map // Parse Redis INFO into both a flat map and section groups.
result := make(map[string]interface{}) result := make(map[string]interface{})
grouped := make(map[string]map[string]interface{})
currentSection := "General"
sections := strings.Split(info, "\n# ") sections := strings.Split(info, "\n# ")
for _, section := range sections { for _, section := range sections {
lines := strings.Split(section, "\n") lines := strings.Split(section, "\n")
for _, line := range lines { for lineIndex, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") { if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
currentSection = strings.TrimSpace(strings.TrimPrefix(line, "#"))
continue
}
if lineIndex == 0 && !strings.Contains(line, ":") {
currentSection = strings.TrimSpace(line)
continue continue
} }
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 { if len(parts) == 2 {
result[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
result[key] = value
if _, ok := grouped[currentSection]; !ok {
grouped[currentSection] = make(map[string]interface{})
}
grouped[currentSection][key] = value
} }
} }
} }
@@ -422,8 +438,15 @@ func RedisInfo(c *gin.Context) {
// Add db_size // Add db_size
dbSize, _ := client.DBSize(ctx).Result() dbSize, _ := client.DBSize(ctx).Result()
result["db_size"] = dbSize result["db_size"] = dbSize
if _, ok := grouped["Keyspace"]; !ok {
grouped["Keyspace"] = make(map[string]interface{})
}
grouped["Keyspace"]["db_size"] = dbSize
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, gin.H{
"info": result,
"sections": grouped,
})
} }
// GET /api/v1/redis/:id/dbsize // GET /api/v1/redis/:id/dbsize
+46
View File
@@ -0,0 +1,46 @@
# Frontend Architecture
OpsxSQL frontend uses a light domain-first structure:
- `pages`: route-level composition only
- `features`: business domains such as datasource, Redis, Mongo, query, ticket, RBAC, governance, system
- `api`: transport clients grouped by backend domain
- `components`: existing shared app components
- `shared`: new cross-domain primitives
Production guideline:
- Keep page files below about 700 lines.
- Move reusable business UI into `features/<domain>/components`.
- Move domain state/data orchestration into `features/<domain>/hooks`.
- Move cross-domain helpers into `shared/lib`.
- Avoid putting transport logic inside components.
- New API imports should prefer domain paths such as `api/datasource/redis` and `api/system/user`; top-level API files are compatibility shims.
- Database-engine-specific behavior belongs under `features/datasource/engines/<engine>` or the engine domain (`features/redis`, `features/mongo`) when the surface is large enough.
Domain map:
- `features/datasource`: instance inventory, connection forms, import flows, MySQL/PostgreSQL engine panels
- `features/redis`: Redis key/value browsing, Redis result formatting, Redis command helpers
- `features/mongo`: MongoDB collection/document browsing and Mongo shell result rendering
- `features/query`: self-service query workspace, templates, query history, result rendering
- `features/ticket`: change ticket lifecycle, execution, rollback, approval actions
- `features/governance`: workflows, audit rules, risk policy, execution windows, SLA policy
- `features/rbac`: users, roles, permission groups, LDAP sync
- `features/system`: settings, notifications, announcements, audit logs, appearance
First migration sample:
- Redis value formatting and command helpers now live in `features/redis`.
- Redis Hash result display is shared by Redis detail and query workflows.
- Query template/history UI now lives in `features/query`.
Current page-size hotspots:
- `InstancePage.tsx`: datasource domain
- `UsersPage.tsx`: RBAC domain
- `TicketsPage.tsx` and `TicketDetailPage.tsx`: ticket domain
- `WorkflowPage.tsx`: governance domain
- `LdapConfigPage.tsx`: RBAC/system boundary; should be folded into RBAC LDAP panels or system integration settings
Use `npm run audit:pages` in `frontend/` to find pages over the 700-line budget.
+94
View File
@@ -0,0 +1,94 @@
# Frontend Modernization Roadmap
This roadmap tracks the global cleanup from page-heavy implementation toward production-grade domain modules.
## Phase 1: Guardrails
- Add `features/*` domain boundaries.
- Keep page files below 700 lines.
- Add `npm run audit:pages`.
- Prefer `api/<domain>` imports for new code.
Status: started.
## Phase 2: Data Assets And Engines
Target pages:
- `InstancePage.tsx`
- `InstanceDetailPage.tsx`
- `RedisDetailPage.tsx`
- `MongoDBDetailPage.tsx`
- `SchemaSidebar.tsx`
Target domains:
- `features/datasource`
- `features/datasource/engines/mysql`
- `features/datasource/engines/postgresql`
- `features/redis`
- `features/mongo`
Expected outcome:
- MySQL/PostgreSQL/Redis/Mongo share consistent instance headers, metadata panels, query result density, and dangerous-action affordances.
- Engine-specific behavior is isolated.
## Phase 3: Query And Change Platform
Target pages:
- `QueriesPage.tsx`
- `TicketCreatePage.tsx`
- `TicketsPage.tsx`
- `TicketDetailPage.tsx`
- `AutoTaskPage.tsx`
Target domains:
- `features/query`
- `features/ticket`
Expected outcome:
- Query workspace, ticket creation, audit results, execution confirmation, rollback evidence, and todo views use shared production patterns.
## Phase 4: Governance And RBAC
Target pages:
- `WorkflowPage.tsx`
- `AuditRulesPage.tsx`
- `UsersPage.tsx`
- `RolesPage.tsx`
- `InstanceGroupPage.tsx`
- `LdapConfigPage.tsx`
Target domains:
- `features/governance`
- `features/rbac`
Expected outcome:
- Approval workflow, risk policy, roles, permissions, permission groups, and LDAP sync become coherent domains instead of page-local implementations.
## Phase 5: System Operations
Target pages:
- `SettingsPage.tsx`
- `ConfigPlatformPage.tsx`
- `PlatformConfigPage.tsx`
- `NotificationPage.tsx`
- `AnnouncementPage.tsx`
- `AuditLogsPage.tsx`
- `AppearanceSettings.tsx`
Target domain:
- `features/system`
Expected outcome:
- System settings, notifications, announcements, audit logs, retention, and appearance use one system-governance layout.
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"audit:pages": "node scripts/audit-page-size.mjs 700",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
+28
View File
@@ -0,0 +1,28 @@
import fs from 'node:fs';
import path from 'node:path';
const root = process.cwd();
const pagesDir = path.join(root, 'src/pages');
const limit = Number(process.argv[2] || 700);
const files = fs.readdirSync(pagesDir)
.filter((file) => file.endsWith('.tsx'))
.map((file) => {
const fullPath = path.join(pagesDir, file);
const lines = fs.readFileSync(fullPath, 'utf8').split('\n').length;
return { file, lines };
})
.sort((a, b) => b.lines - a.lines);
const overLimit = files.filter((item) => item.lines > limit);
console.log(`Page size budget: ${limit} lines\n`);
for (const item of files) {
const mark = item.lines > limit ? '!' : ' ';
console.log(`${mark} ${String(item.lines).padStart(4, ' ')} ${item.file}`);
}
if (overLimit.length > 0) {
console.log(`\n${overLimit.length} page(s) exceed the ${limit}-line budget.`);
process.exitCode = 1;
}
+2 -1
View File
@@ -38,13 +38,14 @@ function RouteGuard() {
const { isLoggedIn, hasPermission } = useAuth(); const { isLoggedIn, hasPermission } = useAuth();
const location = useLocation(); const location = useLocation();
const path = location.pathname.split('/')[1] || 'dashboard'; const path = location.pathname.split('/')[1] || 'dashboard';
const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record<string, string>)[path] || path;
if (!isLoggedIn) { if (!isLoggedIn) {
return <Navigate to="/login" replace />; return <Navigate to="/login" replace />;
} }
// 检查当前路径是否有权限访问 // 检查当前路径是否有权限访问
if (path !== 'dashboard' && !hasPermission(path)) { if (permissionPath !== 'dashboard' && !hasPermission(permissionPath)) {
return <Navigate to="/dashboard" replace />; return <Navigate to="/dashboard" replace />;
} }
+27
View File
@@ -0,0 +1,27 @@
# Feature Domains
`features/*` stores product-domain code. Page files should stay thin: routing, page-level state, and composition only.
Recommended structure:
- `features/<domain>/components`: domain UI components
- `features/<domain>/hooks`: domain data/state hooks
- `features/<domain>/types.ts`: domain types
- `features/<domain>/utils.ts`: domain-only helpers
Current domains:
- `datasource`: instance inventory and MySQL/PostgreSQL engine surfaces
- `redis`: Redis value formatting, Hash/ZSet viewers, Redis command helpers
- `mongo`: MongoDB database/collection/document surfaces
- `query`: self-service query workspace
- `ticket`: change ticket lifecycle
- `governance`: workflows, audit rules, approval policy
- `rbac`: users, roles, permissions, permission groups, LDAP sync
- `system`: settings, notifications, announcements, audit logs
Migration rule:
- If a page grows past roughly 700 lines, extract domain components or hooks before adding more behavior.
- If logic is reused by two domains, move it to `shared/*` instead of another feature domain.
- API clients stay under `api/<domain>`; features consume APIs but do not define transport details.
@@ -0,0 +1,14 @@
# Datasource Domain
Scope:
- MySQL / PostgreSQL / Redis / MongoDB instance inventory
- instance create/edit/import flows
- connection testing and deployment mode display
- instance grouping entry points shared with RBAC
Migration targets:
- Move instance list filters, cards, table rows, import dialogs, and create/edit forms out of `pages/InstancePage.tsx`.
- Keep engine-specific panels under `features/datasource/engines/<engine>` when behavior diverges.
- Keep transport calls in `api/datasource`.
@@ -0,0 +1,9 @@
# MySQL Feature Scope
MySQL-specific UI and behavior:
- schema/table/column browsing
- process list and kill flow
- status metrics
- SQL query context helpers
- DDL audit hints
@@ -0,0 +1,8 @@
# PostgreSQL Feature Scope
PostgreSQL-specific UI and behavior:
- schema/table/column browsing
- process/activity views
- query context helpers
- PostgreSQL audit hints and execution caveats
@@ -0,0 +1,13 @@
# Governance Domain
Scope:
- approval workflow configuration
- audit rules
- execution windows and approval SLA
- risk grading display and policy configuration
Migration targets:
- Split `WorkflowPage.tsx` into workflow list, node editor, condition editor, and default workflow panels.
- Split `AuditRulesPage.tsx` into rule filters, rule table, and rule editor.
+13
View File
@@ -0,0 +1,13 @@
# Mongo Domain
Scope:
- MongoDB database and collection browsing
- collection stats
- document query/result rendering
- Mongo shell read-only execution helpers
Migration targets:
- Move Mongo detail tabs and document table from `pages/MongoDBDetailPage.tsx` when they grow.
- Share formatter patterns with Redis only through `shared` when truly engine-neutral.
@@ -0,0 +1,119 @@
import {
CheckCircle,
ClipboardPaste,
History,
PanelRightClose,
Search,
Star,
StarOff,
X,
} from 'lucide-react';
import type { QueryHistoryItem } from '../types';
interface QueryHistoryDrawerProps {
history: QueryHistoryItem[];
filteredHistory: QueryHistoryItem[];
favFilter: boolean;
searchHistory: string;
clearConfirm: boolean;
dbTypeIcon: Record<string, string>;
onClose: () => void;
onSearchChange: (value: string) => void;
onToggleFavoriteFilter: () => void;
onClearHistory: () => void;
onToggleFavorite: (id: number) => void;
onUseSql: (sql: string) => void;
}
export function QueryHistoryDrawer({
history,
filteredHistory,
favFilter,
searchHistory,
clearConfirm,
dbTypeIcon,
onClose,
onSearchChange,
onToggleFavoriteFilter,
onClearHistory,
onToggleFavorite,
onUseSql,
}: QueryHistoryDrawerProps) {
return (
<div className="w-[300px] shrink-0 border-l border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col">
<div className="flex items-center justify-between px-4 py-3">
<span className="text-sm font-semibold flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
查询历史
{history.length > 0 && <span className="text-xs dg-text-muted font-normal">({history.length})</span>}
</span>
<button onClick={onClose} className="dg-btn dg-btn-ghost dg-btn-sm p-1">
<PanelRightClose className="w-4 h-4" />
</button>
</div>
{history.length > 0 && (
<div className="px-4 pb-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史..."
value={searchHistory}
onChange={function (e) { onSearchChange(e.target.value); }}
/>
</div>
<div className="flex items-center gap-2">
<button onClick={onToggleFavoriteFilter}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (favFilter ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : '')}>
<Star className="w-3 h-3" />仅收藏
</button>
<div className="flex-1" />
<button onClick={onClearHistory}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (clearConfirm ? 'text-red-400 bg-red-500/10' : '')}>
{clearConfirm ? <CheckCircle className="w-3 h-3" /> : <X className="w-3 h-3" />}
{clearConfirm ? '确认清除' : '清除'}
</button>
</div>
</div>
)}
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div>
) : filteredHistory.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
) : (
<div className="space-y-1.5 px-3 py-2">
{filteredHistory.map(function (h) {
return (
<div key={h.id} className={'dg-card px-3 py-2.5 group ' + (h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : '')}>
<div className="flex items-center gap-2 mb-1">
{h.db_type && dbTypeIcon[h.db_type] && <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />}
<span className={'text-[10px] px-1.5 py-0.5 rounded-full ' + (h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400')}>
{h.execution_time_ms}ms
</span>
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span>
<div className="flex-1" />
<button onClick={function () { onToggleFavorite(h.id); }}
className={'p-0.5 rounded transition-colors ' + (h.favorite ? 'text-yellow-400' : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400')}>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center gap-1">
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<button onClick={function () { onUseSql(h.sql); }}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
title="粘贴到编辑器">
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,70 @@
import { Search } from 'lucide-react';
import type { QueryTemplate } from '@/lib/queryTemplates';
interface QueryTemplatePanelProps {
categories: string[];
activeCategory: string;
categoryLabels: Record<string, string>;
search: string;
templates: QueryTemplate[];
onCategoryChange: (category: string) => void;
onSearchChange: (value: string) => void;
onApplyTemplate: (template: QueryTemplate) => void;
}
export function QueryTemplatePanel({
categories,
activeCategory,
categoryLabels,
search,
templates,
onCategoryChange,
onSearchChange,
onApplyTemplate,
}: QueryTemplatePanelProps) {
return (
<div className="shrink-0 px-5 pb-2">
<div className="dg-card p-3">
<div className="flex items-center gap-2 mb-3 flex-wrap">
{categories.map(function (cat) {
return (
<button key={cat} onClick={function () { onCategoryChange(cat); }}
className={'dg-btn dg-btn-ghost dg-btn-sm ' + (activeCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : '')}>
{categoryLabels[cat] || cat}
</button>
);
})}
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input
value={search}
onChange={function (e) { onSearchChange(e.target.value); }}
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
placeholder="搜索模板..."
/>
</div>
</div>
{templates.length === 0 ? (
<div className="text-center py-4 text-xs dg-text-muted">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{templates.map(function (tpl) {
return (
<button key={tpl.id} onClick={function () { onApplyTemplate(tpl); }}
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group"
title={tpl.description}>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
</div>
<p className="text-xs dg-text-muted line-clamp-1">{tpl.description}</p>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
export interface QueryHistoryItem {
id: number;
sql: string;
instance_id: number;
database: string;
db_type: string;
status: string;
execution_time_ms: number;
result_preview: string;
created_at: string;
favorite?: boolean;
}
+22
View File
@@ -0,0 +1,22 @@
export function exportToCSV(columns: string[], rows: Record<string, unknown>[]): string {
var header = columns.join(',');
var lines = rows.map(function (row) {
return columns.map(function (col) {
var val = String(row[col] ?? '');
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(',');
});
return [header].concat(lines).join('\n');
}
export function downloadCSV(filename: string, content: string) {
var blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
var link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
+14
View File
@@ -0,0 +1,14 @@
# RBAC Domain
Scope:
- users
- roles and permissions
- permission groups / instance groups
- LDAP import and sync flows
Migration targets:
- Split `pages/UsersPage.tsx` into user list, user forms, password reset, LDAP import, and permission assignment components.
- Keep permission constants and policy helpers here.
- Keep generic modals and tables in `shared` only if reused outside RBAC.
@@ -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>
);
}
+40
View File
@@ -0,0 +1,40 @@
export type StringFormat =
| 'String'
| 'Text'
| 'Hex'
| 'Json'
| 'Binary'
| 'Msgpack'
| 'PHPSerialize'
| 'Java Serialize'
| 'Pickle'
| 'Brotli'
| 'Gzip'
| 'Deflate'
| 'DeflateRaw'
| 'Protobuf';
export const STRING_FORMAT_OPTIONS: StringFormat[] = [
'String',
'Text',
'Hex',
'Json',
'Binary',
'Msgpack',
'PHPSerialize',
'Java Serialize',
'Pickle',
'Brotli',
'Gzip',
'Deflate',
'DeflateRaw',
'Protobuf',
];
export interface RedisLineEditorState {
open: boolean;
originalField?: string;
field: string;
value: string;
format: StringFormat;
}
+105
View File
@@ -0,0 +1,105 @@
import type { StringFormat } from './types';
export function ttlMsToSeconds(ttl: number | undefined): number {
if (ttl == null || ttl < 0) return -1;
return Math.max(1, Math.ceil(ttl / 1000));
}
export function formatTTL(ttl: number | undefined): string {
if (ttl == null || ttl < 0) return '永不过期';
if (ttl === 0) return '即将过期';
var seconds = Math.ceil(ttl / 1000);
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
export function normalizeZSetRows(value: any): { member: string; score: number }[] {
if (!Array.isArray(value)) return [];
return value.map(function (row: any) {
if (Array.isArray(row)) {
return { member: String(row[0] ?? ''), score: Number(row[1] ?? 0) };
}
return {
member: String(row?.member ?? row?.Member ?? ''),
score: Number(row?.score ?? row?.Score ?? 0),
};
});
}
export function normalizeValueForEdit(type: string, value: any): any {
if (type === 'zset') return normalizeZSetRows(value);
return value;
}
export function stringifyValue(type: string, value: any): string {
if (type === 'string') return typeof value === 'string' ? value : String(value ?? '');
return JSON.stringify(normalizeValueForEdit(type, value), null, 2);
}
export function parseEditorValue(type: string, text: string): any {
if (type === 'string') return text;
return JSON.parse(text || (type === 'hash' ? '{}' : '[]'));
}
export function tryFormatJsonText(text: string): string | null {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return null;
}
}
export function formatStringByMode(text: string, mode: StringFormat): string {
if (mode === 'String' || mode === 'Text') return text;
if (mode === 'Json') return tryFormatJsonText(text) || '不是合法 JSON,已显示原始内容:\n\n' + text;
var bytes = Array.from(new TextEncoder().encode(text));
if (mode === 'Hex') return bytes.map(function (byte) { return byte.toString(16).padStart(2, '0'); }).join(' ');
if (mode === 'Binary') return bytes.map(function (byte) { return byte.toString(2).padStart(8, '0'); }).join(' ');
return '当前前端暂不支持 ' + mode + ' 解码,已显示原始内容:\n\n' + text;
}
export function compactValuePreview(value: any): string {
var text = typeof value === 'string' ? value : JSON.stringify(value);
var formatted = typeof value === 'string' ? tryFormatJsonText(value) : null;
if (formatted) {
try {
text = JSON.stringify(JSON.parse(value));
} catch {
text = formatted.replace(/\s+/g, ' ');
}
}
return text || '';
}
export function middleEllipsis(text: string, maxLength: number = 36): string {
if (text.length <= maxLength) return text;
var keep = Math.max(6, Math.floor((maxLength - 3) / 2));
return text.slice(0, keep) + '...' + text.slice(text.length - keep);
}
export function redisCliQuote(value: any): string {
return JSON.stringify(String(value ?? ''));
}
export function redisCommandForHashField(key: string, field: string, value: any): string {
return 'HSET ' + redisCliQuote(key) + ' ' + redisCliQuote(field) + ' ' + redisCliQuote(value);
}
export function redisDeleteCommandForHashField(key: string, field: string): string {
return 'HDEL ' + redisCliQuote(key) + ' ' + redisCliQuote(field);
}
export function parseRedisKeyFromCommand(command: string): string {
var parts = command.match(/"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|\S+/g) || [];
if (parts.length < 2) return '';
var raw = parts[1];
if ((raw.startsWith('"') && raw.endsWith('"')) || (raw.startsWith("'") && raw.endsWith("'"))) {
try {
return JSON.parse(raw.replace(/^'/, '"').replace(/'$/, '"'));
} catch {
return raw.slice(1, -1);
}
}
return raw;
}
+15
View File
@@ -0,0 +1,15 @@
# System Domain
Scope:
- system settings
- notification channels
- announcements
- audit logs
- appearance settings
- platform cleanup and retention settings
Migration targets:
- Unify settings pages under one system domain.
- Keep low-level generic controls in `shared/ui`.
+14
View File
@@ -0,0 +1,14 @@
# Ticket Domain
Scope:
- change ticket list
- ticket create and detail
- approval timeline
- execution confirmation and rollback evidence
- todo / pending work views
Migration targets:
- Split `TicketsPage.tsx` and `TicketDetailPage.tsx` into list, filters, stats, detail header, timeline, SQL diff, approval actions, and execution panels.
- Keep SQL editor reuse in shared/query components only when it is not ticket-specific.
+49 -186
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { import {
X, AlertTriangle, CheckCircle, Play, Loader2, AlertTriangle, CheckCircle, Play, Loader2,
ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star, ChevronDown, ChevronRight, Clock, FileText, Sparkles,
StarOff, Download, Search, ArrowUpDown, Database, Table2, Download, Search, ArrowUpDown, Database, Table2,
ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3, ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3,
KeyRound, FolderOpen, Server, Zap, ClipboardPaste, KeyRound, FolderOpen, Server, Zap, ClipboardPaste,
History, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast, PanelRightClose, PanelRightOpen, ChevronLeft, ChevronLast,
} from 'lucide-react'; } from 'lucide-react';
// Card components replaced by design system utility classes // Card components replaced by design system utility classes
import { useActionPermission } from '@/hooks/useAuth'; import { useActionPermission } from '@/hooks/useAuth';
@@ -16,10 +16,14 @@ import { instanceApi } from '@/api/instance';
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates'; import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail'; import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates'; import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
import type { RedisLineEditorState } from '@/features/redis/types';
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import type { QueryHistoryItem } from '@/features/query/types';
import { downloadCSV, exportToCSV } from '@/features/query/utils';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
interface QueryResult { interface QueryResult {
columns: string[]; columns: string[];
rows: Record<string, unknown>[]; rows: Record<string, unknown>[];
@@ -37,25 +41,9 @@ interface QueryResult {
masked_columns?: string[]; masked_columns?: string[];
} }
interface QueryHistoryItem {
id: number;
sql: string;
instance_id: number;
database: string;
db_type: string;
status: string;
execution_time_ms: number;
result_preview: string;
created_at: string;
favorite?: boolean;
}
type SortDirection = 'asc' | 'desc' | null; type SortDirection = 'asc' | 'desc' | null;
type SortState = { column: string | null; direction: SortDirection }; type SortState = { column: string | null; direction: SortDirection };
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
const dbTypeLabel: Record<string, string> = { const dbTypeLabel: Record<string, string> = {
mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB', mysql: 'MySQL', postgres: 'PostgreSQL', postgresql: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB', mongodb: 'MongoDB',
}; };
@@ -93,35 +81,6 @@ const queryPolicyText: Record<DBType, string> = {
mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。', mongodb: 'MongoDB 查询入口用于 find / aggregate / count / distinct 等只读操作;写操作请提交变更工单。',
}; };
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function exportToCSV(columns: string[], rows: Record<string, unknown>[]): string {
const header = columns.join(',');
const lines = rows.map(row =>
columns.map(col => {
const val = String(row[col] ?? '');
if (val.includes(',') || val.includes('"') || val.includes('\n')) {
return `"${val.replace(/"/g, '""')}"`;
}
return val;
}).join(',')
);
return [header, ...lines].join('\n');
}
function downloadCSV(filename: string, content: string) {
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
/* ================================================================== */
/* Main Page — 直接显示编辑器 + 可折叠历史抽屉 */
/* ================================================================== */
export default function QueriesPage() { export default function QueriesPage() {
// -- 编辑器 state -- // -- 编辑器 state --
const [sql, setSql] = useState(''); const [sql, setSql] = useState('');
@@ -134,6 +93,7 @@ export default function QueriesPage() {
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null); const [result, setResult] = useState<QueryResult | null>(null);
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
// -- Pagination -- // -- Pagination --
const [pageSize, setPageSize] = useState(100); const [pageSize, setPageSize] = useState(100);
@@ -367,9 +327,6 @@ export default function QueriesPage() {
return r; return r;
}, [history, favFilter, searchHistory]); }, [history, favFilter, searchHistory]);
/* ================================================================ */
/* Render */
/* ================================================================ */
return ( return (
<div className="flex h-[calc(100vh-3rem)] gap-0"> <div className="flex h-[calc(100vh-3rem)] gap-0">
{/* ====== 主区域 ====== */} {/* ====== 主区域 ====== */}
@@ -435,44 +392,17 @@ export default function QueriesPage() {
</div> </div>
</div> </div>
{/* -- 模板选择器 -- */}
{showTemplates && ( {showTemplates && (
<div className="shrink-0 px-5 pb-2"> <QueryTemplatePanel
<div className="dg-card p-3"> categories={templateCategories}
<div className="flex items-center gap-2 mb-3 flex-wrap"> activeCategory={templateCategory}
{templateCategories.map(cat => ( categoryLabels={catLabel}
<button key={cat} onClick={() => setTemplateCategory(cat)} search={templateSearch}
className={`dg-btn dg-btn-ghost dg-btn-sm ${templateCategory === cat ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}> templates={filteredTemplates}
{catLabel[cat] || cat} onCategoryChange={setTemplateCategory}
</button> onSearchChange={setTemplateSearch}
))} onApplyTemplate={handleApplyTemplate}
<div className="flex-1" /> />
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input value={templateSearch} onChange={e => setTemplateSearch(e.target.value)}
className="pl-7 pr-3 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-md text-xs focus:border-blue-400 focus:outline-none w-40"
placeholder="搜索模板..." />
</div>
</div>
{filteredTemplates.length === 0 ? (
<div className="text-center py-4 text-xs dg-text-muted">无匹配模板</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{filteredTemplates.map(tpl => (
<button key={tpl.id} onClick={() => handleApplyTemplate(tpl)}
className="dg-card text-left p-2.5 hover:bg-[var(--dg-bg-hover)] group"
title={tpl.description}>
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400 font-medium">{tpl.category}</span>
<span className="text-sm font-medium group-hover:text-blue-400 transition-colors truncate">{tpl.name}</span>
</div>
<p className="text-xs dg-text-muted line-clamp-1">{tpl.description}</p>
</button>
))}
</div>
)}
</div>
</div>
)} )}
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */} {/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
@@ -622,28 +552,14 @@ export default function QueriesPage() {
<div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto"> <div ref={resultContainerRef} onScroll={handleResultScroll} className="overflow-auto">
{/* ARDM-style: Hash field/value compact list for Redis HGETALL */} {/* ARDM-style: Hash field/value compact list for Redis HGETALL */}
{dbType === 'redis' && result.columns.length === 2 && result.columns[0] === 'field' && result.columns[1] === 'value' ? ( {dbType === 'redis' && result.columns.length === 2 && result.columns[0] === 'field' && result.columns[1] === 'value' ? (
<div className="divide-y divide-[var(--dg-border)]"> <RedisHashQueryResult
{(sortedResult?.rows || []).map((row, i) => { rows={sortedResult?.rows || []}
const field = String(row.field ?? ''); rawKey={result.raw_key}
const value = String(row.value ?? ''); rawType={result.raw_type}
const isJson = (() => { try { const p = JSON.parse(value); return typeof p === 'object'; } catch { return false; } })(); rawTtl={result.raw_ttl}
const formatted = isJson ? JSON.stringify(JSON.parse(value), null, 2) : value; command={sql}
return ( onView={setRedisLineViewer}
<div key={i} className="flex hover:bg-[var(--dg-bg-hover)] transition-colors"> />
<div className="w-[200px] shrink-0 px-4 py-2.5 text-xs font-mono text-blue-400 border-r border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
{field}
</div>
<div className="flex-1 px-4 py-2.5 text-xs font-mono whitespace-pre-wrap break-all min-w-0">
{isJson ? (
<pre className="text-blue-300 text-xs whitespace-pre-wrap">{formatted}</pre>
) : (
<span>{value}</span>
)}
</div>
</div>
);
})}
</div>
) : ( ) : (
/* Standard table for other results */ /* Standard table for other results */
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -747,80 +663,27 @@ export default function QueriesPage() {
</div>{/* end 主体区 (sidebar + 编辑器+结果) */} </div>{/* end 主体区 (sidebar + 编辑器+结果) */}
</div>{/* end 主区域 */} </div>{/* end 主区域 */}
{/* ====== 右侧历史抽屉 ====== */}
{showHistory && ( {showHistory && (
<div className="w-[300px] shrink-0 border-l border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] flex flex-col"> <QueryHistoryDrawer
<div className="flex items-center justify-between px-4 py-3"> history={history}
<span className="text-sm font-semibold flex items-center gap-2"> filteredHistory={filteredHistory}
<History className="w-4 h-4 text-blue-400" /> favFilter={favFilter}
查询历史 searchHistory={searchHistory}
{history.length > 0 && <span className="text-xs dg-text-muted font-normal">({history.length})</span>} clearConfirm={clearConfirm}
</span> dbTypeIcon={dbTypeIcon}
<button onClick={() => setShowHistory(false)} className="dg-btn dg-btn-ghost dg-btn-sm p-1"> onClose={() => setShowHistory(false)}
<PanelRightClose className="w-4 h-4" /> onSearchChange={setSearchHistory}
</button> onToggleFavoriteFilter={() => setFavFilter(!favFilter)}
</div> onClearHistory={handleClearHistory}
onToggleFavorite={toggleFavorite}
{/* 搜索+筛选 */} onUseSql={setSql}
{history.length > 0 && ( />
<div className="px-4 pb-2 space-y-2">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 dg-text-muted" />
<input className="w-full pl-8 pr-3 py-1.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-xs placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none"
placeholder="搜索历史..." value={searchHistory} onChange={e => setSearchHistory(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button onClick={() => setFavFilter(!favFilter)}
className={`dg-btn dg-btn-ghost dg-btn-sm ${favFilter ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30' : ''}`}>
<Star className="w-3 h-3" />仅收藏
</button>
<div className="flex-1" />
<button onClick={handleClearHistory}
className={`dg-btn dg-btn-ghost dg-btn-sm ${clearConfirm ? 'text-red-400 bg-red-500/10' : ''}`}>
{clearConfirm ? <CheckCircle className="w-3 h-3" /> : <X className="w-3 h-3" />}
{clearConfirm ? '确认清除' : '清除'}
</button>
</div>
</div>
)}
{/* 历史列表 */}
<div className="flex-1 overflow-y-auto">
{history.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">暂无查询历史</div>
) : filteredHistory.length === 0 ? (
<div className="p-6 text-center text-xs dg-text-muted">没有匹配的记录</div>
) : (
<div className="space-y-1.5 px-3 py-2">
{filteredHistory.map(h => (
<div key={h.id} className={`dg-card px-3 py-2.5 group ${h.favorite ? 'bg-yellow-500/5 border-yellow-500/20' : ''}`}>
<div className="flex items-center gap-2 mb-1">
{h.db_type && dbTypeIcon[h.db_type] && <img src={dbTypeIcon[h.db_type]} alt="" className="w-3.5 h-3.5 object-contain" />}
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${h.status === 'success' ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
{h.execution_time_ms}ms
</span>
<span className="text-[10px] dg-text-muted">{new Date(h.created_at).toLocaleTimeString()}</span>
<div className="flex-1" />
<button onClick={() => toggleFavorite(h.id)}
className={`p-0.5 rounded transition-colors ${h.favorite ? 'text-yellow-400' : 'dg-text-muted opacity-0 group-hover:opacity-100 hover:text-yellow-400'}`}>
{h.favorite ? <Star className="w-3 h-3 fill-current" /> : <StarOff className="w-3 h-3" />}
</button>
</div>
<div className="flex items-center gap-1">
<code className="flex-1 text-[11px] font-mono text-blue-400 truncate">{h.sql.substring(0, 80)}</code>
<button onClick={() => setSql(h.sql)}
className="dg-btn dg-btn-ghost dg-btn-sm p-1 opacity-0 group-hover:opacity-100"
title="粘贴到编辑器">
<ClipboardPaste className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)} )}
<RedisValueViewerDialog
state={redisLineViewer}
onChange={setRedisLineViewer}
onClose={() => setRedisLineViewer({ open: false, field: '', value: '', format: 'Json' })}
/>
</div> </div>
); );
} }
+230 -177
View File
@@ -5,6 +5,18 @@ import { redisApi } from '@/api/redis';
import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ConfirmDialog } from '@/components/ConfirmDialog';
import { FormDialog } from '@/components/FormDialog'; import { FormDialog } from '@/components/FormDialog';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { RedisLineEditorDialog } from '@/features/redis/components/RedisLineEditorDialog';
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
import { STRING_FORMAT_OPTIONS, type RedisLineEditorState, type StringFormat } from '@/features/redis/types';
import {
formatStringByMode,
formatTTL,
normalizeValueForEdit,
parseEditorValue,
stringifyValue,
ttlMsToSeconds,
tryFormatJsonText,
} from '@/features/redis/utils';
type TabKey = 'keys' | 'monitor' | 'cli' | 'perf'; type TabKey = 'keys' | 'monitor' | 'cli' | 'perf';
type ValueMode = 'table' | 'json' | 'raw'; type ValueMode = 'table' | 'json' | 'raw';
@@ -17,59 +29,14 @@ interface RedisKeyDetail {
value: any; value: any;
} }
function ttlMsToSeconds(ttl: number | undefined): number {
if (ttl == null || ttl < 0) return -1;
return Math.max(1, Math.ceil(ttl / 1000));
}
function formatTTL(ttl: number | undefined): string {
if (ttl == null || ttl < 0) return '永不过期';
if (ttl === 0) return '即将过期';
var seconds = Math.ceil(ttl / 1000);
if (seconds < 60) return seconds + 's';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's';
return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm';
}
function normalizeZSetRows(value: any): { member: string; score: number }[] {
if (!Array.isArray(value)) return [];
return value.map(function (row: any) {
return {
member: String(row?.member ?? row?.Member ?? ''),
score: Number(row?.score ?? row?.Score ?? 0),
};
});
}
function normalizeValueForEdit(type: string, value: any): any {
if (type === 'zset') return normalizeZSetRows(value);
return value;
}
function stringifyValue(type: string, value: any): string {
if (type === 'string') return typeof value === 'string' ? value : String(value ?? '');
return JSON.stringify(normalizeValueForEdit(type, value), null, 2);
}
function parseEditorValue(type: string, text: string): any {
if (type === 'string') return text;
return JSON.parse(text || (type === 'hash' ? '{}' : '[]'));
}
function tryFormatJsonText(text: string): string | null {
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return null;
}
}
export default function RedisDetailPage() { export default function RedisDetailPage() {
var params = useParams<{ id: string }>(); var params = useParams<{ id: string }>();
var navigate = useNavigate(); var navigate = useNavigate();
var instanceId = Number(params.id); var instanceId = Number(params.id);
var [info, setInfo] = useState<Record<string, any>>({}); var [info, setInfo] = useState<Record<string, any>>({});
var [infoSectionsMap, setInfoSectionsMap] = useState<Record<string, Record<string, any>>>({});
var [infoSearch, setInfoSearch] = useState('');
var [activeTab, setActiveTab] = useState<TabKey>('keys'); var [activeTab, setActiveTab] = useState<TabKey>('keys');
var [selectedDb, setSelectedDb] = useState(0); var [selectedDb, setSelectedDb] = useState(0);
var [keyPattern, setKeyPattern] = useState('*'); var [keyPattern, setKeyPattern] = useState('*');
@@ -78,6 +45,9 @@ export default function RedisDetailPage() {
var [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null); var [selectedKey, setSelectedKey] = useState<RedisKeyDetail | null>(null);
var [detailLoading, setDetailLoading] = useState(false); var [detailLoading, setDetailLoading] = useState(false);
var [valueMode, setValueMode] = useState<ValueMode>('table'); var [valueMode, setValueMode] = useState<ValueMode>('table');
var [stringFormat, setStringFormat] = useState<StringFormat>('Json');
var [inspectValue, setInspectValue] = useState<{ title: string; value: any } | null>(null);
var [lineEditor, setLineEditor] = useState<RedisLineEditorState>({ open: false, originalField: '', field: '', value: '', format: 'Json' });
var [valueSearch, setValueSearch] = useState(''); var [valueSearch, setValueSearch] = useState('');
var [editValue, setEditValue] = useState(''); var [editValue, setEditValue] = useState('');
var [editTtl, setEditTtl] = useState(-1); var [editTtl, setEditTtl] = useState(-1);
@@ -100,8 +70,10 @@ export default function RedisDetailPage() {
useEffect(function () { useEffect(function () {
redisApi.getInfo(instanceId).then(function (res: any) { redisApi.getInfo(instanceId).then(function (res: any) {
setInfo(res.info || res || {}); var flatInfo = res.info || res || {};
}).catch(function () { setInfo({}); }); setInfo(flatInfo);
setInfoSectionsMap(res.sections || {});
}).catch(function () { setInfo({}); setInfoSectionsMap({}); });
}, [instanceId]); }, [instanceId]);
var fetchKeys = function () { var fetchKeys = function () {
@@ -119,6 +91,8 @@ export default function RedisDetailPage() {
setEditValue(stringifyValue(detail.type, detail.value)); setEditValue(stringifyValue(detail.type, detail.value));
setEditTtl(ttlMsToSeconds(detail.ttl)); setEditTtl(ttlMsToSeconds(detail.ttl));
setValueMode(detail.type === 'string' ? 'json' : 'table'); setValueMode(detail.type === 'string' ? 'json' : 'table');
setStringFormat(detail.type === 'string' ? 'Json' : 'String');
setInspectValue(null);
setValueSearch(''); setValueSearch('');
}).catch(function () { }).catch(function () {
toast('error', '加载 Redis Key 详情失败'); toast('error', '加载 Redis Key 详情失败');
@@ -156,13 +130,69 @@ export default function RedisDetailPage() {
var handleCopyValue = function () { var handleCopyValue = function () {
if (!selectedKey) return; if (!selectedKey) return;
navigator.clipboard?.writeText(editValue).then(function () { copyText(editValue, '已复制当前值');
toast('success', '已复制当前值'); };
var copyText = function (text: string, message: string) {
navigator.clipboard?.writeText(text).then(function () {
toast('success', message);
}).catch(function () { }).catch(function () {
toast('warning', '复制失败,请手动复制'); toast('warning', '复制失败,请手动复制');
}); });
}; };
var inspectCellValue = function (title: string, value: any) {
setInspectValue({ title: title, value: value });
setValueMode('json');
};
var openHashLineEditor = function (field: string, value: any) {
setLineEditor({ open: true, originalField: field, field: field, value: String(value ?? ''), format: 'Json' });
};
var updateHashValue = function (nextValue: Record<string, any>, successMessage: string) {
if (!selectedKey) return;
var keyName = selectedKey.key || selectedKey.name || '';
setSavingKey(true);
redisApi.updateKey(instanceId, keyName, {
db: selectedDb,
type: 'hash',
value: nextValue,
ttl: editTtl > 0 ? editTtl : undefined,
}).then(function () {
toast('success', successMessage);
setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' });
loadKeyDetail(keyName);
fetchKeys();
}).catch(function () {}).finally(function () {
setSavingKey(false);
});
};
var handleSaveHashLine = function () {
if (!selectedKey || selectedKey.type !== 'hash') return;
var field = lineEditor.field.trim();
if (!field) {
toast('error', 'Field 不能为空');
return;
}
var nextValue = Object.assign({}, selectedKey.value || {});
if (lineEditor.originalField && lineEditor.originalField !== field) {
delete nextValue[lineEditor.originalField];
}
nextValue[field] = lineEditor.value;
updateHashValue(nextValue, 'Hash 行已保存');
};
var handleDeleteHashLine = function (field: string) {
if (!selectedKey || selectedKey.type !== 'hash') return;
setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => {
var nextValue = Object.assign({}, selectedKey.value || {});
delete nextValue[field];
updateHashValue(nextValue, 'Hash 字段已删除');
}});
};
var handleSaveKey = function () { var handleSaveKey = function () {
if (!selectedKey) return; if (!selectedKey) return;
var keyName = selectedKey.key || selectedKey.name || ''; var keyName = selectedKey.key || selectedKey.name || '';
@@ -207,108 +237,47 @@ export default function RedisDetailPage() {
} }
}, [activeTab, instanceId]); }, [activeTab, instanceId]);
var renderStructuredValue = function () { var renderFormattedValue = function () {
if (!selectedKey) return null; if (!selectedKey) return '';
var type = selectedKey.type; if (inspectValue) {
var value = normalizeValueForEdit(type, selectedKey.value); var inspectedText = typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value, null, 2);
var search = valueSearch.trim().toLowerCase(); return tryFormatJsonText(inspectedText) || inspectedText;
if (type === 'hash') {
var entries = Object.entries(value || {}).filter(function (entry) {
return !search || entry[0].toLowerCase().includes(search) || String(entry[1]).toLowerCase().includes(search);
});
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-16 px-3 py-2 font-medium">#</th>
<th className="w-[32%] px-3 py-2 font-medium">Field</th>
<th className="px-3 py-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
{entries.map(function (entry, index) {
var pretty = typeof entry[1] === 'string' ? (tryFormatJsonText(entry[1]) || entry[1]) : JSON.stringify(entry[1], null, 2);
return (
<tr key={entry[0]} 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)]">{entry[0]}</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 (selectedKey.type === 'string') return formatStringByMode(String(selectedKey.value ?? ''), stringFormat);
if (type === 'list' || type === 'set') { return JSON.stringify(normalizeValueForEdit(selectedKey.type, selectedKey.value), null, 2);
var list = Array.isArray(value) ? value.filter(function (item) {
return !search || String(item).toLowerCase().includes(search);
}) : [];
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(value).filter(function (row) {
return !search || row.member.toLowerCase().includes(search) || String(row.score).includes(search);
});
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">#</th>
<th className="px-3 py-2 font-medium">Member</th>
<th className="w-32 px-3 py-2 font-medium">Score</th>
</tr>
</thead>
<tbody>
{zRows.map(function (row, index) {
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">{row.member}</td>
<td className="px-3 py-2 font-mono text-xs text-[var(--dg-text-secondary)]">{row.score}</td>
</tr>
);
})}
</tbody>
</table>
);
}
var formatted = tryFormatJsonText(String(value ?? ''));
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(value ?? '')}
</pre>
);
}; };
var infoSections = Object.entries(info).filter(function (e) { return typeof e[1] === 'object' && e[1] !== null; }); var getInfoValue = function (key: string, fallback: string = '-') {
var infoScalars = Object.entries(info).filter(function (e) { return typeof e[1] !== 'object'; }); var value = info[key];
return value == null || value === '' ? fallback : String(value);
};
var usedMemory = Number(info.used_memory || 0);
var maxMemory = Number(info.maxmemory || 0);
var memoryPercent = maxMemory > 0 ? Math.min(100, Math.round((usedMemory / maxMemory) * 100)) : 0;
var keyMetricCards = [
{ group: 'Server', label: 'Redis Version', value: getInfoValue('redis_version'), hint: getInfoValue('redis_mode') },
{ group: 'Memory', label: 'Used Memory', value: getInfoValue('used_memory_human', getInfoValue('used_memory')), hint: maxMemory > 0 ? memoryPercent + '% of ' + getInfoValue('maxmemory_human', getInfoValue('maxmemory')) : 'maxmemory 未设置' },
{ group: 'Memory', label: 'Used Memory Peak', value: getInfoValue('used_memory_peak_human', getInfoValue('used_memory_peak')), hint: getInfoValue('used_memory_peak_perc') },
{ group: 'Memory', label: 'Used Memory Lua', value: getInfoValue('used_memory_lua_human', getInfoValue('used_memory_lua')), hint: 'scripts ' + getInfoValue('used_memory_scripts_human', getInfoValue('used_memory_scripts')) },
{ group: 'Stats', label: 'Connected Clients', value: getInfoValue('connected_clients'), hint: 'max ' + getInfoValue('maxclients') },
{ group: 'Stats', label: 'Total Connections', value: Number(info.total_connections_received || 0).toLocaleString(), hint: 'rejected ' + getInfoValue('rejected_connections') },
{ group: 'Stats', label: 'Total Commands', value: Number(info.total_commands_processed || 0).toLocaleString(), hint: getInfoValue('instantaneous_ops_per_sec') + ' ops/s' },
{ group: 'Keyspace', label: 'DB Size', value: Number(info.db_size || 0).toLocaleString(), hint: getInfoValue('db0') },
];
var fallbackSections = Object.keys(infoSectionsMap).length > 0 ? infoSectionsMap : { 'All Redis Info': info };
var normalizedInfoSearch = infoSearch.trim().toLowerCase();
var searchableInfoSections = Object.entries(fallbackSections).map(function (section) {
var entries = Object.entries(section[1] || {}).filter(function (entry) {
return !normalizedInfoSearch
|| section[0].toLowerCase().includes(normalizedInfoSearch)
|| entry[0].toLowerCase().includes(normalizedInfoSearch)
|| String(entry[1]).toLowerCase().includes(normalizedInfoSearch);
});
return { name: section[0], entries: entries };
}).filter(function (section) { return section.entries.length > 0; });
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -325,11 +294,24 @@ export default function RedisDetailPage() {
</div> </div>
</div> </div>
{/* 信息卡片 */} {/* 核心指标 */}
{infoScalars.length > 0 && ( {Object.keys(info).length > 0 && (
<div className="grid grid-cols-4 gap-3"> <div className="grid grid-cols-4 gap-3">
{infoScalars.slice(0, 4).map(function (e) { {keyMetricCards.map(function (card) {
return <div key={e[0]} className="dg-card p-3"><div className="text-lg font-bold">{String(e[1])}</div><div className="text-xs text-[var(--dg-text-muted)]">{e[0]}</div></div>; return (
<div key={card.group + card.label} className="dg-card p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-wide text-[var(--dg-text-muted)]">{card.group}</span>
</div>
<div className="truncate text-lg font-bold text-[var(--dg-text-primary)]">{card.value}</div>
<div className="mt-1 truncate text-xs text-[var(--dg-text-muted)]">{card.label} · {card.hint}</div>
{card.label === 'Used Memory' && maxMemory > 0 && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--dg-bg-tertiary)]">
<div className="h-full rounded-full bg-[var(--dg-blue)]" style={{ width: memoryPercent + '%' }} />
</div>
)}
</div>
);
})} })}
</div> </div>
)} )}
@@ -425,18 +407,33 @@ export default function RedisDetailPage() {
</div> </div>
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-4 py-3"> <div className="flex items-center justify-between border-b border-[var(--dg-border)] px-4 py-3">
<div className="flex rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-0.5"> <div className="flex items-center gap-2">
{[ <div className="flex rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-0.5">
{ key: 'table' as ValueMode, label: '结构', icon: Table2 }, {[
{ key: 'json' as ValueMode, label: '格式化', icon: Code2 }, { key: 'table' as ValueMode, label: '结构', icon: Table2 },
{ key: 'raw' as ValueMode, label: '编辑', icon: FileText }, { key: 'json' as ValueMode, label: '格式化', icon: Code2 },
].map(function (mode) { { key: 'raw' as ValueMode, label: '编辑', icon: FileText },
var Icon = mode.icon; ].map(function (mode) {
return <button key={mode.key} onClick={function () { setValueMode(mode.key); }} var Icon = mode.icon;
className={'inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs transition-colors ' + (valueMode === mode.key ? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}> return <button key={mode.key} onClick={function () { setValueMode(mode.key); if (mode.key !== 'json') setInspectValue(null); }}
<Icon className="h-3.5 w-3.5" />{mode.label} className={'inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-xs transition-colors ' + (valueMode === mode.key ? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]')}>
</button>; <Icon className="h-3.5 w-3.5" />{mode.label}
})} </button>;
})}
</div>
{selectedKey.type === 'string' && valueMode === 'json' && (
<select value={stringFormat} onChange={function (e) { setStringFormat(e.target.value as StringFormat); }}
className="h-8 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>
)}
{inspectValue && valueMode === 'json' && (
<button onClick={function () { setInspectValue(null); }} className="h-8 rounded-md border border-[var(--dg-border)] px-2 text-xs text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]">
返回 Key 格式化
</button>
)}
</div> </div>
<div className="relative w-64"> <div className="relative w-64">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" /> <Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
@@ -451,12 +448,31 @@ export default function RedisDetailPage() {
<textarea value={editValue} onChange={function (e) { setEditValue(e.target.value); }} spellCheck={false} <textarea value={editValue} onChange={function (e) { setEditValue(e.target.value); }} spellCheck={false}
className="min-h-[480px] w-full resize-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]" /> className="min-h-[480px] w-full resize-none rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed outline-none focus:border-[var(--dg-blue)]" />
) : valueMode === 'json' ? ( ) : valueMode === 'json' ? (
<pre className="min-h-[480px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]"> <div>
{selectedKey.type === 'string' ? (tryFormatJsonText(String(selectedKey.value ?? '')) || String(selectedKey.value ?? '')) : JSON.stringify(normalizeValueForEdit(selectedKey.type, selectedKey.value), null, 2)} {inspectValue && (
</pre> <div className="mb-2 flex items-center justify-between rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] px-3 py-2 text-xs">
<span className="truncate font-mono text-[var(--dg-text-secondary)]">正在查看字段:{inspectValue.title}</span>
<button onClick={function () { copyText(typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value), '已复制字段值'); }} className="inline-flex items-center gap-1.5 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]">
<Copy className="h-3.5 w-3.5" />复制
</button>
</div>
)}
<pre className="min-h-[480px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-4 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{renderFormattedValue()}
</pre>
</div>
) : ( ) : (
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]"> <div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
{renderStructuredValue()} <RedisStructuredValue
type={selectedKey.type}
value={selectedKey.value}
redisKey={selectedKey.key || selectedKey.name || ''}
search={valueSearch}
onCopy={copyText}
onInspect={inspectCellValue}
onEditHashLine={openHashLineEditor}
onDeleteHashLine={handleDeleteHashLine}
/>
</div> </div>
)} )}
</div> </div>
@@ -469,19 +485,48 @@ export default function RedisDetailPage() {
{/* 监控信息 */} {/* 监控信息 */}
{activeTab === 'monitor' && ( {activeTab === 'monitor' && (
<div className="space-y-4"> <div className="space-y-4">
{infoSections.map(function (section) { <div className="dg-card p-4">
var entries = Object.entries(section[1] as Record<string, any>); <div className="flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-semibold">All Redis INFO</h3>
<p className="mt-1 text-xs text-[var(--dg-text-muted)]">完整 INFO 输出,按 Server / Clients / Memory / Stats / Replication / Persistence / CPU / Cluster / Keyspace 分组</p>
</div>
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input
value={infoSearch}
onChange={function (e) { setInfoSearch(e.target.value); }}
className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 text-sm"
placeholder="搜索 redis_version / used_memory / cluster..."
/>
</div>
</div>
</div>
{searchableInfoSections.map(function (section) {
return ( return (
<div key={section[0]} className="dg-card p-4"> <div key={section.name} className="dg-card overflow-hidden">
<h3 className="text-sm font-semibold mb-3">{section[0]}</h3> <div className="flex items-center justify-between border-b border-[var(--dg-border)] px-4 py-3">
<div className="grid grid-cols-3 gap-2"> <h3 className="text-sm font-semibold">{section.name}</h3>
{entries.map(function (e) { <span className="text-xs text-[var(--dg-text-muted)]">{section.entries.length} 项</span>
return <div key={e[0]} className="flex justify-between px-3 py-1.5 bg-[var(--dg-bg-tertiary)] rounded text-xs"><span className="text-[var(--dg-text-muted)]">{e[0]}</span><span className="font-mono">{String(e[1])}</span></div>; </div>
<div className="grid grid-cols-2 gap-px bg-[var(--dg-border-light)]">
{section.entries.map(function (entry) {
return (
<div key={section.name + entry[0]} className="grid grid-cols-[220px_minmax(0,1fr)] bg-[var(--dg-bg-secondary)] px-4 py-2 text-xs">
<span className="truncate font-mono text-[var(--dg-text-muted)]" title={entry[0]}>{entry[0]}</span>
<span className="break-all font-mono text-[var(--dg-text-primary)]">{String(entry[1])}</span>
</div>
);
})} })}
</div> </div>
</div> </div>
); );
})} })}
{searchableInfoSections.length === 0 && (
<div className="dg-card py-12 text-center text-sm text-[var(--dg-text-muted)]">没有匹配的 INFO 字段</div>
)}
</div> </div>
)} )}
@@ -563,6 +608,14 @@ export default function RedisDetailPage() {
<div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div> <div><label className="text-sm text-[var(--dg-text-muted)]">TTL (秒, -1 永不过期)</label><input type="number" value={newKey.ttl} onChange={function (e) { setNewKey(Object.assign({}, newKey, { ttl: Number(e.target.value) })); }} className="w-full px-3 py-2 mt-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm" /></div>
</div> </div>
</FormDialog> </FormDialog>
<RedisLineEditorDialog
state={lineEditor}
saving={savingKey}
onChange={setLineEditor}
onClose={() => setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' })}
onConfirm={handleSaveHashLine}
onCopy={value => copyText(value, '已复制字段值')}
/>
<ConfirmDialog <ConfirmDialog
open={!!deleteConfirm} open={!!deleteConfirm}
title={deleteConfirm?.msg || '确认操作'} title={deleteConfirm?.msg || '确认操作'}
+18
View File
@@ -0,0 +1,18 @@
# Shared Layer
`shared/*` is for cross-domain primitives only.
Use it for:
- framework-neutral utilities
- generic hooks
- generic UI primitives not tied to OpsxSQL business concepts
- constants used by multiple domains
Do not put Redis, SQL ticket, instance, workflow, RBAC, or audit-specific concepts here. Those belong in `features/<domain>`.
Suggested structure:
- `shared/lib`: generic functions
- `shared/hooks`: reusable hooks
- `shared/ui`: small generic UI wrappers