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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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 || '确认操作'}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user