From 617c15dbfe16e3e0f69cb1bd8b918d87d6785831 Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Tue, 7 Jul 2026 17:20:44 +0800 Subject: [PATCH] feat: harden query read-only workflow --- backend/internal/handler/instance_detail.go | 52 +-- backend/internal/handler/redis.go | 35 +- docs/frontend-architecture.md | 46 ++ docs/frontend-modernization-roadmap.md | 94 ++++ frontend/package.json | 1 + frontend/scripts/audit-page-size.mjs | 28 ++ frontend/src/App.tsx | 3 +- frontend/src/features/README.md | 27 ++ frontend/src/features/datasource/README.md | 14 + .../datasource/engines/mysql/README.md | 9 + .../datasource/engines/postgresql/README.md | 8 + frontend/src/features/governance/README.md | 13 + frontend/src/features/mongo/README.md | 13 + .../query/components/QueryHistoryDrawer.tsx | 119 +++++ .../query/components/QueryTemplatePanel.tsx | 70 +++ frontend/src/features/query/types.ts | 12 + frontend/src/features/query/utils.ts | 22 + frontend/src/features/rbac/README.md | 14 + .../redis/components/RedisHashQueryResult.tsx | 98 +++++ .../components/RedisLineEditorDialog.tsx | 75 ++++ .../redis/components/RedisStructuredValue.tsx | 160 +++++++ .../components/RedisValueViewerDialog.tsx | 65 +++ frontend/src/features/redis/types.ts | 40 ++ frontend/src/features/redis/utils.ts | 105 +++++ frontend/src/features/system/README.md | 15 + frontend/src/features/ticket/README.md | 14 + frontend/src/pages/QueriesPage.tsx | 235 +++------- frontend/src/pages/RedisDetailPage.tsx | 407 ++++++++++-------- frontend/src/shared/README.md | 18 + 29 files changed, 1417 insertions(+), 395 deletions(-) create mode 100644 docs/frontend-architecture.md create mode 100644 docs/frontend-modernization-roadmap.md create mode 100644 frontend/scripts/audit-page-size.mjs create mode 100644 frontend/src/features/README.md create mode 100644 frontend/src/features/datasource/README.md create mode 100644 frontend/src/features/datasource/engines/mysql/README.md create mode 100644 frontend/src/features/datasource/engines/postgresql/README.md create mode 100644 frontend/src/features/governance/README.md create mode 100644 frontend/src/features/mongo/README.md create mode 100644 frontend/src/features/query/components/QueryHistoryDrawer.tsx create mode 100644 frontend/src/features/query/components/QueryTemplatePanel.tsx create mode 100644 frontend/src/features/query/types.ts create mode 100644 frontend/src/features/query/utils.ts create mode 100644 frontend/src/features/rbac/README.md create mode 100644 frontend/src/features/redis/components/RedisHashQueryResult.tsx create mode 100644 frontend/src/features/redis/components/RedisLineEditorDialog.tsx create mode 100644 frontend/src/features/redis/components/RedisStructuredValue.tsx create mode 100644 frontend/src/features/redis/components/RedisValueViewerDialog.tsx create mode 100644 frontend/src/features/redis/types.ts create mode 100644 frontend/src/features/redis/utils.ts create mode 100644 frontend/src/features/system/README.md create mode 100644 frontend/src/features/ticket/README.md create mode 100644 frontend/src/shared/README.md diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 06a38ec..ea91eac 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -840,6 +840,10 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string } cmd := strings.ToUpper(parts[0]) + keyName := "" + if len(parts) > 1 { + keyName = parts[1] + } readOnly := map[string]bool{ "GET": true, "MGET": true, "HGET": true, "HGETALL": 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) } + 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 columns := []string{"key", "value"} var rows []interface{} @@ -1128,10 +1147,6 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string columns = []string{"field", "value"} } else { // Single value result (GET, HGET, TYPE, TTL, etc.) - keyName := "" - if len(parts) > 1 { - keyName = parts[1] - } rawValue := fmt.Sprintf("%v", v) // Try to pretty-format JSON with Chinese characters preserved var prettyJSON string @@ -1147,20 +1162,6 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string 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{ "columns": []string{}, "rows": []interface{}{}, @@ -1169,22 +1170,23 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string "raw_key": keyName, "raw_text": rawValue, } - if keyType != "" { - result["raw_type"] = keyType - } - if keyTTL != "" { - result["raw_ttl"] = keyTTL + for k, v := range keyMeta { + result[k] = v } return result, nil } } - return gin.H{ + response := gin.H{ "columns": columns, "rows": rowsOrDefault(rows), "total": len(rows), "count": len(rows), - }, nil + } + for k, v := range keyMeta { + response[k] = v + } + return response, nil } // executeMongoQuery — MongoDB query with pagination diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index d0042c7..bbbd007 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -396,25 +396,41 @@ func RedisInfo(c *gin.Context) { defer client.Close() ctx := context.Background() - info, err := client.Info(ctx, "server", "memory", "clients", "stats", "keyspace").Result() + info, err := client.Info(ctx).Result() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } - // Parse info into structured map + // Parse Redis INFO into both a flat map and section groups. result := make(map[string]interface{}) + grouped := make(map[string]map[string]interface{}) + currentSection := "General" sections := strings.Split(info, "\n# ") for _, section := range sections { lines := strings.Split(section, "\n") - for _, line := range lines { + for lineIndex, line := range lines { 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 } parts := strings.SplitN(line, ":", 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 dbSize, _ := client.DBSize(ctx).Result() 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 diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md new file mode 100644 index 0000000..b0f59f5 --- /dev/null +++ b/docs/frontend-architecture.md @@ -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//components`. +- Move domain state/data orchestration into `features//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/` 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. diff --git a/docs/frontend-modernization-roadmap.md b/docs/frontend-modernization-roadmap.md new file mode 100644 index 0000000..d509ee7 --- /dev/null +++ b/docs/frontend-modernization-roadmap.md @@ -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/` 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. diff --git a/frontend/package.json b/frontend/package.json index 97c35c9..bc7e64d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "audit:pages": "node scripts/audit-page-size.mjs 700", "preview": "vite preview" }, "dependencies": { diff --git a/frontend/scripts/audit-page-size.mjs b/frontend/scripts/audit-page-size.mjs new file mode 100644 index 0000000..bb57f73 --- /dev/null +++ b/frontend/scripts/audit-page-size.mjs @@ -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; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c5deb9..36d0ec8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,13 +38,14 @@ function RouteGuard() { const { isLoggedIn, hasPermission } = useAuth(); const location = useLocation(); const path = location.pathname.split('/')[1] || 'dashboard'; + const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record)[path] || path; if (!isLoggedIn) { return ; } // 检查当前路径是否有权限访问 - if (path !== 'dashboard' && !hasPermission(path)) { + if (permissionPath !== 'dashboard' && !hasPermission(permissionPath)) { return ; } diff --git a/frontend/src/features/README.md b/frontend/src/features/README.md new file mode 100644 index 0000000..ce9013f --- /dev/null +++ b/frontend/src/features/README.md @@ -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//components`: domain UI components +- `features//hooks`: domain data/state hooks +- `features//types.ts`: domain types +- `features//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/`; features consume APIs but do not define transport details. diff --git a/frontend/src/features/datasource/README.md b/frontend/src/features/datasource/README.md new file mode 100644 index 0000000..818e6b6 --- /dev/null +++ b/frontend/src/features/datasource/README.md @@ -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/` when behavior diverges. +- Keep transport calls in `api/datasource`. diff --git a/frontend/src/features/datasource/engines/mysql/README.md b/frontend/src/features/datasource/engines/mysql/README.md new file mode 100644 index 0000000..152fc3b --- /dev/null +++ b/frontend/src/features/datasource/engines/mysql/README.md @@ -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 diff --git a/frontend/src/features/datasource/engines/postgresql/README.md b/frontend/src/features/datasource/engines/postgresql/README.md new file mode 100644 index 0000000..e0ea488 --- /dev/null +++ b/frontend/src/features/datasource/engines/postgresql/README.md @@ -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 diff --git a/frontend/src/features/governance/README.md b/frontend/src/features/governance/README.md new file mode 100644 index 0000000..f639c66 --- /dev/null +++ b/frontend/src/features/governance/README.md @@ -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. diff --git a/frontend/src/features/mongo/README.md b/frontend/src/features/mongo/README.md new file mode 100644 index 0000000..e2b5896 --- /dev/null +++ b/frontend/src/features/mongo/README.md @@ -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. diff --git a/frontend/src/features/query/components/QueryHistoryDrawer.tsx b/frontend/src/features/query/components/QueryHistoryDrawer.tsx new file mode 100644 index 0000000..1917185 --- /dev/null +++ b/frontend/src/features/query/components/QueryHistoryDrawer.tsx @@ -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; + 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 ( +
+
+ + + 查询历史 + {history.length > 0 && ({history.length})} + + +
+ + {history.length > 0 && ( +
+
+ + +
+
+ +
+ +
+
+ )} + +
+ {history.length === 0 ? ( +
暂无查询历史
+ ) : filteredHistory.length === 0 ? ( +
没有匹配的记录
+ ) : ( +
+ {filteredHistory.map(function (h) { + return ( +
+
+ {h.db_type && dbTypeIcon[h.db_type] && } + + {h.execution_time_ms}ms + + {new Date(h.created_at).toLocaleTimeString()} +
+ +
+
+ {h.sql.substring(0, 80)} + +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/query/components/QueryTemplatePanel.tsx b/frontend/src/features/query/components/QueryTemplatePanel.tsx new file mode 100644 index 0000000..0b325ca --- /dev/null +++ b/frontend/src/features/query/components/QueryTemplatePanel.tsx @@ -0,0 +1,70 @@ +import { Search } from 'lucide-react'; +import type { QueryTemplate } from '@/lib/queryTemplates'; + +interface QueryTemplatePanelProps { + categories: string[]; + activeCategory: string; + categoryLabels: Record; + 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 ( +
+
+
+ {categories.map(function (cat) { + return ( + + ); + })} +
+
+ + +
+
+ {templates.length === 0 ? ( +
无匹配模板
+ ) : ( +
+ {templates.map(function (tpl) { + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/features/query/types.ts b/frontend/src/features/query/types.ts new file mode 100644 index 0000000..ecf9c91 --- /dev/null +++ b/frontend/src/features/query/types.ts @@ -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; +} diff --git a/frontend/src/features/query/utils.ts b/frontend/src/features/query/utils.ts new file mode 100644 index 0000000..edceebd --- /dev/null +++ b/frontend/src/features/query/utils.ts @@ -0,0 +1,22 @@ +export function exportToCSV(columns: string[], rows: Record[]): 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); +} diff --git a/frontend/src/features/rbac/README.md b/frontend/src/features/rbac/README.md new file mode 100644 index 0000000..a48c559 --- /dev/null +++ b/frontend/src/features/rbac/README.md @@ -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. diff --git a/frontend/src/features/redis/components/RedisHashQueryResult.tsx b/frontend/src/features/redis/components/RedisHashQueryResult.tsx new file mode 100644 index 0000000..8664cf6 --- /dev/null +++ b/frontend/src/features/redis/components/RedisHashQueryResult.tsx @@ -0,0 +1,98 @@ +import { Copy, Eye } from 'lucide-react'; +import type { RedisLineEditorState } from '../types'; +import { + middleEllipsis, + parseRedisKeyFromCommand, +} from '../utils'; + +interface RedisHashQueryResultProps { + rows: Record[]; + 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 ( +
+
+ + {String(rawType || 'hash').replace(/^./, function (c) { return c.toUpperCase(); })} + + + {redisKeyLabel} + + + TTL {String(rawTtl ?? '-1')} + +
+ + + + + + + + + + + + + + + + + {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 ( + + + + + + + ); + })} + +
IDKeyValue操作
{i + 1} +
{fieldLabel}
+
+
{previewLabel}
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/features/redis/components/RedisLineEditorDialog.tsx b/frontend/src/features/redis/components/RedisLineEditorDialog.tsx new file mode 100644 index 0000000..562f561 --- /dev/null +++ b/frontend/src/features/redis/components/RedisLineEditorDialog.tsx @@ -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 ( + + + + }> +
+
+ + +
+
+
+ +
+ + Size: {new TextEncoder().encode(state.value).length}B + + + +
+
+