feat: Initial project setup - uzdb database client

Initialize complete Wails (Go + React) database management tool

## Project Structure
- Frontend: React 18 + TypeScript + Vite
- Backend: Go 1.23 + Gin + GORM + SQLite + Zap
- Desktop: Wails v2

## Features Implemented

### UI/UX Design
- Complete design system with colors, typography, spacing
- Wireframes for all major screens
- User flows and interaction specifications
- Layout design with 3-panel architecture

### Frontend Components
- ConnectionPanel: Database connection sidebar with status indicators
- AppLayout: Resizable main layout framework
- MenuBar & ToolBar: Navigation and quick actions
- QueryEditor: SQL editor with syntax highlighting support
- DataGrid: Sortable, filterable, editable data table
- TableStructure: Table metadata viewer
- StatusBar: Connection info and query statistics
- StatusIndicator: Animated connection status component

### Backend Services
- Wails bindings: 15+ methods exposed to frontend
- Connection management: CRUD operations with connection pooling
- Query execution: SQL execution with result handling
- Table metadata: Schema introspection
- Encryption service: AES-256-GCM password encryption
- HTTP API: RESTful endpoints for debugging/integration

### Documentation
- Design system specification
- Feature requirements document
- Wireframes and user flows
- API testing guide
- Project README

## Technical Details
- Password encryption using AES-256-GCM
- Thread-safe connection manager with sync.RWMutex
- Unified error handling and logging
- Clean architecture with dependency injection
- SQLite for storing user data (connections, history)

🤖 Generated with Qoder
This commit is contained in:
loveuer
2026-03-29 06:49:23 -07:00
commit 5a83e86bc9
91 changed files with 16488 additions and 0 deletions

66
.gitignore vendored Normal file
View File

@@ -0,0 +1,66 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
uzdb.exe
build/bin/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# Dependency directories
vendor/
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~
.DS_Store
# Node modules (frontend)
frontend/node_modules/
frontend/dist/
frontend/.vite/
# Wails build artifacts
wails_build/
# Data directory (contains user data including encrypted passwords)
data/
*.db
*.sqlite
*.sqlite3
# Log files
*.log
logs/
# Environment variables
.env
.env.local
.env.*.local
# Temporary files
tmp/
temp/
*.tmp
# Coverage reports
coverage.txt
coverage.html
# Profiling files
cpu.pprof
mem.pprof
block.pprof
mutex.pprof

View File

@@ -0,0 +1,34 @@
# UI/UX Designer Agent
## Role
You are a UI/UX design expert specializing in database management tools and desktop applications. You help design intuitive, efficient, and aesthetically pleasing interfaces for uzdb - a lightweight database client built with Wails (Go + React).
## Responsibilities
1. **Information Architecture**: Organize database browsing, querying, and management features logically
2. **Visual Design**: Create clean, professional designs following modern desktop app conventions
3. **Interaction Design**: Ensure smooth workflows for common database operations
4. **Design System**: Maintain consistent components, colors, typography, and spacing
5. **Accessibility**: Design for diverse users with clear visual hierarchy and keyboard navigation
## Domain Knowledge
- Database clients (DBeaver, Navicat, DataGrip, TablePlus)
- SQL query editors with syntax highlighting
- Data grid/table visualization
- Connection management and authentication flows
- Schema exploration and navigation patterns
## Output Formats
- Wireframes and mockups (ASCII or descriptions)
- Component specifications
- User flow diagrams
- Design tokens (colors, spacing, typography)
- Interaction guidelines
- Figma/Sketch handoff notes when applicable
## Working Directory
All design documentation is stored in `/root/codes/project/self/uzdb/doc/`
## Collaboration
- Work with frontend developers to ensure design feasibility
- Consider technical constraints of Wails framework
- Align with existing React component structure

186
doc/README.md Normal file
View File

@@ -0,0 +1,186 @@
# uzdb Project Documentation
## 📚 Documentation Index
This directory contains all design and specification documents for the uzdb project.
### Core Documents
| Document | Description | Status |
|----------|-------------|--------|
| [Features](./features.md) | Complete feature specification with priorities | ✅ Complete |
| [Design System](./design-system.md) | Visual design language, colors, typography, components | ✅ Complete |
| [Wireframes](./wireframes.md) | UI mockups and layout specifications | ✅ Complete |
| [User Flows](./user-flows.md) | User interaction flows and edge cases | ✅ Complete |
---
## 🎯 Project Overview
**uzdb** is a lightweight database management tool inspired by DBeaver and Navicat, built with Wails (Go + React).
### Key Characteristics
- **Lightweight**: Fast startup, minimal resource usage
- **Simple**: Focus on essential features done well
- **Modern**: Clean UI following current design trends
- **Cross-platform**: Windows, macOS, Linux support via Wails
### Tech Stack
- **Frontend**: React + TypeScript + Vite
- **Backend**: Go 1.23+
- **Desktop Framework**: Wails v2
- **Styling**: CSS Variables + modern CSS
---
## 🏗️ Architecture
```
┌─────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────┬──────────┬──────────────┐ │
│ │ Sidebar │ Editor │ Data Grid │ │
│ │ │ │ │ │
│ └──────────┴──────────┴──────────────┘ │
│ Wails Runtime │
├─────────────────────────────────────────┤
│ Backend (Go) │
│ ┌──────────┬──────────┬──────────────┐ │
│ │ MySQL │PostgreSQL│ SQLite │ │
│ │ Driver │ Driver │ Driver │ │
│ └──────────┴──────────┴──────────────┘ │
└─────────────────────────────────────────┘
```
---
## 📁 File Structure
```
uzdb/
├── frontend/ # React application
│ ├── src/
│ │ ├── components/ # UI components
│ │ ├── App.tsx # Main app component
│ │ └── main.tsx # Entry point
│ └── package.json
├── doc/ # This directory - design docs
│ ├── features.md
│ ├── design-system.md
│ ├── wireframes.md
│ ├── user-flows.md
│ └── README.md # This file
├── internal/ # Go backend code
├── main.go # Go entry point
└── wails.json # Wails configuration
```
---
## 🎨 Quick Reference
### Color Palette
- **Primary**: `#3b82f6` (Blue)
- **Success**: `#10b981` (Green)
- **Warning**: `#f59e0b` (Amber)
- **Error**: `#ef4444` (Red)
### Keyboard Shortcuts
| Action | Shortcut |
|--------|----------|
| Run query | `Ctrl/Cmd + Enter` |
| New connection | `Ctrl/Cmd + N` |
| Save | `Ctrl/Cmd + S` |
| Find | `Ctrl/Cmd + F` |
| Close tab | `Ctrl/Cmd + W` |
### Supported Databases
- ✅ MySQL 5.7+
- ✅ PostgreSQL 12+
- ✅ SQLite 3.x
---
## 👥 Team Roles
### UI/UX Designer
- Responsible for visual design and user experience
- Maintains design system consistency
- Creates wireframes and prototypes
**See:** [.qoder/agents/uiux-designer.md](../.qoder/agents/uiux-designer.md)
### Frontend Developer
- Implements React components
- Integrates with Wails runtime
- Ensures responsive design
### Backend Developer
- Implements database drivers
- Handles Go business logic
- Manages connections and queries
---
## 📋 Development Workflow
### 1. Design Phase
1. Review feature requirements
2. Create/update wireframes
3. Define component specs
4. Get team approval
### 2. Implementation Phase
1. Create React components
2. Implement Go handlers
3. Write tests
4. Integration testing
### 3. Review Phase
1. Design review against specs
2. Code review
3. QA testing
4. Bug fixes
---
## 🔗 Related Resources
### Design Tools
- [Figma](https://figma.com/) - UI design and prototyping
- [Lucide Icons](https://lucide.dev/) - Icon library
- [Inter Font](https://rsms.me/inter/) - Primary typeface
### Development
- [Wails Documentation](https://wails.io/docs/)
- [React Docs](https://react.dev/)
- [TypeScript Handbook](https://www.typescriptlang.org/docs/)
### Inspiration
- [DBeaver](https://dbeaver.io/) - Feature reference
- [Navicat](https://www.navicat.com/) - UX reference
- [TablePlus](https://tableplus.com/) - Modern design reference
---
## 📝 Contributing
When adding new features:
1. **Update documentation** in this directory
2. **Follow design system** for consistency
3. **Consider accessibility** requirements
4. **Test with real data** before merging
---
## 📞 Contact
For questions about design or features:
- Open an issue on GitHub
- Tag @uiux-designer for design questions
- Tag @maintainer for feature discussions
---
*Last updated: 2026-03-29*

217
doc/design-system.md Normal file
View File

@@ -0,0 +1,217 @@
# uzdb Design System
## Overview
uzdb is a lightweight database management tool inspired by DBeaver and Navicat, built with Wails (Go + React). This document defines the visual design language and component system.
---
## Color Palette
### Primary Colors
| Token | Value | Usage |
|-------|-------|-------|
| `--primary` | `#3b82f6` | Main actions, active states, links |
| `--primary-hover` | `#2563eb` | Hover states |
| `--primary-active` | `#1d4ed8` | Active/pressed states |
### Semantic Colors
| Token | Value | Usage |
|-------|-------|-------|
| `--success` | `#10b981` | Successful operations, connected status |
| `--warning` | `#f59e0b` | Warnings, pending states |
| `--error` | `#ef4444` | Errors, disconnected status, destructive actions |
| `--info` | `#06b6d4` | Informational messages |
### Neutral Colors
| Token | Value | Usage |
|-------|-------|-------|
| `--bg-primary` | `#ffffff` | Main background |
| `--bg-secondary` | `#f8fafc` | Secondary backgrounds, sidebars |
| `--bg-tertiary` | `#f1f5f9` | Cards, panels |
| `--border` | `#e2e8f0` | Borders, dividers |
| `--text-primary` | `#0f172a` | Primary text |
| `--text-secondary` | `#64748b` | Secondary text, labels |
| `--text-muted` | `#94a3b8` | Placeholder text, hints |
### Database Type Colors
| Token | Value | Usage |
|-------|-------|-------|
| `--db-mysql` | `#00758f` | MySQL connections |
| `--db-postgresql` | `#336791` | PostgreSQL connections |
| `--db-sqlite` |#003b57` | SQLite connections |
| `--db-mariadb` | `#003541` | MariaDB connections |
---
## Typography
### Font Family
```css
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
```
### Font Sizes
| Token | Size | Line Height | Usage |
|-------|------|-------------|-------|
| `--text-xs` | 12px | 16px | Captions, hints |
| `--text-sm` | 14px | 20px | Body text, labels |
| `--text-base` | 16px | 24px | Default text |
| `--text-lg` | 18px | 28px | Section titles |
| `--text-xl` | 20px | 28px | Panel titles |
| `--text-2xl` | 24px | 32px | Modal titles |
---
## Spacing
### Base Scale (4px grid)
| Token | Value |
|-------|-------|
| `--space-1` | 4px |
| `--space-2` | 8px |
| `--space-3` | 12px |
| `--space-4` | 16px |
| `--space-5` | 20px |
| `--space-6` | 24px |
| `--space-8` | 32px |
| `--space-10` | 40px |
| `--space-12` | 48px |
---
## Layout
### Application Shell
```
┌─────────────────────────────────────────────────────────────┐
│ Menu Bar (File, Edit, View, Tools, Help) │
├─────────────────────────────────────────────────────────────┤
│ Toolbar (Quick actions, search) │
├──────────┬──────────────────────────────────────────────────┤
│ │ │
│ Sidebar │ Main Content Area │
│ (200px) │ (Query editor, │
│ │ data grid, │
│ - Conn │ schema viewer) │
│ - DBs │ │
│ - Tables│ │
│ │ │
├──────────┴──────────────────────────────────────────────────┤
│ Status Bar (Connection info, query time, row count) │
└─────────────────────────────────────────────────────────────┘
```
### Breakpoints
- **Sidebar collapsible**: < 768px width
- **Responsive panels**: Stack vertically on small screens
---
## Components
### Button Styles
```css
.btn-primary {
background: var(--primary);
color: white;
padding: var(--space-2) var(--space-4);
border-radius: 6px;
font-size: var(--text-sm);
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text-primary);
padding: var(--space-2) var(--space-4);
border-radius: 6px;
}
```
### Input Fields
```css
.input {
border: 1px solid var(--border);
border-radius: 6px;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
}
.input:focus {
outline: 2px solid var(--primary);
border-color: transparent;
}
```
### Data Grid
- Row height: 36px
- Header height: 40px
- Cell padding: 8px 12px
- Border: 1px solid var(--border)
- Alternating row colors for readability
### Tabs
- Active tab: Primary color underline (2px)
- Inactive tab: Transparent background
- Tab height: 36px
---
## Icons
Use [Lucide Icons](https://lucide.dev/) or [Heroicons](https://heroicons.com/)
Common icons:
- `database` - Connection/database
- `table` - Table view
- `search` - Search/query
- `play` - Execute query
- `download` - Export data
- `upload` - Import data
- `settings` - Settings/preferences
- `plus` - Add new
- `trash-2` - Delete
---
## Interactions
### Hover States
- Buttons: Darken background by 10%
- Links: Underline + primary color
- Table rows: Background `--bg-tertiary`
### Focus States
- All interactive elements: 2px primary outline
- Visible focus ring for keyboard navigation
### Loading States
- Spinner for async operations
- Skeleton screens for data loading
- Disabled state during execution
### Transitions
- Duration: 150ms
- Easing: `ease-in-out`
- Properties: color, background-color, transform
---
## Accessibility
### Requirements
- Minimum contrast ratio: 4.5:1 (AA standard)
- Focus indicators on all interactive elements
- Keyboard navigation support
- Screen reader friendly labels
- Resizable text up to 200%
### Keyboard Shortcuts
| Action | Shortcut |
|--------|----------|
| Execute query | Ctrl/Cmd + Enter |
| New connection | Ctrl/Cmd + N |
| Save | Ctrl/Cmd + S |
| Find | Ctrl/Cmd + F |
| Close tab | Ctrl/Cmd + W |

377
doc/features.md Normal file
View File

@@ -0,0 +1,377 @@
# uzdb Feature Specification
## Overview
uzdb is a lightweight database management tool built with Wails (Go + React). It provides essential database operations without the complexity of enterprise tools like DBeaver or Navicat.
---
## Target Users
1. **Developers** - Need quick database access for debugging and development
2. **Small Teams** - Want a simple, fast tool for daily database tasks
3. **Students/Learners** - Need an easy-to-use SQL client for learning
---
## Supported Databases (MVP)
### Phase 1 (Initial Release)
- ✅ MySQL 5.7+ / 8.0+
- ✅ PostgreSQL 12+
- ✅ SQLite 3.x
### Phase 2 (Future)
- ⏳ MariaDB 10.3+
- ⏳ Microsoft SQL Server
- ⏳ MongoDB (basic support)
---
## Core Features
### 1. Connection Management
#### 1.1 Create Connection
**Priority:** P0
**Description:** Users can create new database connections
**Requirements:**
- Support MySQL, PostgreSQL, SQLite
- Store connection details securely
- Test connection before saving
- Support SSH tunneling (Phase 2)
**UI Components:**
- Connection dialog with form fields
- Database type selector
- Test connection button
- Advanced options accordion
#### 1.2 Connection List
**Priority:** P0
**Description:** View and manage saved connections
**Requirements:**
- Display all saved connections
- Show connection status (connected/disconnected)
- Quick connect double-click
- Right-click context menu
**UI Components:**
- Sidebar connection tree
- Status indicators
- Context menu
#### 1.3 Edit/Delete Connection
**Priority:** P0
**Description:** Modify or remove existing connections
**Requirements:**
- Edit connection properties
- Duplicate connection
- Delete with confirmation
- Cannot delete active connection
---
### 2. Schema Explorer
#### 2.1 Database Navigation
**Priority:** P0
**Description:** Browse database structure in sidebar
**Requirements:**
- Expand/collapse databases
- Show schemas (PostgreSQL)
- List tables, views, procedures
- Icon differentiation by object type
**UI Components:**
- Tree view component
- Lazy loading for large schemas
- Search/filter capability (Phase 2)
#### 2.2 Table Metadata
**Priority:** P0
**Description:** View table structure and information
**Requirements:**
- Column list with types and constraints
- Indexes display
- Foreign keys relationships
- Triggers list
**UI Components:**
- Right sidebar panel
- Tabbed interface for different metadata types
---
### 3. Query Editor
#### 3.1 SQL Editor
**Priority:** P0
**Description:** Write and execute SQL queries
**Requirements:**
- Syntax highlighting (SQL keywords, strings, comments)
- Line numbers
- Basic autocomplete (table/column names)
- Multiple tabs support
- Query history (Phase 2)
**UI Components:**
- Monaco Editor or CodeMirror
- Tab bar for multiple queries
- Toolbar with run/save buttons
#### 3.2 Query Execution
**Priority:** P0
**Description:** Execute SQL and view results
**Requirements:**
- Execute selected text or full query
- Show execution time
- Display row count
- Cancel long-running queries
- Error message display with line number
**Keyboard Shortcuts:**
- `Ctrl+Enter`: Execute query
- `Ctrl+R`: Refresh results
#### 3.3 Query Snippets
**Priority:** P2
**Description:** Save and reuse common queries
**Requirements:**
- Save query as snippet
- Organize snippets by category
- Insert snippet into editor
- Share snippets (Phase 3)
---
### 4. Data Grid
#### 4.1 Results Display
**Priority:** P0
**Description:** Show query results in tabular format
**Requirements:**
- Virtual scrolling for large datasets
- Column resizing
- Sort by clicking headers
- Fixed header row
- Alternating row colors
**Performance:**
- Render 1000+ rows smoothly
- Load data in chunks (100 rows at a time)
#### 4.2 Data Editing
**Priority:** P1
**Description:** Edit cell values inline
**Requirements:**
- Double-click to edit
- Validate data types
- Show NULL indicator
- Commit on blur or Enter
- Escape to cancel
**Cell Editors:**
- Text input for strings
- Number input for integers/decimals
- Date picker for dates
- Checkbox for booleans
- Dropdown for enums
#### 4.3 Pagination & Navigation
**Priority:** P0
**Description:** Navigate through result sets
**Requirements:**
- Page size selector (25, 50, 100, 500)
- Jump to page input
- First/Previous/Next/Last buttons
- Total row count display
- Limit/offset in query
---
### 5. Data Export/Import
#### 5.1 Export Data
**Priority:** P0
**Description:** Export table/query results to file
**Requirements:**
- Formats: CSV, JSON, SQL INSERT
- Select destination path
- Configure export options
- Show progress for large exports
**Export Options (CSV):**
- Delimiter selection (comma, semicolon, tab)
- Include/exclude headers
- Quote character
- Encoding (UTF-8 default)
**Export Options (JSON):**
- Pretty print vs compact
- Array of objects vs arrays
#### 5.2 Import Data
**Priority:** P1
**Description:** Import data from files
**Requirements:**
- Import CSV/JSON to table
- Map columns
- Preview before import
- Handle errors gracefully
- Transaction support (rollback on error)
---
### 6. Additional Features
#### 6.1 Find in Results
**Priority:** P1
**Description:** Search within query results
**Requirements:**
- Case-sensitive toggle
- Match whole word
- Highlight matches
- Navigate between matches
#### 6.2 Query History
**Priority:** P2
**Description:** Track executed queries
**Requirements:**
- Store last 100 queries per connection
- Search history
- Re-execute from history
- Pin important queries
#### 6.3 Bookmarks
**Priority:** P2
**Description:** Save frequently used queries
**Requirements:**
- Bookmark query with name
- Organize in folders
- Quick access from sidebar
---
## Non-Functional Requirements
### Performance
- App startup: < 2 seconds
- Connection open: < 1 second (local)
- Query results render: < 100ms for 100 rows
- Memory usage: < 200MB idle
### Security
- Passwords stored encrypted (keyring/keystore)
- No plain-text credentials in config files
- SSL/TLS support for connections
- Sanitize SQL inputs to prevent injection in logs
### Accessibility
- WCAG 2.1 AA compliance
- Keyboard navigation throughout
- Screen reader support
- High contrast mode (Phase 2)
### Internationalization
- English (initial)
- Chinese (Simplified) (Phase 2)
- Spanish (Phase 3)
---
## Technical Architecture
### Frontend (React + TypeScript)
```
src/
├── components/
│ ├── Sidebar/
│ │ ├── ConnectionTree.tsx
│ │ └── SchemaExplorer.tsx
│ ├── Editor/
│ │ ├── QueryEditor.tsx
│ │ └── SQLEditor.tsx
│ ├── Grid/
│ │ ├── DataGrid.tsx
│ │ └── CellEditor.tsx
│ ├── Dialogs/
│ │ ├── ConnectionDialog.tsx
│ │ └── ExportDialog.tsx
│ └── common/
│ ├── Button.tsx
│ ├── Input.tsx
│ └── Modal.tsx
├── hooks/
│ ├── useQuery.ts
│ └── useConnection.ts
├── stores/
│ ├── connectionStore.ts
│ └── queryStore.ts
└── utils/
├── formatters.ts
└── validators.ts
```
### Backend (Go)
```
internal/
├── database/
│ ├── mysql.go
│ ├── postgres.go
│ ├── sqlite.go
│ └── connection.go
├── models/
│ ├── connection.go
│ ├── query.go
│ └── table.go
└── handlers/
├── connection_handler.go
├── query_handler.go
└── export_handler.go
```
---
## Out of Scope (for MVP)
❌ Visual query builder
❌ ER diagrams
❌ Database comparison/sync
❌ User management
❌ Backup/restore
❌ Query profiling/optimization
❌ Multi-tab result sets
❌ Stored procedure debugger
❌ Real-time collaboration
---
## Success Metrics
1. **Usability**: New user can connect and run query in < 2 minutes
2. **Performance**: Query results load in < 500ms for typical queries
3. **Stability**: < 1 crash per 100 hours of usage
4. **Adoption**: 100+ GitHub stars in first month
---
## Future Roadmap
### Phase 2 (v0.2)
- MariaDB support
- SSH tunneling
- Query history
- Data import
- Dark theme
### Phase 3 (v0.3)
- SQL Server support
- Visual query builder (basic)
- Snippet sharing
- Plugin system
### Phase 4 (v1.0)
- MongoDB support
- ER diagram viewer
- Team features (shared connections)
- Auto-update

691
doc/layout-design.md Normal file
View File

@@ -0,0 +1,691 @@
# uzdb 布局设计文档
## Overview
本文档详细描述 uzdb 数据库管理工具的整体布局设计,包括左侧连接面板、右侧功能区域的界面设计和交互规范。
---
## 整体布局架构
### 经典两栏布局
```
┌─────────────────────────────────────────────────────────────────────┐
│ ☰ File Edit View Query Tools Help │ ← 菜单栏
├─────────────────────────────────────────────────────────────────────┤
│ [▶ Run] [💾 Save] [📤 Export] [🔍 Find] [🗄️ MySQL @ localhost ▼] │ ← 工具栏
├──────────────────┬──────────────────────────────────────────────────┤
│ │ ┌─ 📑 query_1.sql ─────────────────────────┐ │
│ 🗄️ Connections │ │ │ │
│ ├─ ⚫ MySQL │ │ SELECT * FROM users │ │
│ │ ├─ 🔵 public │ │ WHERE active = true; │ │
│ │ │ ├─ 📋 users │ │ │
│ │ │ ├─ 📋 products │ │ │
│ │ │ └─ 📋 orders │ │ │
│ │ └─ 📊 information_schema │ │ │
│ ├─ ⚫ PostgreSQL│ └───────────────────────────────────────────┘ │
│ │ └─ 🔵 public │ ┌─ Results ────────────────────────────────┐ │
│ └─ ⚫ SQLite │ │ id │ name │ email │ active │ │
│ │ ├────┼───────┼─────────────────┼──────────┤ │
│ [+ New Conn] │ │ 1 │ Alice │ alice@mail.com │ ✓ │ │
│ │ │ 2 │ Bob │ bob@mail.com │ ✓ │ │
│ │ │ 3 │ Carol │ carol@mail.com │ ✗ │ │
│ │ └───────────────────────────────────────────┘ │
│ │ [◀] [1] [2] [3] [▶] Per page: [25▼] 127 rows │
├──────────────────┴──────────────────────────────────────────────────┤
│ ✓ Connected to MySQL@localhost:3306 │ UTF-8 │ LF │ ins │ ← 状态栏
└─────────────────────────────────────────────────────────────────────┘
```
### 布局尺寸规范
| 区域 | 默认宽度/高度 | 可调节范围 | 说明 |
|------|--------------|-----------|------|
| 左侧面板 | 240px | 180px - 400px | 可拖拽调节,支持折叠 |
| 菜单栏 | 32px (高) | 固定 | 标准菜单高度 |
| 工具栏 | 40px (高) | 固定 | 包含快捷操作 |
| 状态栏 | 28px (高) | 固定 | 显示连接和信息状态 |
| Tab 标签栏 | 36px (高) | 固定 | 每个标签页高度 |
---
## 左侧连接面板设计
### 1. 面板结构
```
┌─────────────────────────────┐
│ 🗄️ Connections [⚙️] │ ← 面板标题 + 设置按钮
├─────────────────────────────┤
│ 🔵 Active Connection │ ← 当前活跃连接(高亮)
│ ├─ 🗄️ database_name │
│ │ ├─ 📊 schema_name │
│ │ │ ├─ 📋 table1 │
│ │ │ ├─ 📋 table2 │
│ │ │ └─ 📋 table3 │
│ │ └─ 📊 another_schema │
│ ├─ 📋 views (5) │
│ ├─ ⚡ functions (12) │
│ └─ 📝 procedures (3) │
├─────────────────────────────┤
│ ⚫ saved_connection_1 │ ← 已保存但未连接
│ ⚫ saved_connection_2 │
│ ⚪ saved_connection_3 │ ← 连接错误
├─────────────────────────────┤
│ [+ New Connection] │ ← 新建连接按钮
└─────────────────────────────┘
```
### 2. 连接状态指示器
#### 状态类型与视觉表现
| 状态 | 图标 | 颜色 | 说明 | 交互 |
|------|------|------|------|------|
| **已连接** | `🔵` / `●` | `#10b981` (绿色) | 连接成功且可用 | 可展开查看结构 |
| **使用中** | `🟢` / `◉` | `#3b82f6` (蓝色) | 当前正在使用的连接 | 高亮显示,带光晕效果 |
| **未连接** | `⚫` / `○` | `#94a3b8` (灰色) | 已保存但未建立连接 | 双击或点击展开连接 |
| **连接中** | `⟳` / `⌛` | `#f59e0b` (橙色) | 正在建立连接 | 旋转动画,不可交互 |
| **错误** | `⚪` / `✕` | `#ef4444` (红色) | 连接失败 | 悬停显示错误信息 |
#### CSS 实现示例
```css
/* 状态指示器基础样式 */
.connection-status {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 8px;
transition: all 0.15s ease-in-out;
}
/* 各状态样式 */
.status-connected {
background-color: var(--success);
box-shadow: 0 0 4px var(--success);
}
.status-active {
background-color: var(--primary);
box-shadow: 0 0 8px var(--primary);
animation: pulse 2s infinite;
}
.status-disconnected {
background-color: var(--text-muted);
border: 1px solid var(--border);
}
.status-connecting {
border: 2px solid var(--warning);
border-top-color: transparent;
animation: spin 1s linear infinite;
}
.status-error {
background-color: var(--error);
cursor: help;
}
/* 动画定义 */
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
```
### 3. 数据库类型图标
| 数据库类型 | 图标 | 颜色 | 使用场景 |
|-----------|------|------|----------|
| MySQL | `🗄️` / `🐬` | `#00758f` | MySQL 连接 |
| PostgreSQL | `🐘` / `🗄️` | `#336791` | PostgreSQL 连接 |
| SQLite | `◪` / `🗄️` | `#003b57` | SQLite 连接 |
| MariaDB | `🗄️` | `#003541` | MariaDB 连接 |
### 4. 交互方式
#### 单击交互
- **单击连接名**: 选中连接,如果未连接则尝试连接
- **单击展开箭头**: 展开/折叠数据库结构树
- **单击状态图标**: 显示连接详情 tooltip
#### 双击交互
- **双击连接**: 快速连接/断开连接
- **双击数据库/表**: 在新标签页打开数据浏览或结构查看
#### 右键菜单
```
┌─────────────────────────────┐
│ 🗄️ MySQL @ localhost │
├─────────────────────────────┤
│ ▶ Connect │
│ ▶ Disconnect │
│ ├───────────────────────── │
│ ▶ Edit Connection... │
│ ▶ Duplicate Connection │
│ ▶ Refresh Schema │
│ ├───────────────────────── │
│ ▶ Test Connection │
│ ▶ Copy Connection String │
│ ├───────────────────────── │
│ ▶ Delete Connection │
└─────────────────────────────┘
```
#### 键盘导航
| 快捷键 | 功能 |
|--------|------|
| `↑` / `↓` | 上下移动选择 |
| `→` | 展开节点 / 连接数据库 |
| `←` | 折叠节点 |
| `Enter` | 确认操作 / 连接 |
| `Space` | 切换展开/折叠 |
| `F2` | 重命名连接 |
| `Delete` | 删除连接(需确认) |
| `Ctrl+R` | 刷新 Schema |
| `Context Menu` | 显示右键菜单 |
### 5. 新建连接按钮区域
```
┌─────────────────────────────┐
│ │
│ ┌───────────────────────┐ │
│ │ + New Connection │ │ ← 主按钮
│ └───────────────────────┘ │
│ │
│ Recent: │
│ • MySQL @ localhost │
│ • PG @ prod-db │
│ │
└─────────────────────────────┘
```
---
## 右侧功能区域设计
### 1. SQL 编辑器模块
```
┌─ 📑 unsaved_query.sql ───────────────────────────────────────────┐
│ [×] │
├──────────────────────────────────────────────────────────────────┤
│ 1 │ -- Get active users with their orders │
│ 2 │ SELECT │
│ 3 │ u.id, │
│ 4 │ u.name, │
│ 5 │ u.email, │
│ 6 │ COUNT(o.id) as order_count │
│ 7 │ FROM users u │
│ 8 │ LEFT JOIN orders o ON u.id = o.user_id │
│ 9 │ WHERE u.active = true │
│ 10 │ AND u.created_at >= '2024-01-01' │
│ 11 │ GROUP BY u.id, u.name, u.email │
│ 12 │ HAVING COUNT(o.id) > 0 │
│ 13 │ ORDER BY order_count DESC │
│ 14 │ LIMIT 100; │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ ▶ Run (Ctrl+Enter) │ ▶ Explain │ ✨ Format │ 📜 History │
│ 💾 Save │ 📤 Export Results │ 🔍 Find in Query │
└──────────────────────────────────────────────────────────────────┘
```
#### SQL 编辑器特性
| 功能 | 描述 | 实现建议 |
|------|------|----------|
| **语法高亮** | SQL 关键字、字符串、注释、函数等 | Monaco Editor / CodeMirror |
| **智能提示** | 表名、列名、函数自动补全 | 基于当前连接 schema |
| **代码格式化** | 美化 SQL 格式 | sql-formatter 库 |
| **多标签支持** | 同时编辑多个查询 | Tab 组件管理 |
| **查询历史** | 记录执行过的 SQL | 本地存储 + 搜索 |
| **代码片段** | 常用 SQL 模板快速插入 | 预定义 snippets |
#### 执行结果展示区
```
┌─ Results ────────────────────────────────────────────────────────┐
│ Query executed successfully in 0.045s (127 rows affected) │
├──────────────────────────────────────────────────────────────────┤
│ [Filters ▼] [Sort ▼] [Group By ▼] [🔄 Refresh] [📤 Export] │
├──────────────────────────────────────────────────────────────────┤
│ id │ name │ email │ created_at │ active │ ... │
├─────┼─────────┼─────────────────┼─────────────┼────────┼───────┤
│ 1 │ Alice │ alice@mail.com │ 2024-01-15 │ ✓ │ ... │
│ 2 │ Bob │ bob@mail.com │ 2024-01-16 │ ✓ │ ... │
│ 3 │ Charlie │ carol@mail.com │ 2024-01-17 │ ✗ │ ... │
│ 4 │ Diana │ diana@mail.com │ 2024-01-18 │ ✓ │ ... │
│ 5 │ Eve │ eve@mail.com │ 2024-01-19 │ ✓ │ ... │
└──────────────────────────────────────────────────────────────────┘
│ Showing 1-25 of 127 rows [◀◀] [◀] [1] [2] [3] [...] [▶] [▶▶] │
│ Per page: [25 ▼] [📋 Copy Row] │
└──────────────────────────────────────────────────────────────────┘
```
### 2. 数据浏览模块
```
┌─ 📋 public.users ────────────────────────────────────────────────┐
│ [×] │
├──────────────────────────────────────────────────────────────────┤
│ Table: users │ Database: public │ Connection: MySQL@local │
├──────────────────────────────────────────────────────────────────┤
│ [Data] [Structure] [Indexes] [Foreign Keys] [Triggers] │
├──────────────────────────────────────────────────────────────────┤
│ [✏️ Add Row] [🗑️ Delete] [🔍 Filter] [🔄 Refresh] [📤 Export]│
├──────────────────────────────────────────────────────────────────┤
│ ☐ │ id │ name │ email │ created_at │ active │ ✎ │
├────┼─────┼─────────┼─────────────────┼─────────────┼────────┼───┤
│ ☐ │ 1 │ Alice │ alice@mail.com │ 2024-01-15 │ ✓ │ ✎ │
│ ☐ │ 2 │ Bob │ bob@mail.com │ 2024-01-16 │ ✓ │ ✎ │
│ ☐ │ 3 │ Charlie │ carol@mail.com │ 2024-01-17 │ ✗ │ ✎ │
│ ☐ │ 4 │ Diana │ diana@mail.com │ 2024-01-18 │ ✓ │ ✎ │
└──────────────────────────────────────────────────────────────────┘
│ Selected: 0 rows Total: 1,247 rows │
│ [◀◀] [◀] [1] [2] [3] [...] [▶] [▶▶] Per page: [50 ▼] │
└──────────────────────────────────────────────────────────────────┘
```
#### 数据编辑功能
| 操作 | 交互方式 | 说明 |
|------|----------|------|
| **单元格编辑** | 双击单元格 | 根据数据类型弹出相应编辑器 |
| **行选择** | 点击复选框 | 支持多选批量操作 |
| **添加行** | 点击"Add Row" | 在表格末尾添加新行 |
| **删除行** | 选择后点删除 | 需要确认对话框 |
| **撤销/重做** | Ctrl+Z / Ctrl+Y | 支持多级撤销 |
#### 单元格编辑器类型
| 数据类型 | 编辑器 | 说明 |
|----------|--------|------|
| VARCHAR/TEXT | 文本输入框 | 支持多行编辑 |
| INT/BIGINT | 数字输入框 | 整数验证 |
| DECIMAL/FLOAT | 数字输入框 | 小数精度控制 |
| DATE | 日期选择器 | 日历控件 |
| DATETIME/TIMESTAMP | 日期时间选择器 | 包含时间 |
| BOOLEAN | 复选框 | 勾选/取消 |
| ENUM | 下拉选择框 | 预定义选项 |
| JSON | JSON 编辑器 | 语法高亮 |
| BLOB | 文件上传/预览 | 二进制数据处理 |
### 3. 表结构查看模块
```
┌─ 📋 Table Structure: users ──────────────────────────────────────┐
│ [×] │
├──────────────────────────────────────────────────────────────────┤
│ [Data] [Structure] [Indexes] [Foreign Keys] [Triggers] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Columns (5) ─────────────────────────────────────────────┐ │
│ │ Name │ Type │ Null │ Key │ Default │ Extra │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ id │ INT │ ❌ │ PK │ │ auto_inc│ │
│ │ name │ VARCHAR(100) │ ❌ │ │ │ │ │
│ │ email │ VARCHAR(255) │ ❌ │ UK │ │ │ │
│ │ created_at │ TIMESTAMP │ ✓ │ │ NOW() │ │ │
│ │ active │ BOOLEAN │ ❌ │ │ TRUE │ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Indexes (2) ──────────────────────────────────────────────┐ │
│ │ Name │ Type │ Columns │ Unique │ Method │ │
│ ├────────────────────────────────────────────────────────────┤ │
│ │ PRIMARY │ BTREE │ id │ ✓ │ BTREE │ │
│ │ idx_email │ BTREE │ email │ ✓ │ BTREE │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Foreign Keys (1) ─────────────────────────────────────────┐ │
│ │ Name │ Column │ References │ On Update │ On Delete││
│ ├────────────────────────────────────────────────────────────┤ │
│ │ fk_user_role │ role_id │ roles(id) │ CASCADE │ SET NULL ││
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Table Info ───────────────────────────────────────────────┐ │
│ │ Engine: InnoDB │ Collation: utf8mb4_unicode_ci │ │
│ │ Rows: 1,247 │ Size: 256 KB │ │
│ │ Auto Increment: 1248 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
```
### 4. 其他功能模块
#### 4.1 查询历史
```
┌─ 📜 Query History ───────────────────────────────────────────────┐
│ [×] │
├──────────────────────────────────────────────────────────────────┤
│ 🔍 Search history... [🗑️ Clear All] │
├──────────────────────────────────────────────────────────────────┤
│ Today │
│ ├─ SELECT * FROM users WHERE active = true... (10:45 AM) │
│ ├─ UPDATE products SET price = price * 1.1... (10:32 AM) │
│ └─ DELETE FROM temp_data WHERE created_at < ... (09:15 AM) │
│ │
│ Yesterday │
│ ├─ CREATE INDEX idx_email ON users(email)... (03:22 PM) │
│ └─ SELECT COUNT(*) FROM orders... (02:10 PM) │
│ │
│ Last 7 Days │
│ └─ ... │
└──────────────────────────────────────────────────────────────────┘
```
#### 4.2 导出向导
```
┌─ Export Data ────────────────────────────────────────────────────┐
│ [×] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ Source: [✓ users (entire table) ▼] │
│ │
│ Format: [CSV Comma-separated (,) ▼] │
│ │
│ ┌─ Options ─────────────────────────────────────────────────┐ │
│ │ ☑ Include column headers │ │
│ │ ☐ Quote all fields │ │
│ │ Delimiter: [, ▼] Quote: [" ▼] Escape: [\ ▼] │ │
│ │ Encoding: [UTF-8 ] │ │
│ │ Line endings: [Unix (LF) ▼] │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ Output: (○) Copy to clipboard │
│ (●) Save to file │
│ [ /home/user/exports/users.csv ] [Browse] │
│ │
│ Preview (first 5 rows): │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ id,name,email,created_at,active │ │
│ │ 1,Alice,alice@mail.com,2024-01-15,true │ │
│ │ 2,Bob,bob@mail.com,2024-01-16,true │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ [Cancel] [Export] │
│ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 状态栏设计规范
### 状态栏布局
```
┌─────────────────────────────────────────────────────────────────┐
│ [状态] [连接信息] │ [编码] │ [换行] │ [模式] │
└─────────────────────────────────────────────────────────────────┘
```
### 状态指示
| 区域 | 内容 | 示例 |
|------|------|------|
| **连接状态** | 当前连接状态和详情 | `✓ Connected to MySQL@localhost:3306` |
| **查询信息** | 执行时间和影响行数 | `✓ 127 rows in 0.045s` |
| **错误信息** | 错误描述 | `✕ Error: Connection timeout` |
| **编码格式** | 文件/连接编码 | `UTF-8` |
| **换行符** | 行结束符类型 | `LF` / `CRLF` |
| **编辑模式** | 编辑器模式 | `ins` (插入) / `ovr` (覆盖) |
### 状态栏颜色
| 状态 | 背景色 | 文字色 | 图标 |
|------|--------|--------|------|
| 正常 | `#f8fafc` | `#64748b` | - |
| 成功 | `#d1fae5` | `#065f46` | `✓` |
| 警告 | `#fef3c7` | `#92400e` | `⚠` |
| 错误 | `#fee2e2` | `#991b1b` | `✕` |
| 信息 | `#dbeafe` | `#1e40af` | `` |
---
## 响应式设计
### 断点定义
| 断点 | 宽度范围 | 布局调整 |
|------|----------|----------|
| **Small** | < 768px | 侧边栏隐藏为抽屉,单栏布局 |
| **Medium** | 768px - 1024px | 侧边栏可折叠,双栏布局 |
| **Large** | > 1024px | 完整双栏布局,所有功能可见 |
### 小屏幕适配
```
┌─────────────────────────────────────┐
│ ☰ uzdb [⚙️] │ ← 汉堡菜单
├─────────────────────────────────────┤
│ [▶] [💾] [📤] [🔍] [▼] │ ← 简化 toolbar
├─────────────────────────────────────┤
│ │
│ [全屏编辑器/数据网格] │
│ │
│ │
├─────────────────────────────────────┤
│ ✓ Connected │ UTF-8 │ ins │
└─────────────────────────────────────┘
```
---
## 主题与配色
### 浅色主题(默认)
参考 `/root/codes/project/self/uzdb/doc/design-system.md` 中的颜色定义:
| 元素 | 颜色值 | 用途 |
|------|--------|------|
| 主背景 | `#ffffff` | 主内容区域 |
| 次级背景 | `#f8fafc` | 侧边栏、面板 |
| 第三背景 | `#f1f5f9` | 卡片、分组 |
| 边框 | `#e2e8f0` | 分隔线、边框 |
| 主文字 | `#0f172a` | 标题、正文 |
| 次级文字 | `#64748b` | 标签、说明 |
| 强调色 | `#3b82f6` | 链接、按钮、激活状态 |
### 深色主题(未来扩展)
预留深色主题支持,使用 CSS 变量实现主题切换。
---
## 交互细节规范
### 1. 悬停效果
| 元素 | 悬停效果 | 过渡时间 |
|------|----------|----------|
| 按钮 | 背景变深 10% | 150ms |
| 链接 | 下划线 + 强调色 | 150ms |
| 表格行 | 背景色 `#f1f5f9` | 100ms |
| 树节点 | 背景色 `#f1f5f9` | 100ms |
### 2. 焦点状态
所有可交互元素必须具有可见的焦点环:
```css
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
```
### 3. 加载状态
| 场景 | 加载指示 | 说明 |
|------|----------|------|
| 连接建立 | 旋转 Spinner | 状态变为"连接中" |
| 查询执行 | 进度条 + Spinner | 显示执行进度 |
| 数据加载 | Skeleton 骨架屏 | 渐进式加载 |
| 大数据导出 | 进度百分比 | 可取消操作 |
### 4. 空状态
```
┌─────────────────────────────────────┐
│ │
│ 🗄️ │
│ │
│ No Database Connections │
│ │
│ Get started by connecting to your │
│ first database │
│ │
│ [+ New Connection] │
│ │
│ Supported: MySQL, PostgreSQL, │
│ SQLite, MariaDB │
│ │
└─────────────────────────────────────┘
```
### 5. 错误提示
- **Inline 错误**: 表单字段下方红色文字
- **Toast 通知**: 右上角弹出提示3 秒自动消失
- **模态对话框**: 严重错误需要用户确认
---
## 无障碍设计 (Accessibility)
### WCAG 2.1 AA 合规
- ✅ 对比度至少 4.5:1
- ✅ 键盘导航支持
- ✅ 屏幕阅读器标签
- ✅ 焦点指示器可见
- ✅ 可调整字体大小(最高 200%
### 键盘快捷键总览
| 快捷键 | 功能 | 作用域 |
|--------|------|--------|
| `Ctrl+Enter` | 执行查询 | SQL 编辑器 |
| `Ctrl+S` | 保存 | 全局 |
| `Ctrl+N` | 新建连接 | 全局 |
| `Ctrl+F` | 查找 | 编辑器/数据网格 |
| `Ctrl+Z` | 撤销 | 编辑器 |
| `Ctrl+Y` | 重做 | 编辑器 |
| `Ctrl+W` | 关闭标签 | 标签页 |
| `F2` | 重命名 | 树节点 |
| `F5` | 刷新 | 全局 |
| `Delete` | 删除 | 选中项 |
| `Esc` | 取消/关闭 | 模态框 |
---
## 性能优化建议
### 1. 虚拟滚动
对于大数据集(>100 行),使用虚拟滚动技术:
- 只渲染可见区域的数据
- 滚动时动态加载数据块
- 保持滚动条总长度正确
### 2. 懒加载
- 树形结构按需加载子节点
- 图片/图标延迟加载
- 非关键组件异步加载
### 3. 缓存策略
- 查询结果短期缓存5 分钟)
- Schema 信息中期缓存30 分钟)
- 连接信息长期缓存(直到断开)
---
## 开发实现建议
### 推荐组件库
| 组件类型 | 推荐库 | 说明 |
|----------|--------|------|
| UI 组件 | Radix UI / Headless UI | 无样式组件,灵活定制 |
| 数据网格 | TanStack Table / AG Grid | 高性能表格 |
| 代码编辑 | Monaco Editor / CodeMirror | SQL 语法支持 |
| 图表 | Recharts / Chart.js | 数据统计展示 |
| 图标 | Lucide Icons / Heroicons | 一致风格 |
### Wails 集成要点
1. **前后端通信**: 使用 Wails Events 实现实时通知
2. **状态管理**: Zustand / Jotai 轻量级方案
3. **路由**: 单页应用,基于状态的视图切换
4. **样式**: TailwindCSS + CSS Variables 主题系统
---
## 附录:组件层级结构
```
App
├── MenuBar
├── ToolBar
├── MainContent
│ ├── Sidebar
│ │ ├── ConnectionPanel
│ │ │ ├── ConnectionHeader
│ │ │ ├── ConnectionList
│ │ │ │ └── ConnectionItem
│ │ │ │ ├── StatusIndicator
│ │ │ │ ├── ConnectionName
│ │ │ │ └── ContextMenu
│ │ │ └── NewConnectionButton
│ │ └── SchemaExplorer
│ │ └── SchemaTree
│ └── Workspace
│ ├── TabBar
│ └── TabContent
│ ├── SQLEditor
│ │ ├── EditorToolbar
│ │ ├── CodeEditor
│ │ └── ResultsPanel
│ ├── DataTable
│ │ ├── TableToolbar
│ │ ├── DataGrid
│ │ └── Pagination
│ └── TableStructure
│ ├── StructureTabs
│ └── StructureContent
└── StatusBar
```
---
## 版本历史
| 版本 | 日期 | 作者 | 变更说明 |
|------|------|------|----------|
| 1.0 | 2026-03-29 | Design Team | 初始版本,完整布局设计 |
---
## 参考文档
- [Design System](./design-system.md) - 视觉设计规范
- [Wireframes](./wireframes.md) - 原始线框图
- [Features](./features.md) - 功能规格说明
- [User Flows](./user-flows.md) - 用户流程

396
doc/user-flows.md Normal file
View File

@@ -0,0 +1,396 @@
# uzdb User Flows
## 1. First-Time User Experience
```
┌─────────────────┐
│ Launch App │
└────────┬────────┘
┌─────────────────┐
│ Empty State: │
│ "No Connections"│
└────────┬────────┘
│ Click "New Connection"
┌─────────────────┐
│ Connection │
│ Wizard │
│ 1. Select Type │
└────────┬────────┘
┌─────────────────┐
│ 2. Configure │
│ Connection │
│ (Host, Port, │
│ Credentials) │
└────────┬────────┘
│ Click "Test Connection"
┌─────────────────┐
│ Test Result │
│ ✓ Success │
│ ✕ Error + Help │
└────────┬────────┘
│ Save & Connect
┌─────────────────┐
│ Main Interface │
│ with Schema │
│ Explorer │
└─────────────────┘
```
---
## 2. Query Execution Flow
```
┌─────────────────┐
│ Open Query Tab │
│ (Ctrl+T) │
└────────┬────────┘
┌─────────────────┐
│ Write/Edit SQL │
│ - Syntax │
│ highlighting │
│ - Autocomplete │
│ - Snippets │
└────────┬────────┘
│ Ctrl+Enter or Click Run
┌─────────────────┐
│ Execute Query │
│ Show loading │
│ indicator │
└────────┬────────┘
┌─────────────────┐
│ Display Results │
│ - Data grid │
│ - Row count │
│ - Execution time│
│ - Status │
└────────┬────────┘
├──────────────┬──────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Export Data │ │ Edit Cell │ │ Save Query │
│ (CSV, JSON) │ │ (Inline) │ │ (.sql file) │
└─────────────┘ └─────────────┘ └─────────────┘
```
---
## 3. Table Browsing Flow
```
┌─────────────────┐
│ Expand Database │
│ in Sidebar │
└────────┬────────┘
┌─────────────────┐
│ Expand Schema │
└────────┬────────┘
┌─────────────────┐
│ Click Table │
└────────┬────────┘
├─────────────────┬────────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Preview Top │ │ View │ │ Open Table │
│ 100 Rows │ │ Structure │ │ Details │
│ (Double-click)│ │ (Right panel)│ │ Panel │
└─────────────┘ └─────────────┘ └─────────────┘
┌─────────────────┐
│ Columns, Indexes│
│ Foreign Keys, │
│ Triggers │
└─────────────────┘
```
---
## 4. Connection Management Flow
### Add New Connection
```
┌─────────────────┐
│ Click "+" │
│ or File > New │
└────────┬────────┘
┌─────────────────┐
│ Select DB Type │
│ ○ MySQL │
│ ○ PostgreSQL │
│ ○ SQLite │
│ ○ MariaDB │
└────────┬────────┘
┌─────────────────┐
│ Fill Form │
│ - Name │
│ - Host/Port │
│ - Auth │
│ - Advanced opts │
└────────┬────────┘
│ Test Connection
┌─────────────────┐
│ Connection Test │
│ Result │
└────────┬────────┘
│ Success → Save
┌─────────────────┐
│ Added to │
│ Connection List │
└─────────────────┘
```
### Edit/Delete Connection
```
┌─────────────────┐
│ Right-click │
│ Connection │
└────────┬────────┘
├─────────────┬──────────────┐
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Edit │ │ Duplicate │ │ Delete │
│ Connection │ │ Connection │ │ Connection │
└─────────────┘ └─────────────┘ └──────┬──────┘
┌─────────────────┐
│ Confirm Delete │
│ "Are you sure?" │
└────────┬────────┘
┌─────────────────┐
│ Connection │
│ Removed │
└─────────────────┘
```
---
## 5. Data Export Flow
```
┌─────────────────┐
│ Select Data │
│ (table/query │
│ results) │
└────────┬────────┘
│ Click Export button
┌─────────────────┐
│ Export Dialog │
│ - Format: │
│ CSV/JSON/SQL │
│ - Options │
│ - Destination │
└────────┬────────┘
│ Configure options
┌─────────────────┐
│ Review Settings │
│ - Preview │
│ - Row count │
│ - File size est.│
└────────┬────────┘
│ Click Export
┌─────────────────┐
│ Export Progress │
│ [████████░░] 80%│
└────────┬────────┘
┌─────────────────┐
│ Export Complete │
│ ✓ 1,234 rows │
│ exported to │
│ /path/file.csv│
│ │
│ [Open File] │
└─────────────────┘
```
---
## 6. Error Handling Flow
### Connection Error
```
┌─────────────────┐
│ Attempt Connect │
└────────┬────────┘
│ Fails
┌─────────────────┐
│ Error Dialog │
│ ┌─────────────┐ │
│ │ ⚠ Connection│ │
│ │ Failed │ │
│ │ │ │
│ │ ECONNREFUSED│ │
│ │ localhost: │ │
│ │ 3306 │ │
│ └─────────────┘ │
│ │
│ Common causes: │
│ • Server not │
│ running │
│ • Wrong port │
│ • Firewall │
│ │
│ [Retry] [Help] │
└─────────────────┘
```
### Query Error
```
┌─────────────────┐
│ Execute Query │
└────────┬────────┘
│ Syntax Error
┌─────────────────┐
│ Results Panel │
│ Shows error: │
│ ┌─────────────┐ │
│ │ ❌ Error │ │
│ │ Line 3: │ │
│ │ Unknown col │ │
│ │ 'emai' │ │
│ │ │ │
│ │ SELECT id, │ │
│ │ emai │ │
│ │ ^^^^ │ │
│ └─────────────┘ │
└─────────────────┘
```
---
## 7. Keyboard Navigation Flow
```
Global Shortcuts:
- Ctrl+N: New connection
- Ctrl+T: New query tab
- Ctrl+S: Save
- Ctrl+W: Close tab
- Ctrl+Enter: Run query
- Ctrl+F: Find in editor
- Ctrl+,: Settings
Within Data Grid:
- Arrow keys: Navigate cells
- Enter: Edit cell
- Esc: Cancel edit
- Ctrl+C: Copy selection
- Ctrl+F: Filter column
Within Query Editor:
- Ctrl+Space: Autocomplete
- Tab: Insert snippet
- Ctrl+/: Toggle comment
- Ctrl+D: Duplicate line
```
---
## 8. Settings/Preferences Flow
```
┌─────────────────┐
│ Tools > Settings│
│ or Ctrl+, │
└────────┬────────┘
┌─────────────────┐
│ Settings Modal │
│ ┌─────────────┐ │
│ │ General │ │ ← Selected
│ │ Editor │ │
│ │ Data Grid │ │
│ │ Keybindings │ │
│ │ Themes │ │
│ └─────────────┘ │
│ │
│ ┌─────────────┐ │
│ │ Font size │ │
│ │ [14 ▼] │ │
│ │ │ │
│ │ Theme │ │
│ │ [Light ▼] │ │
│ │ │ │
│ │ Auto-save │ │
│ │ [✓] │ │
│ └─────────────┘ │
│ │
│ [Reset] [OK] │
└─────────────────┘
```
---
## Edge Cases Handled
1. **Lost Connection During Query**
- Show error message
- Offer to reconnect
- Preserve query text
2. **Large Result Sets (>10k rows)**
- Warn user before loading all
- Offer to limit results
- Provide streaming option
3. **Unsaved Changes on Close**
- Prompt to save
- Auto-save to temp file
- Restore on relaunch
4. **Multiple Tabs with Same Connection**
- Share connection instance
- Independent transactions
- Clear tab indicators
5. **Long-running Queries**
- Show progress if available
- Provide cancel button
- Timeout configuration

228
doc/wireframes.md Normal file
View File

@@ -0,0 +1,228 @@
# uzdb UI Wireframes
## Main Application Window
### 1. Connection Manager (Startup Screen)
```
┌─────────────────────────────────────────────────────────────┐
│ uzdb - Connect │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ + New Connection │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ 🗄️ Local MySQL │ │
│ │ localhost:3306 • mysql │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ 🐘 Production PostgreSQL │ │
│ │ prod-db.example.com:5432 • postgres │ │
│ ├──────────────────────────────────────────────────────┤ │
│ │ ◪ Local SQLite │ │
│ │ ~/data/app.db │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ [New Connection] [Edit] [Delete] [Connect] │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 2. Main Application Layout
```
┌────────────────────────────────────────────────────────────────────┐
│ File Edit View Query Tools Help │
├────────────────────────────────────────────────────────────────────┤
│ [▶ Run] [💾 Save] [📤 Export] [🔍 Find] [Connection: ▼] │
├─────────────┬──────────────────────────────────────────────────────┤
│ │ ┌─ Query Editor ──────────────────────────────┐ │
│ 🗄️ Database │ │ │ │
│ ├─ 🗄️ MySQL │ │ SELECT * FROM users │ │
│ │ ├─ 📊 public │ WHERE active = 1; │ │
│ │ │ ├─ 📋 users │ │ │
│ │ │ ├─ 📋 products │ │ │
│ │ │ └─ 📋 orders │ │ │
│ │ └─ 📊 information_schema │ │ │
│ ├─ 🐘 PostgreSQL│ └─────────────────────────────────────────┘ │
│ │ └─ 📊 public │ 127 rows │ 0.045s │
│ └─ ◪ SQLite ├──────────────────────────────────────────────────┤
│ │ Results │
│ Connections │ ┌─────────────────────────────────────────────┐ │
│ (drag to add) │ │ id │ name │ email │ active │ │
│ │ ├────┼──────────┼────────────────┼───────────┤ │
│ │ │ 1 │ Alice │ alice@mail.com │ ✓ │ │
│ │ │ 2 │ Bob │ bob@mail.com │ ✓ │ │
│ │ │ 3 │ Charlie │ charlie@mail.c │ ✗ │ │
│ │ └─────────────────────────────────────────────┘ │
│ │ │
│ │ [<] [1] [2] [3] [...] [10] [>] Per page: [25▼] │
├─────────────┴───────────────────────────────────────────────────┤
│ ✓ Connected to MySQL@localhost:3306 │ UTF-8 │ LF │ ins │
└──────────────────────────────────────────────────────────────────┘
```
### 3. Query Editor Tab
```
┌─ Untitled.sql ───────────────────────────────────────────────────┐
│ 1 │ SELECT u.id, u.name, COUNT(o.id) as order_count │
│ 2 │ FROM users u │
│ 3 │ LEFT JOIN orders o ON u.id = o.user_id │
│ 4 │ WHERE u.created_at > '2024-01-01' │
│ 5 │ GROUP BY u.id, u.name │
│ 6 │ HAVING COUNT(o.id) > 5 │
│ 7 │ ORDER BY order_count DESC │
│ 8 │ LIMIT 100; │
└──────────────────────────────────────────────────────────────────┘
▶ Run (Ctrl+Enter) │ Explain │ Format │ History │ Snippets
```
### 4. Table Data View
```
┌─ public.users ───────────────────────────────────────────────────┐
│ [Filters] [Sort] [Group By] [Refresh] [Export] [Import] │
├──────────────────────────────────────────────────────────────────┤
│ ☑ │ id │ name │ email │ created_at │ [actions] │
├───┼─────┼─────────┼─────────────────┼─────────────┼───────────┤
│ ✓ │ 1 │ Alice │ alice@mail.com │ 2024-01-15 │ ✏️ 🗑️ │
│ ✓ │ 2 │ Bob │ bob@mail.com │ 2024-01-16 │ ✏️ 🗑️ │
│ ✓ │ 3 │ Charlie │ charlie@mail.co │ 2024-01-17 │ ✏️ 🗑️ │
│ ✓ │ 4 │ Diana │ diana@mail.com │ 2024-01-18 │ ✏️ 🗑️ │
└──────────────────────────────────────────────────────────────────┘
│ Showing 1-25 of 1,247 rows [First] [Prev] [1] [2] [Next] [Last]│
└───────────────────────────────────────────────────────────────────┘
```
### 5. Table Details Panel (Right Sidebar)
```
┌─ Table Info ─────────────────────────────────┐
│ 📋 users │
├──────────────────────────────────────────────┤
│ Columns (5) │
│ ┌──────────────────────────────────────────┐ │
│ │ name │ type │ null │ key │ │
│ ├──────────────────────────────────────────┤ │
│ │ id │ int │ ❌ │ PK │ │
│ │ name │ varchar │ ❌ │ │ │
│ │ email │ varchar │ ❌ │ UK │ │
│ │ created_at │ timestamp │ ✓ │ │ │
│ │ active │ boolean │ ❌ │ │ │
│ └──────────────────────────────────────────┘ │
│ │
│ Indexes (2) │
│ ├─ PRIMARY (id) │
│ └─ idx_email (email) │
│ │
│ Foreign Keys (0) │
│ │
│ Triggers (1) │
│ └─ update_timestamp │
└──────────────────────────────────────────────┘
```
### 6. New Connection Dialog
```
┌─────────────────────────────────────────────────────────────┐
│ New Connection [✕] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Database Type: [MySQL ▼] │
│ │
│ ┌─ Connection ──────────────────────────────────────────┐ │
│ │ Name: [Local Development ] │ │
│ │ Host: [localhost ] │ │
│ │ Port: [3306 ] │ │
│ │ Username: [root ] │ │
│ │ Password: [••••••••••••••••• ] 👁 │ │
│ │ Database: [ ] │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Advanced ────────────────────────────────────────────┐ │
│ │ SSL: [▼] │ │
│ │ Timeout: [30] seconds │ │
│ │ Max connections: [10] │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ [Test Connection] [Cancel] [Save & Connect] │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 7. Export Data Dialog
```
┌─────────────────────────────────────────────────────────────┐
│ Export Data [✕] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Source: [✓ users (entire table) ▼] │
│ │
│ Format: [CSV Comma-separated (.) ▼] │
│ │
│ ┌─ Options ────────────────────────────────────────────┐ │
│ │ ☑ Include column headers │ │
│ │ ☐ Quote all fields │ │
│ │ Encoding: [UTF-8 ] │ │
│ │ Line endings: [Unix (LF) ▼] │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ Output: (○) Copy to clipboard │
│ (●) Save to file │
│ [ /home/user/exports/users.csv ] [...] │
│ │
│ [Cancel] [Export] │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 8. Empty State - No Connections
```
┌─────────────────────────────────────────────────────────────┐
│ │
│ │
│ 🗄️ │
│ │
│ No Database Connections │
│ │
│ Get started by connecting to your first database │
│ │
│ │
│ [+ New Connection] │
│ │
│ Supported: MySQL, PostgreSQL, SQLite, MariaDB │
│ │
└─────────────────────────────────────────────────────────────┘
```
## Responsive Considerations
### Small Screens (< 768px)
- Sidebar becomes collapsible drawer
- Tabs stack vertically
- Toolbar items move to overflow menu
- Data grid shows horizontal scroll
### Medium Screens (768px - 1024px)
- Sidebar can be collapsed
- Single query editor tab visible
- Reduced panel padding
---
## Component States
### Button States
- **Default**: Primary color background
- **Hover**: Darken 10%
- **Active**: Darken 20%, slight inset shadow
- **Disabled**: 50% opacity, not clickable
- **Loading**: Spinner replaces icon/text
### Input States
- **Default**: Light border
- **Focus**: Primary color ring
- **Error**: Red border + error message
- **Disabled**: Gray background, not editable
- **Read-only**: Subtle background, copyable
### Connection Status
- **Connected**: Green dot + "Connected"
- **Connecting**: Spinning loader
- **Disconnected**: Gray dot + "Disconnected"
- **Error**: Red dot + error tooltip

46
uzdb/.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Binaries
*.exe
*.test
*.out
uzdb.exe
build/bin/
# Dependency directories
vendor/
# Node modules (frontend)
node_modules/
frontend/dist/
frontend/.vite/
# Wails build artifacts
wails_build/
# Data directory (contains user data)
data/
*.db
*.sqlite
*.sqlite3
# Log files
*.log
logs/
# Environment variables
.env
.env.local
# Temporary files
tmp/
temp/
*.tmp
# IDE and editor files
.idea/
.vscode/
*.swp
*~
# OS files
.DS_Store
Thumbs.db

164
uzdb/API_TEST.md Normal file
View File

@@ -0,0 +1,164 @@
# API Testing Guide
## Base URL
```
Development: http://localhost:8080/api
```
## Connection Management
### List all connections
```bash
curl http://localhost:8080/api/connections
```
### Create a new connection
```bash
curl -X POST http://localhost:8080/api/connections \
-H "Content-Type: application/json" \
-d '{
"name": "My Database",
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "password123",
"database": "mydb"
}'
```
### Update a connection
```bash
curl -X PUT http://localhost:8080/api/connections/{id} \
-H "Content-Type: application/json" \
-d '{
"name": "Updated Name",
"host": "new-host.com"
}'
```
### Delete a connection
```bash
curl -X DELETE http://localhost:8080/api/connections/{id}
```
### Test connection
```bash
curl -X POST http://localhost:8080/api/connections/{id}/test
```
## Query Operations
### Execute a query
```bash
curl -X POST http://localhost:8080/api/query \
-H "Content-Type: application/json" \
-d '{
"connectionId": "{connection-id}",
"sql": "SELECT * FROM users LIMIT 10"
}'
```
### Get tables from a connection
```bash
curl http://localhost:8080/api/connections/{id}/tables
```
### Get table data
```bash
curl "http://localhost:8080/api/connections/{id}/tables/users/data?limit=20&offset=0"
```
### Get table structure
```bash
curl http://localhost:8080/api/connections/{id}/tables/users/structure
```
## Saved Queries
### List saved queries
```bash
curl http://localhost:8080/api/saved-queries
```
### Save a query
```bash
curl -X POST http://localhost:8080/api/saved-queries \
-H "Content-Type: application/json" \
-d '{
"name": "Daily Users Report",
"sql": "SELECT * FROM users WHERE created_at > NOW() - INTERVAL 1 DAY",
"connectionId": "{connection-id}"
}'
```
### Execute saved query
```bash
curl -X POST http://localhost:8080/api/saved-queries/{id}/execute
```
## Query History
### Get query history
```bash
curl http://localhost:8080/api/history?limit=50
```
### Clear query history
```bash
curl -X DELETE http://localhost:8080/api/history
```
## Response Format
All responses follow this format:
### Success
```json
{
"success": true,
"data": { ... },
"message": ""
}
```
### Error
```json
{
"success": false,
"data": null,
"error": "Error message here",
"code": "CONNECTION_NOT_FOUND"
}
```
## Using Wails Bindings (from Frontend)
```javascript
// Import Wails runtime
import { Events } from "@wailsio/runtime";
// Get all connections
const connections = await window.go.app.GetConnections();
// Create connection
await window.go.app.CreateConnection({
name: "Test DB",
type: "mysql",
host: "localhost",
port: 3306,
username: "root",
password: "secret",
database: "test"
});
// Execute query
const result = await window.go.app.ExecuteQuery(
connectionId,
"SELECT * FROM users"
);
console.log("Rows:", result.rows);
console.log("Columns:", result.columns);
console.log("Execution time:", result.executionTimeMs);
```

228
uzdb/README.md Normal file
View File

@@ -0,0 +1,228 @@
# uzdb - Lightweight Database Client
A modern, lightweight database management tool inspired by DBeaver and Navicat, built with Wails (Go + React).
## 🚀 Features
- **Multi-Database Support**: MySQL, PostgreSQL, SQLite
- **Modern UI**: Clean interface with dark/light themes
- **SQL Editor**: Syntax highlighting, autocomplete, query history
- **Data Grid**: Sortable, filterable, editable data view
- **Connection Management**: Secure credential storage with encryption
- **Fast & Lightweight**: Minimal resource usage, quick startup
## 🛠️ Tech Stack
### Frontend
- **Framework**: React 18 + TypeScript
- **Build Tool**: Vite
- **Styling**: CSS Variables + Modern CSS
- **Icons**: Lucide Icons
### Backend
- **Language**: Go 1.23+
- **Web Framework**: Gin
- **ORM**: GORM
- **Database**: SQLite3 (for app data)
- **Logging**: Zap
- **Desktop**: Wails v2
## 📁 Project Structure
```
uzdb/
├── frontend/ # React application
│ ├── src/
│ │ ├── components/ # UI components
│ │ ├── mock/ # Mock data for development
│ │ └── index.css # Global styles
│ └── package.json
├── internal/ # Go backend (private packages)
│ ├── app/ # Wails bindings
│ ├── config/ # Configuration management
│ ├── models/ # Data models
│ ├── database/ # Database connections
│ ├── services/ # Business logic
│ ├── handler/ # HTTP handlers
│ ├── middleware/ # HTTP middleware
│ └── utils/ # Utility functions
├── doc/ # Design documentation
│ ├── features.md # Feature specifications
│ ├── design-system.md # Visual design system
│ ├── wireframes.md # UI wireframes
│ ├── user-flows.md # User interaction flows
│ └── layout-design.md # Layout specifications
├── main.go # Application entry point
└── go.mod # Go dependencies
```
## 🚦 Getting Started
### Prerequisites
- **Go** 1.23 or higher
- **Node.js** 18 or higher
- **Wails CLI** (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`)
### Installation
1. **Clone the repository**
```bash
git clone https://github.com/your-org/uzdb.git
cd uzdb
```
2. **Install Go dependencies**
```bash
go mod tidy
```
3. **Install frontend dependencies**
```bash
cd frontend
npm install
cd ..
```
### Development
#### Option 1: Run with Wails Dev Server (Recommended)
```bash
wails dev
```
This starts both the Go backend and React frontend with hot reload.
#### Option 2: Run Components Separately
**Frontend only:**
```bash
cd frontend
npm run dev
```
**Backend HTTP API only:**
```bash
go run . -http-port 8080
```
Then access API at `http://localhost:8080/api`
### Building for Production
```bash
wails build
```
The compiled binary will be in `build/bin/`.
## 📖 Usage
### Connecting to a Database
1. Click **"New Connection"** in the left sidebar
2. Select database type (MySQL, PostgreSQL, or SQLite)
3. Fill in connection details:
- **Name**: Friendly name for the connection
- **Host**: Database server hostname
- **Port**: Database port (default: 3306 for MySQL, 5432 for PostgreSQL)
- **Username/Password**: Authentication credentials
- **Database**: Default database name
4. Click **"Test Connection"** to verify
5. Click **"Save & Connect"**
### Executing Queries
1. Select a connection from the left sidebar
2. Open a new query tab (Ctrl+T)
3. Write your SQL query
4. Press **Ctrl+Enter** or click **Run** to execute
5. View results in the data grid below
### Keyboard Shortcuts
| Action | Shortcut |
|--------|----------|
| New Connection | Ctrl+N |
| New Query Tab | Ctrl+T |
| Execute Query | Ctrl+Enter |
| Save Query | Ctrl+S |
| Find | Ctrl+F |
| Close Tab | Ctrl+W |
| Settings | Ctrl+, |
## 🔌 API Reference
The application exposes both Wails bindings (for the desktop app) and HTTP API (for debugging/integration).
### Wails Bindings (JavaScript)
```javascript
// Get all connections
const connections = await window.go.app.GetConnections();
// Execute a query
const result = await window.go.app.ExecuteQuery(connectionId, sql);
// Get table data
const data = await window.go.app.GetTableData(connectionId, tableName, limit, offset);
```
### HTTP API
See [API_TEST.md](./API_TEST.md) for detailed API documentation.
## 🔒 Security
- All database passwords are encrypted using AES-256-GCM before storage
- Encryption key is derived from a master password (future feature) or system keyring
- No credentials stored in plain text
- SSL/TLS support for database connections
## 📊 Supported Databases
| Database | Version | Status |
|----------|---------|--------|
| MySQL | 5.7+, 8.0+ | ✅ Supported |
| PostgreSQL | 12+ | ✅ Supported |
| SQLite | 3.x | ✅ Supported |
| MariaDB | 10.3+ | 🔄 Planned |
| SQL Server | 2019+ | 🔄 Planned |
## 🤝 Contributing
Contributions are welcome! Please follow these steps:
1. Fork the repository
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
3. Commit your changes (`git commit -m 'Add amazing feature'`)
4. Push to the branch (`git push origin feature/amazing-feature`)
5. Open a Pull Request
### Development Guidelines
- Follow existing code style
- Add tests for new features
- Update documentation as needed
- Ensure linting passes (`go vet`, `npm run lint`)
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- [Wails](https://wails.io/) - Desktop framework
- [Gin](https://gin-gonic.com/) - Web framework
- [GORM](https://gorm.io/) - ORM library
- [React](https://react.dev/) - UI library
- [DBeaver](https://dbeaver.io/) - Inspiration
- [Navicat](https://www.navicat.com/) - Inspiration
## 📞 Support
- **Issues**: [GitHub Issues](https://github.com/your-org/uzdb/issues)
- **Discussions**: [GitHub Discussions](https://github.com/your-org/uzdb/discussions)
- **Documentation**: `/doc` directory
---
**Built with ❤️ using Go + React**

27
uzdb/app.go Normal file
View File

@@ -0,0 +1,27 @@
package main
import (
"context"
"fmt"
)
// App struct
type App struct {
ctx context.Context
}
// NewApp creates a new App application struct
func NewApp() *App {
return &App{}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}

5
uzdb/build.sh Normal file
View File

@@ -0,0 +1,5 @@
# Build the application for development
go build -tags dev -o uzdb.exe
# Or run directly with Wails dev server
wails dev

35
uzdb/build/README.md Normal file
View File

@@ -0,0 +1,35 @@
# Build Directory
The build directory is used to house all the build files and assets for your application.
The structure is:
* bin - Output directory
* darwin - macOS specific files
* windows - Windows specific files
## Mac
The `darwin` directory holds files specific to Mac builds.
These may be customised and used as part of the build. To return these files to the default state, simply delete them
and
build with `wails build`.
The directory contains the following files:
- `Info.plist` - the main plist file used for Mac builds. It is used when building using `wails build`.
- `Info.dev.plist` - same as the main plist file but used when building using `wails dev`.
## Windows
The `windows` directory contains the manifest and rc files used when building with `wails build`.
These may be customised for your application. To return these files to the default state, simply delete them and
build with `wails build`.
- `icon.ico` - The icon used for the application. This is used when building using `wails build`. If you wish to
use a different icon, simply replace this file with your own. If it is missing, a new `icon.ico` file
will be created using the `appicon.png` file in the build directory.
- `installer/*` - The files used to create the Windows installer. These are used when building using `wails build`.
- `info.json` - Application details used for Windows builds. The data here will be used by the Windows installer,
as well as the application itself (right click the exe -> properties -> details)
- `wails.exe.manifest` - The main application manifest file.

BIN
uzdb/build/appicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -0,0 +1,68 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,63 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>{{.Info.ProductName}}</string>
<key>CFBundleExecutable</key>
<string>{{.OutputFilename}}</string>
<key>CFBundleIdentifier</key>
<string>com.wails.{{.Name}}</string>
<key>CFBundleVersion</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleGetInfoString</key>
<string>{{.Info.Comments}}</string>
<key>CFBundleShortVersionString</key>
<string>{{.Info.ProductVersion}}</string>
<key>CFBundleIconFile</key>
<string>iconfile</string>
<key>LSMinimumSystemVersion</key>
<string>10.13.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>
{{range .Info.FileAssociations}}
<dict>
<key>CFBundleTypeExtensions</key>
<array>
<string>{{.Ext}}</string>
</array>
<key>CFBundleTypeName</key>
<string>{{.Name}}</string>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
<key>CFBundleTypeIconFile</key>
<string>{{.IconName}}</string>
</dict>
{{end}}
</array>
{{end}}
{{if .Info.Protocols}}
<key>CFBundleURLTypes</key>
<array>
{{range .Info.Protocols}}
<dict>
<key>CFBundleURLName</key>
<string>com.wails.{{.Scheme}}</string>
<key>CFBundleURLSchemes</key>
<array>
<string>{{.Scheme}}</string>
</array>
<key>CFBundleTypeRole</key>
<string>{{.Role}}</string>
</dict>
{{end}}
</array>
{{end}}
</dict>
</plist>

BIN
uzdb/build/windows/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,15 @@
{
"fixed": {
"file_version": "{{.Info.ProductVersion}}"
},
"info": {
"0000": {
"ProductVersion": "{{.Info.ProductVersion}}",
"CompanyName": "{{.Info.CompanyName}}",
"FileDescription": "{{.Info.ProductName}}",
"LegalCopyright": "{{.Info.Copyright}}",
"ProductName": "{{.Info.ProductName}}",
"Comments": "{{.Info.Comments}}"
}
}
}

View File

@@ -0,0 +1,114 @@
Unicode true
####
## Please note: Template replacements don't work in this file. They are provided with default defines like
## mentioned underneath.
## If the keyword is not defined, "wails_tools.nsh" will populate them with the values from ProjectInfo.
## If they are defined here, "wails_tools.nsh" will not touch them. This allows to use this project.nsi manually
## from outside of Wails for debugging and development of the installer.
##
## For development first make a wails nsis build to populate the "wails_tools.nsh":
## > wails build --target windows/amd64 --nsis
## Then you can call makensis on this file with specifying the path to your binary:
## For a AMD64 only installer:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app.exe
## For a ARM64 only installer:
## > makensis -DARG_WAILS_ARM64_BINARY=..\..\bin\app.exe
## For a installer with both architectures:
## > makensis -DARG_WAILS_AMD64_BINARY=..\..\bin\app-amd64.exe -DARG_WAILS_ARM64_BINARY=..\..\bin\app-arm64.exe
####
## The following information is taken from the ProjectInfo file, but they can be overwritten here.
####
## !define INFO_PROJECTNAME "MyProject" # Default "{{.Name}}"
## !define INFO_COMPANYNAME "MyCompany" # Default "{{.Info.CompanyName}}"
## !define INFO_PRODUCTNAME "MyProduct" # Default "{{.Info.ProductName}}"
## !define INFO_PRODUCTVERSION "1.0.0" # Default "{{.Info.ProductVersion}}"
## !define INFO_COPYRIGHT "Copyright" # Default "{{.Info.Copyright}}"
###
## !define PRODUCT_EXECUTABLE "Application.exe" # Default "${INFO_PROJECTNAME}.exe"
## !define UNINST_KEY_NAME "UninstKeyInRegistry" # Default "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
####
## !define REQUEST_EXECUTION_LEVEL "admin" # Default "admin" see also https://nsis.sourceforge.io/Docs/Chapter4.html
####
## Include the wails tools
####
!include "wails_tools.nsh"
# The version information for this two must consist of 4 parts
VIProductVersion "${INFO_PRODUCTVERSION}.0"
VIFileVersion "${INFO_PRODUCTVERSION}.0"
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
ManifestDPIAware true
!include "MUI.nsh"
!define MUI_ICON "..\icon.ico"
!define MUI_UNICON "..\icon.ico"
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
!insertmacro MUI_PAGE_INSTFILES # Installing page.
!insertmacro MUI_PAGE_FINISH # Finished installation page.
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
!insertmacro MUI_LANGUAGE "English" # Set the Language of the installer
## The following two statements can be used to sign the installer and the uninstaller. The path to the binaries are provided in %1
#!uninstfinalize 'signtool --file "%1"'
#!finalize 'signtool --file "%1"'
Name "${INFO_PRODUCTNAME}"
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
InstallDir "$PROGRAMFILES64\${INFO_COMPANYNAME}\${INFO_PRODUCTNAME}" # Default installing folder ($PROGRAMFILES is Program Files folder).
ShowInstDetails show # This will always show the installation details.
Function .onInit
!insertmacro wails.checkArchitecture
FunctionEnd
Section
!insertmacro wails.setShellContext
!insertmacro wails.webview2runtime
SetOutPath $INSTDIR
!insertmacro wails.files
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro wails.writeUninstaller
SectionEnd
Section "uninstall"
!insertmacro wails.setShellContext
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
RMDir /r $INSTDIR
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
!insertmacro wails.unassociateFiles
!insertmacro wails.unassociateCustomProtocols
!insertmacro wails.deleteUninstaller
SectionEnd

View File

@@ -0,0 +1,249 @@
# DO NOT EDIT - Generated automatically by `wails build`
!include "x64.nsh"
!include "WinVer.nsh"
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "{{.Name}}"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "{{.Info.CompanyName}}"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "{{.Info.ProductName}}"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "{{.Info.ProductVersion}}"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "{{.Info.Copyright}}"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
!endif
!ifndef UNINST_KEY_NAME
!define UNINST_KEY_NAME "${INFO_COMPANYNAME}${INFO_PRODUCTNAME}"
!endif
!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UNINST_KEY_NAME}"
!ifndef REQUEST_EXECUTION_LEVEL
!define REQUEST_EXECUTION_LEVEL "admin"
!endif
RequestExecutionLevel "${REQUEST_EXECUTION_LEVEL}"
!ifdef ARG_WAILS_AMD64_BINARY
!define SUPPORTS_AMD64
!endif
!ifdef ARG_WAILS_ARM64_BINARY
!define SUPPORTS_ARM64
!endif
!ifdef SUPPORTS_AMD64
!ifdef SUPPORTS_ARM64
!define ARCH "amd64_arm64"
!else
!define ARCH "amd64"
!endif
!else
!ifdef SUPPORTS_ARM64
!define ARCH "arm64"
!else
!error "Wails: Undefined ARCH, please provide at least one of ARG_WAILS_AMD64_BINARY or ARG_WAILS_ARM64_BINARY"
!endif
!endif
!macro wails.checkArchitecture
!ifndef WAILS_WIN10_REQUIRED
!define WAILS_WIN10_REQUIRED "This product is only supported on Windows 10 (Server 2016) and later."
!endif
!ifndef WAILS_ARCHITECTURE_NOT_SUPPORTED
!define WAILS_ARCHITECTURE_NOT_SUPPORTED "This product can't be installed on the current Windows architecture. Supports: ${ARCH}"
!endif
${If} ${AtLeastWin10}
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
Goto ok
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
Goto ok
${EndIf}
!endif
IfSilent silentArch notSilentArch
silentArch:
SetErrorLevel 65
Abort
notSilentArch:
MessageBox MB_OK "${WAILS_ARCHITECTURE_NOT_SUPPORTED}"
Quit
${else}
IfSilent silentWin notSilentWin
silentWin:
SetErrorLevel 64
Abort
notSilentWin:
MessageBox MB_OK "${WAILS_WIN10_REQUIRED}"
Quit
${EndIf}
ok:
!macroend
!macro wails.files
!ifdef SUPPORTS_AMD64
${if} ${IsNativeAMD64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_AMD64_BINARY}"
${EndIf}
!endif
!ifdef SUPPORTS_ARM64
${if} ${IsNativeARM64}
File "/oname=${PRODUCT_EXECUTABLE}" "${ARG_WAILS_ARM64_BINARY}"
${EndIf}
!endif
!macroend
!macro wails.writeUninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
SetRegView 64
WriteRegStr HKLM "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
WriteRegStr HKLM "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
WriteRegStr HKLM "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKLM "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
IntFmt $0 "0x%08X" $0
WriteRegDWORD HKLM "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
!macro wails.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
SetRegView 64
DeleteRegKey HKLM "${UNINST_KEY}"
!macroend
!macro wails.setShellContext
${If} ${REQUEST_EXECUTION_LEVEL} == "admin"
SetShellVarContext all
${else}
SetShellVarContext current
${EndIf}
!macroend
# Install webview2 by launching the bootstrapper
# See https://docs.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution#online-only-deployment
!macro wails.webview2runtime
!ifndef WAILS_INSTALL_WEBVIEW_DETAILPRINT
!define WAILS_INSTALL_WEBVIEW_DETAILPRINT "Installing: WebView2 Runtime"
!endif
SetRegView 64
# If the admin key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${If} ${REQUEST_EXECUTION_LEVEL} == "user"
# If the installer is run in user level, check the user specific key exists and is not empty then webview2 is already installed
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
${If} $0 != ""
Goto ok
${EndIf}
${EndIf}
SetDetailsPrint both
DetailPrint "${WAILS_INSTALL_WEBVIEW_DETAILPRINT}"
SetDetailsPrint listonly
InitPluginsDir
CreateDirectory "$pluginsdir\webview2bootstrapper"
SetOutPath "$pluginsdir\webview2bootstrapper"
File "tmp\MicrosoftEdgeWebview2Setup.exe"
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
SetDetailsPrint both
ok:
!macroend
# Copy of APP_ASSOCIATE and APP_UNASSOCIATE macros from here https://gist.github.com/nikku/281d0ef126dbc215dd58bfd5b3a5cd5b
!macro APP_ASSOCIATE EXT FILECLASS DESCRIPTION ICON COMMANDTEXT COMMAND
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "${FILECLASS}_backup" "$R0"
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "${FILECLASS}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}" "" `${DESCRIPTION}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\DefaultIcon" "" `${ICON}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell" "" "open"
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open" "" `${COMMANDTEXT}`
WriteRegStr SHELL_CONTEXT "Software\Classes\${FILECLASS}\shell\open\command" "" `${COMMAND}`
!macroend
!macro APP_UNASSOCIATE EXT FILECLASS
; Backup the previously associated file class
ReadRegStr $R0 SHELL_CONTEXT "Software\Classes\.${EXT}" `${FILECLASS}_backup`
WriteRegStr SHELL_CONTEXT "Software\Classes\.${EXT}" "" "$R0"
DeleteRegKey SHELL_CONTEXT `Software\Classes\${FILECLASS}`
!macroend
!macro wails.associateFiles
; Create file associations
{{range .Info.FileAssociations}}
!insertmacro APP_ASSOCIATE "{{.Ext}}" "{{.Name}}" "{{.Description}}" "$INSTDIR\{{.IconName}}.ico" "Open with ${INFO_PRODUCTNAME}" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
File "..\{{.IconName}}.ico"
{{end}}
!macroend
!macro wails.unassociateFiles
; Delete app associations
{{range .Info.FileAssociations}}
!insertmacro APP_UNASSOCIATE "{{.Ext}}" "{{.Name}}"
Delete "$INSTDIR\{{.IconName}}.ico"
{{end}}
!macroend
!macro CUSTOM_PROTOCOL_ASSOCIATE PROTOCOL DESCRIPTION ICON COMMAND
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "" "${DESCRIPTION}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}" "URL Protocol" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\DefaultIcon" "" "${ICON}"
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open" "" ""
WriteRegStr SHELL_CONTEXT "Software\Classes\${PROTOCOL}\shell\open\command" "" "${COMMAND}"
!macroend
!macro CUSTOM_PROTOCOL_UNASSOCIATE PROTOCOL
DeleteRegKey SHELL_CONTEXT "Software\Classes\${PROTOCOL}"
!macroend
!macro wails.associateCustomProtocols
; Create custom protocols associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_ASSOCIATE "{{.Scheme}}" "{{.Description}}" "$INSTDIR\${PRODUCT_EXECUTABLE},0" "$INSTDIR\${PRODUCT_EXECUTABLE} $\"%1$\""
{{end}}
!macroend
!macro wails.unassociateCustomProtocols
; Delete app custom protocol associations
{{range .Info.Protocols}}
!insertmacro CUSTOM_PROTOCOL_UNASSOCIATE "{{.Scheme}}"
{{end}}
!macroend

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.wails.{{.Name}}" version="{{.Info.ProductVersion}}.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
</dependentAssembly>
</dependency>
<asmv3:application>
<asmv3:windowsSettings>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware> <!-- fallback for Windows 7 and 8 -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">permonitorv2,permonitor</dpiAwareness> <!-- falls back to per-monitor if per-monitor v2 is not supported -->
</asmv3:windowsSettings>
</asmv3:application>
</assembly>

13
uzdb/frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>uzdb</title>
</head>
<body>
<div id="root"></div>
<script src="./src/main.tsx" type="module"></script>
</body>
</html>

2121
uzdb/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1",
"typescript": "^4.6.4",
"vite": "^3.0.7"
}
}

1
uzdb/frontend/package.json.md5 Executable file
View File

@@ -0,0 +1 @@
f26173c7304a0bf8ea5c86eb567e7db2

105
uzdb/frontend/src/App.css Normal file
View File

@@ -0,0 +1,105 @@
/**
* App Component Styles
*/
.main-content {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
/* View Tabs */
.view-tabs {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-4) 0;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.view-tab {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition-fast) var(--ease-in-out);
white-space: nowrap;
}
.view-tab:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
.view-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-icon {
font-size: var(--text-sm);
}
/* View Content */
.view-content {
flex: 1;
overflow: hidden;
position: relative;
}
/* Global scrollbar improvements */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-left: 1px solid var(--border);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border: 2px solid var(--bg-secondary);
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* Focus visible improvements */
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Selection color */
::selection {
background-color: rgba(59, 130, 246, 0.2);
color: var(--text-primary);
}
/* Print styles */
@media print {
.view-tabs,
.layout-menubar,
.layout-toolbar,
.layout-sidebar,
.layout-statusbar {
display: none !important;
}
.view-content {
overflow: visible !important;
}
}

307
uzdb/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,307 @@
/**
* uzdb - Database Management Tool
*
* Main application component integrating all UI components.
*/
import { useState, useCallback } from 'react';
import './index.css';
// Import components
import AppLayout from './components/Layout/AppLayout';
import MenuBar from './components/MenuBar/MenuBar';
import ToolBar from './components/Layout/ToolBar';
import StatusBar from './components/Layout/StatusBar';
import ConnectionPanel, { DatabaseConnection } from './components/Sidebar/ConnectionPanel';
import QueryEditor, { QueryTab, QueryResult } from './components/MainArea/QueryEditor';
import DataGrid from './components/MainArea/DataGrid';
import TableStructure from './components/MainArea/TableStructure';
// Import mock data
import { mockConnections } from './mock/connections';
import {
mockQueryResults,
mockQueryTabs,
mockDataGridColumns,
mockDataGridRows,
mockTableColumns,
mockIndexes,
mockForeignKeys,
mockTableInfo,
} from './mock/queryResults';
type MainView = 'query' | 'data' | 'structure';
function App() {
// Application state
const [connections] = useState<DatabaseConnection[]>(mockConnections);
const [activeConnectionId, setActiveConnectionId] = useState<string>('conn-1');
const [selectedConnectionId, setSelectedConnectionId] = useState<string>('conn-1');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Query editor state
const [queryTabs, setQueryTabs] = useState<QueryTab[]>(mockQueryTabs);
const [activeTabId, setActiveTabId] = useState<string>('tab-1');
const [queryResults, setQueryResults] = useState<QueryResult | null>(null);
const [isQueryLoading, setIsQueryLoading] = useState(false);
// Main view state
const [mainView, setMainView] = useState<MainView>('query');
// Handler: Connection click
const handleConnectionClick = useCallback((connection: DatabaseConnection) => {
setSelectedConnectionId(connection.id);
if (connection.status === 'disconnected') {
console.log(`Connecting to ${connection.name}...`);
// In real app: call backend to connect
} else if (connection.status === 'connected' || connection.status === 'active') {
setActiveConnectionId(connection.id);
}
}, []);
// Handler: New connection
const handleNewConnection = useCallback(() => {
console.log('Opening new connection dialog...');
// In real app: open connection dialog
}, []);
// Handler: Table double-click
const handleTableDoubleClick = useCallback((
table: any,
schema: any,
connection: DatabaseConnection
) => {
console.log(`Opening table ${table.name} from schema ${schema.name}`);
setMainView('data');
}, []);
// Handler: Query execution
const handleExecuteQuery = useCallback((query: string) => {
setIsQueryLoading(true);
console.log('Executing query:', query);
// Simulate async query execution
setTimeout(() => {
setQueryResults(mockQueryResults);
setIsQueryLoading(false);
}, 500);
}, []);
// Handler: Tab content change
const handleContentChange = useCallback((tabId: string, content: string) => {
setQueryTabs(prev => prev.map(tab =>
tab.id === tabId ? { ...tab, content, isDirty: true } : tab
));
}, []);
// Handler: Save query
const handleSaveQuery = useCallback((tabId: string) => {
console.log('Saving query:', tabId);
setQueryTabs(prev => prev.map(tab =>
tab.id === tabId ? { ...tab, isDirty: false } : tab
));
}, []);
// Handler: Close tab
const handleCloseTab = useCallback((tabId: string) => {
setQueryTabs(prev => prev.filter(tab => tab.id !== tabId));
if (activeTabId === tabId) {
setActiveTabId(queryTabs[0]?.id || '');
}
}, [activeTabId, queryTabs]);
// Handler: New tab
const handleNewTab = useCallback(() => {
const newTab: QueryTab = {
id: `tab-${Date.now()}`,
title: 'untitled.sql',
content: '',
isDirty: false,
};
setQueryTabs(prev => [...prev, newTab]);
setActiveTabId(newTab.id);
}, []);
// Handler: Format SQL
const handleFormatSQL = useCallback(() => {
console.log('Formatting SQL...');
// In real app: format SQL using sql-formatter
}, []);
// Handler: Menu item click
const handleMenuItemClick = useCallback((menuId: string, itemId: string) => {
console.log(`Menu "${menuId}" -> Item "${itemId}"`);
switch (itemId) {
case 'new-connection':
handleNewConnection();
break;
case 'run-query':
if (activeTabId) {
const tab = queryTabs.find(t => t.id === activeTabId);
if (tab) handleExecuteQuery(tab.content);
}
break;
case 'toggle-sidebar':
setSidebarCollapsed(prev => !prev);
break;
default:
console.log('Unhandled menu item:', itemId);
}
}, [activeTabId, queryTabs, handleNewConnection, handleExecuteQuery]);
// Get active connection
const activeConnection = connections.find(c => c.id === activeConnectionId);
return (
<AppLayout
sidebarCollapsed={sidebarCollapsed}
onSidebarToggle={setSidebarCollapsed}
menuBar={
<MenuBar
title="uzdb"
onMenuItemClick={handleMenuItemClick}
/>
}
toolbar={
<ToolBar
buttons={[
{
id: 'run',
icon: '▶',
label: 'Run',
tooltip: 'Execute query (Ctrl+Enter)',
onClick: () => {
if (activeTabId) {
const tab = queryTabs.find(t => t.id === activeTabId);
if (tab) handleExecuteQuery(tab.content);
}
},
},
{
id: 'save',
icon: '💾',
label: 'Save',
tooltip: 'Save query (Ctrl+S)',
onClick: () => activeTabId && handleSaveQuery(activeTabId),
disabled: !queryTabs.find(t => t.id === activeTabId)?.isDirty,
},
{
id: 'export',
icon: '📤',
label: 'Export',
tooltip: 'Export results',
disabled: !queryResults,
},
{
id: 'find',
icon: '🔍',
label: 'Find',
tooltip: 'Find in query (Ctrl+F)',
},
]}
/>
}
sidebar={
<ConnectionPanel
connections={connections}
selectedConnectionId={selectedConnectionId}
activeConnectionId={activeConnectionId}
onConnectionClick={handleConnectionClick}
onNewConnection={handleNewConnection}
onTableDoubleClick={handleTableDoubleClick}
/>
}
mainContent={
<div className="main-content">
{/* View tabs */}
<div className="view-tabs">
<button
className={`view-tab ${mainView === 'query' ? 'active' : ''}`}
onClick={() => setMainView('query')}
>
📑 SQL Editor
</button>
<button
className={`view-tab ${mainView === 'data' ? 'active' : ''}`}
onClick={() => setMainView('data')}
>
📊 Data Grid
</button>
<button
className={`view-tab ${mainView === 'structure' ? 'active' : ''}`}
onClick={() => setMainView('structure')}
>
📋 Table Structure
</button>
</div>
{/* Main view content */}
<div className="view-content">
{mainView === 'query' && (
<QueryEditor
tabs={queryTabs}
activeTabId={activeTabId}
results={queryResults}
isLoading={isQueryLoading}
onTabClick={setActiveTabId}
onCloseTab={handleCloseTab}
onNewTab={handleNewTab}
onContentChange={handleContentChange}
onExecute={handleExecuteQuery}
onSave={handleSaveQuery}
onFormat={handleFormatSQL}
/>
)}
{mainView === 'data' && (
<DataGrid
columns={mockDataGridColumns}
rows={mockDataGridRows}
totalRows={1247}
pagination={{ currentPage: 1, pageSize: 25, totalRows: 1247 }}
selectable
editable
tableName="users"
schemaName="public"
onRefresh={() => console.log('Refreshing data...')}
onExport={() => console.log('Exporting data...')}
onAddRow={() => console.log('Adding row...')}
/>
)}
{mainView === 'structure' && (
<TableStructure
tableName="users"
schemaName="public"
connectionName={activeConnection?.name}
columns={mockTableColumns}
indexes={mockIndexes}
foreignKeys={mockForeignKeys}
tableInfo={mockTableInfo}
onViewData={() => setMainView('data')}
onEditTable={() => console.log('Edit table...')}
onRefresh={() => console.log('Refreshing structure...')}
/>
)}
</div>
</div>
}
statusBar={
<StatusBar
connectionInfo={activeConnection?.status === 'active'
? `✓ Connected to ${activeConnection.name}`
: 'Ready'
}
queryInfo={queryResults ? `${queryResults.rowCount} rows in ${queryResults.executionTime}s` : undefined}
statusType={queryResults?.error ? 'error' : queryResults ? 'success' : 'normal'}
encoding="UTF-8"
lineEnding="LF"
editorMode="ins"
/>
}
/>
);
}
export default App;

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -0,0 +1,134 @@
/**
* AppLayout Component Styles
*/
.app-layout {
display: flex;
flex-direction: column;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* Menu Bar */
.layout-menubar {
flex-shrink: 0;
height: var(--menubar-height);
}
/* Toolbar */
.layout-toolbar {
flex-shrink: 0;
height: var(--toolbar-height);
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
/* Main area (sidebar + workspace) */
.layout-main {
display: flex;
flex: 1;
overflow: hidden;
}
/* Sidebar */
.layout-sidebar {
flex-shrink: 0;
height: 100%;
overflow: hidden;
transition: width var(--transition-normal) var(--ease-in-out);
}
.layout-sidebar.collapsed {
width: 0;
overflow: hidden;
}
/* Resize handle */
.layout-resize-handle {
width: 4px;
cursor: col-resize;
background-color: transparent;
transition: background-color var(--transition-fast) var(--ease-in-out);
flex-shrink: 0;
z-index: 10;
}
.layout-resize-handle:hover {
background-color: var(--primary);
}
.layout-resize-handle:active {
background-color: var(--primary-active);
}
/* Sidebar toggle button */
.layout-sidebar-toggle {
width: 48px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--space-2);
gap: var(--space-2);
flex-shrink: 0;
}
.toggle-button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
}
/* Workspace */
.layout-workspace {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background-color: var(--bg-primary);
}
/* Status Bar */
.layout-statusbar {
flex-shrink: 0;
height: var(--statusbar-height);
background-color: var(--bg-secondary);
border-top: 1px solid var(--border);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.layout-sidebar:not(.collapsed) {
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
box-shadow: var(--shadow-lg);
}
.layout-resize-handle {
display: none;
}
}
/* Animation for sidebar transitions */
@keyframes slideInLeft {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.layout-sidebar:not(.collapsed) {
animation: fadeIn 0.2s ease-out;
}

View File

@@ -0,0 +1,155 @@
/**
* AppLayout Component
*
* Main application layout with menu bar, toolbar, sidebar, main content area, and status bar.
* Based on layout-design.md "整体布局架构"
*/
import React, { useState, useCallback } from 'react';
import './AppLayout.css';
export interface AppLayoutProps {
/** Menu bar component */
menuBar?: React.ReactNode;
/** Toolbar component */
toolbar?: React.ReactNode;
/** Sidebar/connection panel component */
sidebar?: React.ReactNode;
/** Main content area component */
mainContent?: React.ReactNode;
/** Status bar component */
statusBar?: React.ReactNode;
/** Sidebar collapsed state */
sidebarCollapsed?: boolean;
/** Handler when sidebar collapse state changes */
onSidebarToggle?: (collapsed: boolean) => void;
/** Children (alternative to explicit props) */
children?: React.ReactNode;
}
/**
* AppLayout - Main application shell component
*/
export const AppLayout: React.FC<AppLayoutProps> = ({
menuBar,
toolbar,
sidebar,
mainContent,
statusBar,
sidebarCollapsed = false,
onSidebarToggle,
children,
}) => {
const [isResizing, setIsResizing] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(240);
const [isCollapsed, setIsCollapsed] = useState(sidebarCollapsed);
// Handle sidebar resize start
const handleResizeStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
}, []);
// Handle mouse move for resizing
useCallback(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const newWidth = Math.max(180, Math.min(400, e.clientX));
setSidebarWidth(newWidth);
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isResizing]);
// Toggle sidebar collapse
const toggleSidebar = useCallback(() => {
const newCollapsed = !isCollapsed;
setIsCollapsed(newCollapsed);
onSidebarToggle?.(newCollapsed);
}, [isCollapsed, onSidebarToggle]);
// Expose toggle function via custom event for keyboard shortcuts
React.useEffect(() => {
const handleToggleRequest = () => toggleSidebar();
window.addEventListener('toggle-sidebar', handleToggleRequest);
return () => window.removeEventListener('toggle-sidebar', handleToggleRequest);
}, [toggleSidebar]);
return (
<div className="app-layout">
{/* Menu Bar */}
{menuBar && <div className="layout-menubar">{menuBar}</div>}
{/* Toolbar */}
{toolbar && <div className="layout-toolbar">{toolbar}</div>}
{/* Main content area (sidebar + workspace) */}
<div className="layout-main">
{/* Sidebar */}
{sidebar && (
<>
<div
className={`layout-sidebar ${isCollapsed ? 'collapsed' : ''}`}
style={
isCollapsed
? undefined
: { width: sidebarWidth, flex: 'none' }
}
>
{sidebar}
</div>
{/* Resize handle */}
{!isCollapsed && (
<div
className="layout-resize-handle"
onMouseDown={handleResizeStart}
role="separator"
aria-orientation="vertical"
tabIndex={0}
title="Drag to resize sidebar"
/>
)}
{/* Collapse toggle button (when collapsed) */}
{isCollapsed && sidebar && (
<div className="layout-sidebar-toggle">
<button
className="btn-icon toggle-button"
onClick={toggleSidebar}
title="Expand sidebar"
aria-label="Expand sidebar"
>
</button>
</div>
)}
</>
)}
{/* Workspace (main content) */}
<div className="layout-workspace">
{mainContent || children}
</div>
</div>
{/* Status Bar */}
{statusBar && <div className="layout-statusbar">{statusBar}</div>}
</div>
);
};
export default AppLayout;

View File

@@ -0,0 +1,120 @@
/**
* StatusBar Component Styles
*/
.statusbar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--statusbar-height);
padding: 0 var(--space-4);
background-color: var(--bg-secondary);
border-top: 1px solid var(--border);
font-size: var(--text-xs);
flex-shrink: 0;
}
/* Status type variations */
.statusbar.status-normal {
background-color: var(--bg-secondary);
color: var(--text-secondary);
}
.statusbar.status-success {
background-color: #d1fae5;
color: #065f46;
}
.statusbar.status-warning {
background-color: #fef3c7;
color: #92400e;
}
.statusbar.status-error {
background-color: #fee2e2;
color: #991b1b;
}
.statusbar.status-info {
background-color: #dbeafe;
color: #1e40af;
}
/* Left section */
.statusbar-left {
display: flex;
align-items: center;
gap: var(--space-4);
overflow: hidden;
}
.status-icon {
font-size: var(--text-sm);
font-weight: bold;
}
.status-item {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.connection-info {
color: inherit;
}
.query-info {
font-weight: 500;
}
/* Right section */
.statusbar-right {
display: flex;
align-items: center;
gap: var(--space-4);
}
.status-button {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-family: var(--font-sans);
color: inherit;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast) var(--ease-in-out);
}
.status-button:hover {
background-color: var(--bg-tertiary);
border-color: var(--border);
}
.editor-mode {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
min-width: 30px;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.statusbar {
padding: 0 var(--space-2);
font-size: 11px;
}
.statusbar-left {
gap: var(--space-2);
}
.statusbar-right {
gap: var(--space-2);
}
.status-button {
padding: var(--space-1);
}
}

View File

@@ -0,0 +1,126 @@
/**
* StatusBar Component
*
* Bottom status bar showing connection info, encoding, and editor mode.
* Based on layout-design.md section "状态栏设计规范"
*/
import React from 'react';
import './StatusBar.css';
export type StatusType = 'normal' | 'success' | 'warning' | 'error' | 'info';
export interface StatusBarProps {
/** Connection status message */
connectionInfo?: string;
/** Query execution info */
queryInfo?: string;
/** Encoding format */
encoding?: string;
/** Line ending type */
lineEnding?: 'LF' | 'CRLF';
/** Editor mode */
editorMode?: 'ins' | 'ovr';
/** Status type for coloring */
statusType?: StatusType;
/** Handler when encoding is clicked */
onEncodingClick?: () => void;
/** Handler when line ending is clicked */
onLineEndingClick?: () => void;
}
/**
* StatusBar component
*/
export const StatusBar: React.FC<StatusBarProps> = ({
connectionInfo,
queryInfo,
encoding = 'UTF-8',
lineEnding = 'LF',
editorMode = 'ins',
statusType = 'normal',
onEncodingClick,
onLineEndingClick,
}) => {
// Get status class based on type
const getStatusClass = (): string => {
switch (statusType) {
case 'success':
return 'status-success';
case 'warning':
return 'status-warning';
case 'error':
return 'status-error';
case 'info':
return 'status-info';
default:
return 'status-normal';
}
};
// Get status icon
const getStatusIcon = (): string => {
switch (statusType) {
case 'success':
return '✓';
case 'warning':
return '⚠';
case 'error':
return '✕';
case 'info':
return '';
default:
return '';
}
};
return (
<div className={`statusbar ${getStatusClass()}`}>
<div className="statusbar-left">
{/* Status icon and message */}
{getStatusIcon() && (
<span className="status-icon" role="status" aria-label={statusType}>
{getStatusIcon()}
</span>
)}
{/* Connection info */}
{connectionInfo && (
<span className="status-item connection-info">{connectionInfo}</span>
)}
{/* Query info (takes priority over connection info when present) */}
{queryInfo && (
<span className="status-item query-info">{queryInfo}</span>
)}
</div>
<div className="statusbar-right">
{/* Encoding */}
<button
className="status-button"
onClick={onEncodingClick}
title="Change encoding"
>
{encoding}
</button>
{/* Line ending */}
<button
className="status-button"
onClick={onLineEndingClick}
title="Change line ending"
>
{lineEnding}
</button>
{/* Editor mode */}
<span className="status-item editor-mode" title={editorMode === 'ins' ? 'Insert' : 'Overwrite'}>
{editorMode}
</span>
</div>
</div>
);
};
export default StatusBar;

View File

@@ -0,0 +1,124 @@
/**
* ToolBar Component Styles
*/
.toolbar {
display: flex;
align-items: center;
height: var(--toolbar-height);
padding: 0 var(--space-4);
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
gap: var(--space-4);
flex-shrink: 0;
}
.toolbar-buttons {
display: flex;
gap: var(--space-1);
}
.toolbar-button {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-primary);
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast) var(--ease-in-out);
white-space: nowrap;
}
.toolbar-button:hover:not(.disabled) {
background-color: var(--bg-tertiary);
border-color: var(--border);
}
.toolbar-button.disabled {
color: var(--text-muted);
cursor: not-allowed;
}
.button-icon {
font-size: var(--text-base);
}
.button-label {
font-weight: 500;
}
.toolbar-content {
display: flex;
align-items: center;
gap: var(--space-2);
}
.toolbar-spacer {
flex: 1;
}
/* Connection indicator */
.toolbar-connection {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast) var(--ease-in-out);
}
.toolbar-connection:hover {
background-color: var(--bg-tertiary);
border-color: var(--text-secondary);
}
.connection-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--text-muted);
}
.connection-status-dot.connected {
background-color: var(--success);
box-shadow: 0 0 4px var(--success);
}
.connection-name {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-primary);
}
.dropdown-arrow {
font-size: var(--text-xs);
color: var(--text-muted);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.toolbar {
padding: 0 var(--space-2);
gap: var(--space-2);
}
.toolbar-button {
padding: var(--space-2);
}
.button-label {
display: none;
}
.connection-name {
display: none;
}
}

View File

@@ -0,0 +1,104 @@
/**
* ToolBar Component
*
* Quick access toolbar with common actions.
* Based on layout-design.md toolbar specifications
*/
import React from 'react';
import './ToolBar.css';
export interface ToolButton {
id: string;
icon: string;
label: string;
tooltip?: string;
disabled?: boolean;
onClick?: () => void;
}
export interface ToolBarProps {
/** Toolbar buttons */
buttons?: ToolButton[];
/** Additional content (e.g., search box) */
children?: React.ReactNode;
/** Handler when button is clicked */
onButtonClick?: (buttonId: string) => void;
}
/**
* Default toolbar buttons
*/
const defaultButtons: ToolButton[] = [
{
id: 'run',
icon: '▶',
label: 'Run',
tooltip: 'Execute query (Ctrl+Enter)',
},
{
id: 'save',
icon: '💾',
label: 'Save',
tooltip: 'Save query (Ctrl+S)',
},
{
id: 'export',
icon: '📤',
label: 'Export',
tooltip: 'Export results',
},
{
id: 'find',
icon: '🔍',
label: 'Find',
tooltip: 'Find in query (Ctrl+F)',
},
];
/**
* ToolBar component
*/
export const ToolBar: React.FC<ToolBarProps> = ({
buttons = defaultButtons,
children,
onButtonClick,
}) => {
const handleButtonClick = (button: ToolButton) => {
button.onClick?.();
onButtonClick?.(button.id);
};
return (
<div className="toolbar" role="toolbar" aria-label="Main toolbar">
<div className="toolbar-buttons">
{buttons.map((button) => (
<button
key={button.id}
className={`toolbar-button ${button.disabled ? 'disabled' : ''}`}
onClick={() => handleButtonClick(button)}
disabled={button.disabled}
title={button.tooltip}
aria-label={button.label}
>
<span className="button-icon">{button.icon}</span>
<span className="button-label">{button.label}</span>
</button>
))}
</div>
{children && <div className="toolbar-content">{children}</div>}
<div className="toolbar-spacer" />
{/* Connection indicator */}
<div className="toolbar-connection">
<span className="connection-status-dot connected"></span>
<span className="connection-name">🗄 MySQL @ localhost</span>
<span className="dropdown-arrow"></span>
</div>
</div>
);
};
export default ToolBar;

View File

@@ -0,0 +1,330 @@
/**
* DataGrid Component Styles
*/
.data-grid {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
overflow: hidden;
}
/* Toolbar */
.data-grid-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.data-grid-info {
display: flex;
align-items: center;
gap: var(--space-4);
}
.table-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
.data-grid-actions {
display: flex;
gap: var(--space-2);
}
/* Filter Bar */
.data-grid-filters {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
overflow-x: auto;
flex-shrink: 0;
}
.filter-input {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-family: var(--font-sans);
color: var(--text-primary);
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: all var(--transition-fast) var(--ease-in-out);
}
.filter-input:hover {
border-color: var(--text-secondary);
}
.filter-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
.filter-input::placeholder {
color: var(--text-muted);
}
/* Table Wrapper */
.data-grid-wrapper {
flex: 1;
overflow: auto;
position: relative;
}
/* Data Table */
.data-grid-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
font-family: var(--font-sans);
table-layout: fixed;
}
.data-grid-table thead {
position: sticky;
top: 0;
z-index: 10;
}
.data-grid-table th {
padding: var(--space-2) var(--space-3);
text-align: left;
background-color: var(--bg-tertiary);
border: 1px solid var(--border);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-grid-table th.sortable {
cursor: pointer;
user-select: none;
}
.data-grid-table th.sortable:hover {
background-color: var(--border);
}
.th-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.sort-icon {
font-size: var(--text-xs);
color: var(--text-muted);
}
.data-grid-table td {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.data-grid-table tbody tr {
transition: background-color var(--transition-fast) var(--ease-in-out);
}
.data-grid-table tbody tr:hover {
background-color: var(--bg-secondary);
}
.data-grid-table tbody tr.selected {
background-color: rgba(59, 130, 246, 0.1);
}
/* Special Columns */
.select-column,
.action-column {
width: 40px;
text-align: center;
}
.select-cell,
.action-cell {
text-align: center;
}
.select-cell input[type="checkbox"],
.action-cell input[type="checkbox"] {
cursor: pointer;
}
.editable-cell {
cursor: pointer;
}
.editable-cell:hover {
background-color: rgba(59, 130, 246, 0.05);
}
.cell-value {
display: block;
overflow: hidden;
text-overflow: ellipsis;
}
.null-value {
color: var(--text-muted);
font-style: italic;
font-size: var(--text-xs);
}
/* Cell Editor */
.cell-editor {
display: flex;
align-items: center;
gap: var(--space-1);
}
.cell-editor input[type="text"],
.cell-editor input[type="number"],
.cell-editor input[type="datetime-local"] {
flex: 1;
padding: var(--space-1);
font-size: var(--text-sm);
font-family: var(--font-sans);
border: 1px solid var(--primary);
border-radius: var(--radius-sm);
outline: none;
}
.cell-editor-number {
width: 100px !important;
}
.cell-editor-actions {
display: flex;
gap: var(--space-1);
}
/* Loading & Empty States */
.loading-cell,
.empty-cell {
text-align: center;
padding: var(--space-10);
color: var(--text-muted);
}
.loading-spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: var(--space-2);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Pagination */
.data-grid-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
background-color: var(--bg-tertiary);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.pagination-info {
display: flex;
gap: var(--space-4);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.selected-info {
color: var(--primary);
font-weight: 500;
}
.pagination-controls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.page-indicator {
font-size: var(--text-sm);
color: var(--text-primary);
min-width: 60px;
text-align: center;
}
.page-size-selector {
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: var(--space-4);
}
.page-size-selector label {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.page-size-selector select {
padding: var(--space-1) var(--space-2);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-primary);
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
outline: none;
}
.page-size-selector select:hover {
border-color: var(--text-secondary);
}
.page-size-selector select:focus {
border-color: var(--primary);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.data-grid-toolbar {
flex-wrap: wrap;
gap: var(--space-2);
}
.data-grid-actions {
flex-wrap: wrap;
}
.pagination-info {
flex-direction: column;
gap: var(--space-1);
}
.pagination-controls {
flex-wrap: wrap;
justify-content: center;
}
}

View File

@@ -0,0 +1,495 @@
/**
* DataGrid Component
*
* Data table viewer with sorting, filtering, and inline editing capabilities.
* Based on layout-design.md section "数据浏览模块"
*/
import React, { useState, useMemo } from 'react';
import './DataGrid.css';
export interface Column {
id: string;
name: string;
type?: string;
width?: number;
sortable?: boolean;
editable?: boolean;
}
export interface DataRow {
id: string | number;
[key: string]: any;
}
export interface PaginationState {
currentPage: number;
pageSize: number;
totalRows: number;
}
export interface SortState {
columnId: string;
direction: 'asc' | 'desc';
}
export interface FilterState {
columnId: string;
value: string;
}
export interface DataGridProps {
/** Table/column data */
columns: Column[];
rows: DataRow[];
/** Total row count (for pagination) */
totalRows?: number;
/** Loading state */
isLoading?: boolean;
/** Enable row selection */
selectable?: boolean;
/** Enable inline editing */
editable?: boolean;
/** Current pagination state */
pagination?: PaginationState;
/** Current sort state */
sort?: SortState;
/** Active filters */
filters?: FilterState[];
/** Handler when page changes */
onPageChange?: (page: number) => void;
/** Handler when page size changes */
onPageSizeChange?: (size: number) => void;
/** Handler when sort changes */
onSortChange?: (sort: SortState) => void;
/** Handler when filter changes */
onFilterChange?: (filter: FilterState) => void;
/** Handler when row is selected */
onRowSelect?: (rowIds: (string | number)[]) => void;
/** Handler when cell is edited */
onCellEdit?: (rowId: string | number, columnId: string, value: any) => void;
/** Handler when add row is requested */
onAddRow?: () => void;
/** Handler when delete rows is requested */
onDeleteRows?: (rowIds: (string | number)[]) => void;
/** Handler when refresh is requested */
onRefresh?: () => void;
/** Handler when export is requested */
onExport?: () => void;
/** Table name for display */
tableName?: string;
/** Schema name for display */
schemaName?: string;
}
/**
* Cell Editor component based on data type
*/
interface CellEditorProps {
value: any;
column: Column;
onSave: (value: any) => void;
onCancel: () => void;
}
const CellEditor: React.FC<CellEditorProps> = ({ value, column, onSave, onCancel }) => {
const [editValue, setEditValue] = useState(value);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
onSave(editValue);
} else if (e.key === 'Escape') {
onCancel();
}
};
// Render appropriate editor based on column type
const renderEditor = () => {
const type = column.type?.toUpperCase() || '';
if (type.includes('BOOL')) {
return (
<input
type="checkbox"
checked={!!editValue}
onChange={(e) => setEditValue(e.target.checked)}
autoFocus
/>
);
}
if (type.includes('INT') || type.includes('DECIMAL') || type.includes('FLOAT')) {
return (
<input
type="number"
value={editValue ?? ''}
onChange={(e) => setEditValue(e.target.value ? Number(e.target.value) : null)}
onKeyDown={handleKeyDown}
autoFocus
className="cell-editor-number"
/>
);
}
if (type.includes('DATE') || type.includes('TIME')) {
return (
<input
type="datetime-local"
value={editValue ? new Date(editValue).toISOString().slice(0, 16) : ''}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
);
}
// Default: text input
return (
<input
type="text"
value={editValue ?? ''}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
className="cell-editor-text"
/>
);
};
return (
<div className="cell-editor">
{renderEditor()}
<div className="cell-editor-actions">
<button className="btn-icon" onClick={() => onSave(editValue)} title="Save">
</button>
<button className="btn-icon" onClick={onCancel} title="Cancel">
×
</button>
</div>
</div>
);
};
/**
* DataGrid component
*/
export const DataGrid: React.FC<DataGridProps> = ({
columns,
rows,
totalRows,
isLoading = false,
selectable = true,
editable = true,
pagination = { currentPage: 1, pageSize: 25, totalRows: 0 },
sort,
filters = [],
onPageChange,
onPageSizeChange,
onSortChange,
onFilterChange,
onRowSelect,
onCellEdit,
onAddRow,
onDeleteRows,
onRefresh,
onExport,
tableName,
schemaName,
}) => {
const [selectedRows, setSelectedRows] = useState<Set<string | number>>(new Set());
const [editingCell, setEditingCell] = useState<{
rowId: string | number;
columnId: string;
} | null>(null);
const [localFilters, setLocalFilters] = useState<FilterState[]>(filters);
// Calculate total pages
const totalPages = Math.ceil((totalRows || rows.length) / pagination.pageSize);
// Handle header click for sorting
const handleHeaderClick = (columnId: string) => {
if (!onSortChange) return;
if (sort?.columnId === columnId) {
// Toggle direction or remove sort
if (sort.direction === 'asc') {
onSortChange({ columnId, direction: 'desc' });
} else {
onSortChange({ columnId: '', direction: 'asc' });
}
} else {
onSortChange({ columnId, direction: 'asc' });
}
};
// Handle row selection
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.checked) {
const allIds = rows.map((r) => r.id);
setSelectedRows(new Set(allIds));
onRowSelect?.(allIds);
} else {
setSelectedRows(new Set());
onRowSelect?.([]);
}
};
const handleSelectRow = (rowId: string | number) => {
const newSelected = new Set(selectedRows);
if (newSelected.has(rowId)) {
newSelected.delete(rowId);
} else {
newSelected.add(rowId);
}
setSelectedRows(newSelected);
onRowSelect?.(Array.from(newSelected));
};
// Handle cell edit
const handleCellDoubleClick = (rowId: string | number, columnId: string) => {
if (editable) {
setEditingCell({ rowId, columnId });
}
};
const handleCellSave = (value: any) => {
if (editingCell) {
onCellEdit?.(editingCell.rowId, editingCell.columnId, value);
setEditingCell(null);
}
};
const handleCellCancel = () => {
setEditingCell(null);
};
// Handle filter change
const handleFilterChange = (columnId: string, value: string) => {
const newFilters = localFilters.filter((f) => f.columnId !== columnId);
if (value) {
newFilters.push({ columnId, value });
}
setLocalFilters(newFilters);
onFilterChange?.({ columnId, value });
};
// Get sort icon
const getSortIcon = (columnId: string) => {
if (!sort || sort.columnId !== columnId) return '⇅';
return sort.direction === 'asc' ? '↑' : '↓';
};
return (
<div className="data-grid">
{/* Toolbar */}
<div className="data-grid-toolbar">
<div className="data-grid-info">
{tableName && (
<span className="table-name">
📋 {schemaName && `${schemaName}.`}{tableName}
</span>
)}
</div>
<div className="data-grid-actions">
<button className="btn btn-primary" onClick={onAddRow} disabled={isLoading}>
Add Row
</button>
<button
className="btn btn-secondary"
onClick={() => onDeleteRows?.(Array.from(selectedRows))}
disabled={selectedRows.size === 0 || isLoading}
>
🗑 Delete
</button>
<button className="btn btn-secondary" onClick={onRefresh} disabled={isLoading}>
🔄 Refresh
</button>
<button className="btn btn-secondary" onClick={onExport} disabled={isLoading}>
📤 Export
</button>
</div>
</div>
{/* Filter bar */}
<div className="data-grid-filters">
{columns.map((column) => (
<input
key={column.id}
type="text"
className="filter-input"
placeholder={`Filter ${column.name}...`}
style={{ width: column.width || 150 }}
value={localFilters.find((f) => f.columnId === column.id)?.value || ''}
onChange={(e) => handleFilterChange(column.id, e.target.value)}
/>
))}
</div>
{/* Data Table */}
<div className="data-grid-wrapper">
<table className="data-grid-table">
<thead>
<tr>
{selectable && (
<th className="select-column">
<input
type="checkbox"
checked={selectedRows.size === rows.length && rows.length > 0}
onChange={handleSelectAll}
/>
</th>
)}
{columns.map((column) => (
<th
key={column.id}
style={{ width: column.width }}
className={column.sortable ? 'sortable' : ''}
onClick={() => column.sortable && handleHeaderClick(column.id)}
>
<div className="th-content">
<span>{column.name}</span>
{column.sortable && (
<span className="sort-icon">{getSortIcon(column.id)}</span>
)}
</div>
</th>
))}
{editable && <th className="action-column"></th>}
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={columns.length + (selectable ? 2 : 1)} className="loading-cell">
<div className="loading-spinner" />
<span>Loading data...</span>
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={columns.length + (selectable ? 2 : 1)} className="empty-cell">
No data available
</td>
</tr>
) : (
rows.map((row) => (
<tr
key={row.id}
className={`${selectedRows.has(row.id) ? 'selected' : ''}`}
>
{selectable && (
<td className="select-cell">
<input
type="checkbox"
checked={selectedRows.has(row.id)}
onChange={() => handleSelectRow(row.id)}
/>
</td>
)}
{columns.map((column) => (
<td
key={column.id}
className={editable ? 'editable-cell' : ''}
onDoubleClick={() => handleCellDoubleClick(row.id, column.id)}
>
{editingCell?.rowId === row.id &&
editingCell?.columnId === column.id ? (
<CellEditor
value={row[column.id]}
column={column}
onSave={handleCellSave}
onCancel={handleCellCancel}
/>
) : (
<span className="cell-value">
{row[column.id] === null ? (
<span className="null-value">NULL</span>
) : (
String(row[column.id])
)}
</span>
)}
</td>
))}
{editable && (
<td className="action-cell">
<button
className="btn-icon"
onClick={() => handleCellDoubleClick(row.id, columns[0].id)}
title="Edit row"
>
</button>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="data-grid-pagination">
<div className="pagination-info">
{selectedRows.size > 0 && (
<span className="selected-info">Selected: {selectedRows.size} rows</span>
)}
<span className="total-info">Total: {totalRows || rows.length} rows</span>
</div>
<div className="pagination-controls">
<button
className="btn-icon"
onClick={() => onPageChange?.(1)}
disabled={pagination.currentPage === 1}
title="First page"
>
</button>
<button
className="btn-icon"
onClick={() => onPageChange?.(pagination.currentPage - 1)}
disabled={pagination.currentPage === 1}
title="Previous page"
>
</button>
<span className="page-indicator">
{pagination.currentPage} / {totalPages || 1}
</span>
<button
className="btn-icon"
onClick={() => onPageChange?.(pagination.currentPage + 1)}
disabled={pagination.currentPage >= totalPages}
title="Next page"
>
</button>
<button
className="btn-icon"
onClick={() => onPageChange?.(totalPages)}
disabled={pagination.currentPage >= totalPages}
title="Last page"
>
</button>
<div className="page-size-selector">
<label>Per page:</label>
<select
value={pagination.pageSize}
onChange={(e) => onPageSizeChange?.(Number(e.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={250}>250</option>
</select>
</div>
</div>
</div>
</div>
);
};
export default DataGrid;

View File

@@ -0,0 +1,347 @@
/**
* QueryEditor Component Styles
*/
.query-editor {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
overflow: hidden;
}
/* Tab Bar */
.query-tabs {
display: flex;
align-items: center;
height: var(--tab-height);
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
padding: 0 var(--space-2);
gap: var(--space-1);
flex-shrink: 0;
}
.query-tab-list {
display: flex;
gap: var(--space-1);
flex: 1;
overflow-x: auto;
}
.query-tab {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
min-width: 120px;
max-width: 200px;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-bottom: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
cursor: pointer;
font-size: var(--text-sm);
color: var(--text-secondary);
transition: all var(--transition-fast) var(--ease-in-out);
user-select: none;
}
.query-tab:hover {
background-color: var(--bg-primary);
}
.query-tab.active {
background-color: var(--bg-primary);
border-bottom-color: var(--bg-primary);
color: var(--text-primary);
}
.query-tab.dirty .tab-title::after {
content: ' ●';
color: var(--primary);
font-size: var(--text-xs);
}
.tab-icon {
font-size: var(--text-sm);
}
.tab-title {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-close {
width: 16px;
height: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: all var(--transition-fast) var(--ease-in-out);
}
.query-tab:hover .tab-close {
opacity: 1;
}
.tab-close:hover {
background-color: var(--error);
color: white;
}
.new-tab-btn {
font-size: var(--text-lg);
font-weight: bold;
}
/* Toolbar */
.query-toolbar {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-2) var(--space-4);
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.toolbar-group {
display: flex;
gap: var(--space-2);
align-items: center;
}
.toolbar-spacer {
flex: 1;
}
.connection-select {
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-primary);
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
outline: none;
}
.connection-select:hover {
border-color: var(--text-secondary);
}
.connection-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Editor Container */
.editor-container {
display: flex;
flex: 1;
overflow: hidden;
border-bottom: 1px solid var(--border);
}
.line-numbers {
display: flex;
flex-direction: column;
padding: var(--space-3) var(--space-2);
background-color: var(--bg-tertiary);
border-right: 1px solid var(--border);
user-select: none;
overflow: hidden;
}
.line-number {
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.5;
color: var(--text-muted);
text-align: right;
min-height: 24px;
}
.sql-editor {
flex: 1;
padding: var(--space-3);
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
border: none;
resize: none;
outline: none;
overflow: auto;
white-space: pre;
}
.sql-editor::placeholder {
color: var(--text-muted);
}
.cursor-position {
padding: var(--space-1) var(--space-3);
font-size: var(--text-xs);
color: var(--text-muted);
background-color: var(--bg-tertiary);
border-top: 1px solid var(--border);
text-align: right;
}
/* Results Panel */
.results-panel {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
background-color: var(--bg-primary);
}
.results-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
}
.results-title {
font-size: var(--text-sm);
font-weight: 600;
margin: 0;
}
.results-message {
font-size: var(--text-sm);
color: var(--success);
}
.results-message.error {
color: var(--error);
}
.results-table-wrapper {
flex: 1;
overflow: auto;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
font-family: var(--font-sans);
}
.results-table th,
.results-table td {
padding: var(--space-2) var(--space-3);
text-align: left;
border: 1px solid var(--border);
white-space: nowrap;
}
.results-table th {
background-color: var(--bg-tertiary);
font-weight: 600;
position: sticky;
top: 0;
z-index: 10;
}
.results-table tr:hover {
background-color: var(--bg-secondary);
}
.results-limit-notice {
padding: var(--space-2) var(--space-4);
font-size: var(--text-xs);
color: var(--text-muted);
text-align: center;
background-color: var(--bg-warning);
}
.results-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-4);
background-color: var(--bg-tertiary);
border-top: 1px solid var(--border);
}
.results-info {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.results-actions {
display: flex;
gap: var(--space-1);
}
/* Error Message */
.error-message {
padding: var(--space-4);
background-color: #fef2f2;
border-left: 3px solid var(--error);
margin: var(--space-3);
}
.error-message pre {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-sm);
color: var(--error);
white-space: pre-wrap;
word-break: break-word;
}
/* Loading Overlay */
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(255, 255, 255, 0.8);
z-index: 100;
gap: var(--space-4);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-overlay span {
font-size: var(--text-sm);
color: var(--text-secondary);
}

View File

@@ -0,0 +1,301 @@
/**
* QueryEditor Component
*
* SQL query editor with syntax highlighting and execution controls.
* Based on layout-design.md section "SQL 编辑器模块"
*/
import React, { useState, KeyboardEvent } from 'react';
import './QueryEditor.css';
export interface QueryTab {
id: string;
title: string;
content: string;
isDirty?: boolean;
}
export interface QueryResult {
columns: string[];
rows: any[][];
rowCount?: number;
executionTime?: number;
message?: string;
error?: string;
}
export interface QueryEditorProps {
/** Current query tabs */
tabs?: QueryTab[];
/** Active tab ID */
activeTabId?: string;
/** Query results */
results?: QueryResult | null;
/** Loading state */
isLoading?: boolean;
/** Handler when tab is clicked */
onTabClick?: (tabId: string) => void;
/** Handler when tab is closed */
onCloseTab?: (tabId: string) => void;
/** Handler when new tab is requested */
onNewTab?: () => void;
/** Handler when query content changes */
onContentChange?: (tabId: string, content: string) => void;
/** Handler when query is executed */
onExecute?: (query: string) => void;
/** Handler when query is saved */
onSave?: (tabId: string) => void;
/** Handler when format is requested */
onFormat?: () => void;
}
/**
* QueryEditor component
*/
export const QueryEditor: React.FC<QueryEditorProps> = ({
tabs = [],
activeTabId,
results,
isLoading = false,
onTabClick,
onCloseTab,
onNewTab,
onContentChange,
onExecute,
onSave,
onFormat,
}) => {
const [editorContent, setEditorContent] = useState('');
const [cursorPosition, setCursorPosition] = useState({ line: 1, column: 1 });
// Get active tab
const activeTab = tabs.find((t) => t.id === activeTabId);
// Handle keyboard shortcuts
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Ctrl+Enter to execute
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
onExecute?.(editorContent);
}
// Ctrl+S to save
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
if (activeTabId) {
onSave?.(activeTabId);
}
}
// Tab key for indentation
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.currentTarget;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newValue =
editorContent.substring(0, start) + ' ' + editorContent.substring(end);
setEditorContent(newValue);
// Restore cursor position
setTimeout(() => {
textarea.selectionStart = textarea.selectionEnd = start + 2;
}, 0);
}
};
// Update cursor position
const handleCursorChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const content = e.target.value;
const cursorPos = e.target.selectionStart;
const lines = content.substring(0, cursorPos).split('\n');
const line = lines.length;
const column = lines[lines.length - 1].length + 1;
setCursorPosition({ line, column });
setEditorContent(content);
onContentChange?.(activeTabId || 'default', content);
};
return (
<div className="query-editor">
{/* Tab Bar */}
<div className="query-tabs">
<div className="query-tab-list">
{tabs.map((tab) => (
<div
key={tab.id}
className={`query-tab ${tab.id === activeTabId ? 'active' : ''} ${
tab.isDirty ? 'dirty' : ''
}`}
onClick={() => onTabClick?.(tab.id)}
>
<span className="tab-icon">📑</span>
<span className="tab-title">{tab.title}</span>
{tab.isDirty && <span className="tab-dirty-indicator"></span>}
<button
className="tab-close"
onClick={(e) => {
e.stopPropagation();
onCloseTab?.(tab.id);
}}
title="Close tab"
>
×
</button>
</div>
))}
</div>
<button
className="btn-icon new-tab-btn"
onClick={onNewTab}
title="New query tab"
aria-label="New query tab"
>
+
</button>
</div>
{/* Editor Toolbar */}
<div className="query-toolbar">
<div className="toolbar-group">
<button
className="btn btn-primary btn-run"
onClick={() => onExecute?.(editorContent)}
disabled={isLoading || !editorContent.trim()}
>
Run
</button>
<button
className="btn btn-secondary"
onClick={onFormat}
disabled={isLoading || !editorContent.trim()}
>
Format
</button>
</div>
<div className="toolbar-group">
<button
className="btn btn-secondary"
onClick={() => activeTabId && onSave?.(activeTabId)}
disabled={isLoading || !activeTab?.isDirty}
>
💾 Save
</button>
<button className="btn btn-secondary" disabled={isLoading || !results}>
📤 Export
</button>
<button className="btn btn-secondary" disabled={isLoading}>
🔍 Find
</button>
</div>
<div className="toolbar-spacer" />
<div className="toolbar-group">
<select className="connection-select" disabled={isLoading}>
<option>🗄 MySQL @ localhost</option>
<option>🐘 PostgreSQL @ prod-db</option>
</select>
</div>
</div>
{/* SQL Editor */}
<div className="editor-container">
<div className="line-numbers">
{editorContent.split('\n').map((_, index) => (
<div key={index} className="line-number">
{index + 1}
</div>
))}
</div>
<textarea
className="sql-editor"
value={activeTab?.content || editorContent}
onChange={handleCursorChange}
onKeyDown={handleKeyDown}
placeholder="-- Write your SQL query here...&#10;&#10;-- Example:&#10;SELECT * FROM users;&#10;"
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
aria-label="SQL query editor"
/>
</div>
{/* Cursor position indicator */}
<div className="cursor-position">
Ln {cursorPosition.line}, Col {cursorPosition.column}
</div>
{/* Results Panel */}
{results && (
<div className="results-panel">
<div className="results-header">
<h4 className="results-title">Results</h4>
{results.message && (
<span className={`results-message ${results.error ? 'error' : 'success'}`}>
{results.error ? '✕' : '✓'} {results.message}
</span>
)}
</div>
{results.error ? (
<div className="error-message">
<pre>{results.error}</pre>
</div>
) : (
<div className="results-table-wrapper">
<table className="results-table">
<thead>
<tr>
{results.columns.map((col, index) => (
<th key={index}>{col}</th>
))}
</tr>
</thead>
<tbody>
{results.rows.slice(0, 100).map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell === null ? 'NULL' : String(cell)}</td>
))}
</tr>
))}
</tbody>
</table>
{results.rows.length > 100 && (
<div className="results-limit-notice">
Showing 100 of {results.rows.length} rows
</div>
)}
</div>
)}
{/* Results footer */}
<div className="results-footer">
<span className="results-info">
{results.rowCount || results.rows.length} rows
{results.executionTime && ` in ${results.executionTime}s`}
</span>
<div className="results-actions">
<button className="btn-icon" title="Copy results">
📋
</button>
<button className="btn-icon" title="Export">
📤
</button>
</div>
</div>
</div>
)}
{/* Loading overlay */}
{isLoading && (
<div className="loading-overlay">
<div className="loading-spinner" />
<span>Executing query...</span>
</div>
)}
</div>
);
};
export default QueryEditor;

View File

@@ -0,0 +1,279 @@
/**
* TableStructure Component Styles
*/
.table-structure {
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--bg-primary);
overflow: hidden;
}
/* Header */
.structure-header {
display: flex;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background-color: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.structure-title {
display: flex;
align-items: center;
gap: var(--space-2);
}
.table-icon {
font-size: var(--text-lg);
}
.structure-heading {
font-size: var(--text-base);
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.structure-meta {
display: flex;
gap: var(--space-4);
margin-left: auto;
}
.meta-item {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.structure-actions {
display: flex;
gap: var(--space-2);
}
/* Tab Navigation */
.structure-tabs {
display: flex;
gap: var(--space-1);
padding: var(--space-2) var(--space-4) 0;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.structure-tab {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-secondary);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all var(--transition-fast) var(--ease-in-out);
white-space: nowrap;
}
.structure-tab:hover {
color: var(--text-primary);
background-color: var(--bg-tertiary);
}
.structure-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-icon {
font-size: var(--text-sm);
}
/* Content */
.structure-content {
flex: 1;
overflow: auto;
padding: var(--space-4);
}
/* Sections */
.structure-section {
margin-bottom: var(--space-6);
}
.section-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
margin: 0 0 var(--space-3) 0;
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--border);
}
/* Structure Table */
.structure-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
font-family: var(--font-sans);
}
.structure-table th {
padding: var(--space-2) var(--space-3);
text-align: left;
background-color: var(--bg-tertiary);
border: 1px solid var(--border);
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
}
.structure-table td {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border);
}
.structure-table tbody tr:hover {
background-color: var(--bg-secondary);
}
.column-name {
font-weight: 500;
color: var(--text-primary);
}
.column-type {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-secondary);
}
.column-default,
.column-extra {
font-family: var(--font-mono);
font-size: var(--text-xs);
color: var(--text-muted);
}
.index-name,
.fk-name {
font-weight: 500;
color: var(--primary);
}
.text-center {
text-align: center;
}
/* Key Badges */
.key-badge {
display: inline-block;
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
font-weight: 600;
border-radius: var(--radius-sm);
margin-right: var(--space-1);
}
.key-badge.pk {
background-color: rgba(59, 130, 246, 0.1);
color: var(--primary);
}
.key-badge.uk {
background-color: rgba(16, 185, 129, 0.1);
color: var(--success);
}
/* Info Grid */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--space-3);
padding: var(--space-3);
background-color: var(--bg-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--border);
}
.info-item {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.info-label {
font-size: var(--text-xs);
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
}
.info-value {
font-size: var(--text-sm);
color: var(--text-primary);
font-weight: 500;
}
/* Empty State */
.empty-cell {
text-align: center;
padding: var(--space-6);
color: var(--text-muted);
font-style: italic;
}
/* Tab Placeholder */
.tab-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-10);
color: var(--text-muted);
gap: var(--space-4);
}
.tab-placeholder p {
font-size: var(--text-base);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.structure-header {
flex-wrap: wrap;
}
.structure-meta {
width: 100%;
flex-direction: column;
gap: var(--space-1);
}
.structure-actions {
width: 100%;
justify-content: flex-end;
}
.structure-tabs {
overflow-x: auto;
}
.info-grid {
grid-template-columns: 1fr;
}
.structure-table {
font-size: var(--text-xs);
}
.structure-table th,
.structure-table td {
padding: var(--space-1) var(--space-2);
}
}

View File

@@ -0,0 +1,347 @@
/**
* TableStructure Component
*
* Table structure viewer showing columns, indexes, foreign keys, and table info.
* Based on layout-design.md section "表结构查看模块"
*/
import React, { useState } from 'react';
import './TableStructure.css';
export interface TableColumn {
name: string;
type: string;
nullable: boolean;
isPrimaryKey?: boolean;
isUnique?: boolean;
defaultValue?: string;
extra?: string;
}
export interface Index {
name: string;
type: string;
columns: string[];
isUnique: boolean;
method: string;
}
export interface ForeignKey {
name: string;
column: string;
referencesTable: string;
referencesColumn: string;
onUpdate?: string;
onDelete?: string;
}
export interface TableInfo {
engine?: string;
collation?: string;
rowCount?: number;
size?: string;
autoIncrement?: number;
comment?: string;
}
export type StructureTab = 'data' | 'structure' | 'indexes' | 'foreignKeys' | 'triggers';
export interface TableStructureProps {
/** Table name */
tableName: string;
/** Schema name */
schemaName?: string;
/** Connection name */
connectionName?: string;
/** Column definitions */
columns?: TableColumn[];
/** Indexes */
indexes?: Index[];
/** Foreign keys */
foreignKeys?: ForeignKey[];
/** Table metadata */
tableInfo?: TableInfo;
/** Active tab */
activeTab?: StructureTab;
/** Handler when tab changes */
onTabChange?: (tab: StructureTab) => void;
/** Handler when viewing data is requested */
onViewData?: () => void;
/** Handler when editing table is requested */
onEditTable?: () => void;
/** Handler when refreshing is requested */
onRefresh?: () => void;
}
/**
* Columns Tab Content
*/
const ColumnsTab: React.FC<{ columns?: TableColumn[] }> = ({ columns = [] }) => {
return (
<div className="structure-section">
<h4 className="section-title">Columns ({columns.length})</h4>
<table className="structure-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Null</th>
<th>Key</th>
<th>Default</th>
<th>Extra</th>
</tr>
</thead>
<tbody>
{columns.length === 0 ? (
<tr>
<td colSpan={6} className="empty-cell">
No columns found
</td>
</tr>
) : (
columns.map((col, index) => (
<tr key={index}>
<td className="column-name">{col.name}</td>
<td className="column-type">{col.type}</td>
<td className="text-center">{col.nullable ? '✓' : '❌'}</td>
<td className="text-center">
{col.isPrimaryKey && <span className="key-badge pk">PK</span>}
{col.isUnique && <span className="key-badge uk">UK</span>}
</td>
<td className="column-default">
{col.defaultValue !== undefined ? String(col.defaultValue) : '-'}
</td>
<td className="column-extra">{col.extra || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
};
/**
* Indexes Tab Content
*/
const IndexesTab: React.FC<{ indexes?: Index[] }> = ({ indexes = [] }) => {
return (
<div className="structure-section">
<h4 className="section-title">Indexes ({indexes.length})</h4>
<table className="structure-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Columns</th>
<th>Unique</th>
<th>Method</th>
</tr>
</thead>
<tbody>
{indexes.length === 0 ? (
<tr>
<td colSpan={5} className="empty-cell">
No indexes found
</td>
</tr>
) : (
indexes.map((idx, index) => (
<tr key={index}>
<td className="index-name">{idx.name}</td>
<td>{idx.type}</td>
<td>{idx.columns.join(', ')}</td>
<td className="text-center">{idx.isUnique ? '✓' : ''}</td>
<td>{idx.method}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
};
/**
* Foreign Keys Tab Content
*/
const ForeignKeysTab: React.FC<{ foreignKeys?: ForeignKey[] }> = ({
foreignKeys = [],
}) => {
return (
<div className="structure-section">
<h4 className="section-title">Foreign Keys ({foreignKeys.length})</h4>
<table className="structure-table">
<thead>
<tr>
<th>Name</th>
<th>Column</th>
<th>References</th>
<th>On Update</th>
<th>On Delete</th>
</tr>
</thead>
<tbody>
{foreignKeys.length === 0 ? (
<tr>
<td colSpan={5} className="empty-cell">
No foreign keys found
</td>
</tr>
) : (
foreignKeys.map((fk, index) => (
<tr key={index}>
<td className="fk-name">{fk.name}</td>
<td>{fk.column}</td>
<td>
{fk.referencesTable}({fk.referencesColumn})
</td>
<td>{fk.onUpdate || '-'}</td>
<td>{fk.onDelete || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
);
};
/**
* Table Info Section
*/
const TableInfoSection: React.FC<{ info?: TableInfo }> = ({ info }) => {
if (!info) return null;
const infoItems = [
{ label: 'Engine', value: info.engine },
{ label: 'Collation', value: info.collation },
{ label: 'Rows', value: info.rowCount?.toLocaleString() },
{ label: 'Size', value: info.size },
{ label: 'Auto Increment', value: info.autoIncrement?.toLocaleString() },
{ label: 'Comment', value: info.comment },
].filter((item) => item.value !== undefined);
return (
<div className="structure-section">
<h4 className="section-title">Table Info</h4>
<div className="info-grid">
{infoItems.map((item) => (
<div key={item.label} className="info-item">
<span className="info-label">{item.label}:</span>
<span className="info-value">{item.value || '-'}</span>
</div>
))}
</div>
</div>
);
};
/**
* Main TableStructure component
*/
export const TableStructure: React.FC<TableStructureProps> = ({
tableName,
schemaName,
connectionName,
columns = [],
indexes = [],
foreignKeys = [],
tableInfo,
activeTab = 'structure',
onTabChange,
onViewData,
onEditTable,
onRefresh,
}) => {
const [localTab, setLocalTab] = useState<StructureTab>(activeTab);
const handleTabChange = (tab: StructureTab) => {
setLocalTab(tab);
onTabChange?.(tab);
};
const tabs: { id: StructureTab; label: string; icon: string }[] = [
{ id: 'data', label: 'Data', icon: '📊' },
{ id: 'structure', label: 'Structure', icon: '📋' },
{ id: 'indexes', label: 'Indexes', icon: '🔖' },
{ id: 'foreignKeys', label: 'Foreign Keys', icon: '🔗' },
{ id: 'triggers', label: 'Triggers', icon: '⚡' },
];
return (
<div className="table-structure">
{/* Header */}
<div className="structure-header">
<div className="structure-title">
<span className="table-icon">📋</span>
<h3 className="structure-heading">
Table Structure: {tableName}
</h3>
</div>
<div className="structure-meta">
{schemaName && <span className="meta-item">Schema: {schemaName}</span>}
{connectionName && (
<span className="meta-item">Connection: {connectionName}</span>
)}
</div>
<div className="structure-actions">
<button className="btn btn-primary" onClick={onViewData}>
View Data
</button>
<button className="btn btn-secondary" onClick={onEditTable}>
Edit Table
</button>
<button className="btn-icon" onClick={onRefresh} title="Refresh">
🔄
</button>
</div>
</div>
{/* Tab Navigation */}
<div className="structure-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
className={`structure-tab ${localTab === tab.id ? 'active' : ''}`}
onClick={() => handleTabChange(tab.id)}
>
<span className="tab-icon">{tab.icon}</span>
<span className="tab-label">{tab.label}</span>
</button>
))}
</div>
{/* Tab Content */}
<div className="structure-content">
{localTab === 'data' && (
<div className="tab-placeholder">
<p>Data view is available in the DataGrid component</p>
<button className="btn btn-primary" onClick={onViewData}>
Open Data View
</button>
</div>
)}
{localTab === 'structure' && (
<>
<ColumnsTab columns={columns} />
<TableInfoSection info={tableInfo} />
</>
)}
{localTab === 'indexes' && <IndexesTab indexes={indexes} />}
{localTab === 'foreignKeys' && <ForeignKeysTab foreignKeys={foreignKeys} />}
{localTab === 'triggers' && (
<div className="tab-placeholder">
<p>No triggers defined for this table</p>
</div>
)}
</div>
</div>
);
};
export default TableStructure;

View File

@@ -0,0 +1,170 @@
/**
* MenuBar Component Styles
*/
.menubar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--menubar-height);
padding: 0 var(--space-2);
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border);
user-select: none;
flex-shrink: 0;
}
.menubar-left {
display: flex;
align-items: center;
gap: var(--space-4);
}
/* Application title */
.menubar-title {
display: flex;
align-items: center;
gap: var(--space-2);
padding-right: var(--space-4);
border-right: 1px solid var(--border);
}
.menubar-icon {
font-size: var(--text-lg);
}
.menubar-app-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
}
/* Navigation */
.menubar-nav {
display: flex;
gap: var(--space-1);
}
.menubar-item {
position: relative;
}
.menubar-button {
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-primary);
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background-color var(--transition-fast) var(--ease-in-out);
}
.menubar-button:hover {
background-color: var(--bg-tertiary);
}
.menubar-button.active {
background-color: var(--bg-tertiary);
}
.menubar-button:focus-visible {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
/* Dropdown menu */
.menu-dropdown {
position: absolute;
top: 100%;
left: 0;
min-width: 220px;
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: var(--space-2) 0;
z-index: 1000;
animation: slideIn 0.15s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-primary);
text-align: left;
background: transparent;
border: none;
cursor: pointer;
transition: background-color var(--transition-fast) var(--ease-in-out);
}
.menu-item:hover:not(.disabled) {
background-color: var(--bg-tertiary);
}
.menu-item.disabled {
color: var(--text-muted);
cursor: not-allowed;
}
.menu-item-label {
flex: 1;
}
.menu-item-shortcut {
font-size: var(--text-xs);
color: var(--text-muted);
margin-left: var(--space-4);
font-family: var(--font-mono);
}
.menu-separator {
height: 1px;
margin: var(--space-2) 0;
background-color: var(--border);
}
.menubar-right {
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.menubar-app-name {
display: none;
}
.menubar-title {
border-right: none;
padding-right: 0;
}
.menubar-button {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
}
.menu-dropdown {
min-width: 180px;
}
}

View File

@@ -0,0 +1,267 @@
/**
* MenuBar Component
*
* Top application menu bar with File, Edit, View, Query, Tools, Help menus.
* Based on layout-design.md menu specifications
*/
import React, { useState, useRef, useEffect } from 'react';
import './MenuBar.css';
/**
* Menu item structure
*/
export interface MenuItem {
/** Unique identifier */
id: string;
/** Display label */
label?: string;
/** Keyboard shortcut */
shortcut?: string;
/** Whether item is disabled */
disabled?: boolean;
/** Whether item is a separator */
separator?: boolean;
/** Submenu items */
submenu?: MenuItem[];
/** Click handler */
onClick?: () => void;
}
/**
* Menu definition structure
*/
export interface MenuDefinition {
/** Menu label (e.g., "File", "Edit") */
label: string;
/** Menu items */
items: MenuItem[];
}
export interface MenuBarProps {
/** List of menu definitions */
menus?: MenuDefinition[];
/** Application title */
title?: string;
/** Handler when menu item is clicked */
onMenuItemClick?: (menuId: string, itemId: string) => void;
}
/**
* Default menu definitions based on layout-design.md
*/
const defaultMenus: MenuDefinition[] = [
{
label: 'File',
items: [
{ id: 'new-connection', label: 'New Connection', shortcut: 'Ctrl+N' },
{ id: 'open-file', label: 'Open File...', shortcut: 'Ctrl+O' },
{ separator: true, id: 'sep-1' },
{ id: 'save', label: 'Save', shortcut: 'Ctrl+S' },
{ id: 'save-as', label: 'Save As...', shortcut: 'Ctrl+Shift+S' },
{ separator: true, id: 'sep-2' },
{ id: 'export', label: 'Export', shortcut: 'Ctrl+E' },
{ id: 'import', label: 'Import', shortcut: 'Ctrl+I' },
{ separator: true, id: 'sep-3' },
{ id: 'close-tab', label: 'Close Tab', shortcut: 'Ctrl+W' },
{ id: 'exit', label: 'Exit', shortcut: 'Alt+F4' },
],
},
{
label: 'Edit',
items: [
{ id: 'undo', label: 'Undo', shortcut: 'Ctrl+Z' },
{ id: 'redo', label: 'Redo', shortcut: 'Ctrl+Y' },
{ separator: true, id: 'sep-4' },
{ id: 'cut', label: 'Cut', shortcut: 'Ctrl+X' },
{ id: 'copy', label: 'Copy', shortcut: 'Ctrl+C' },
{ id: 'paste', label: 'Paste', shortcut: 'Ctrl+V' },
{ separator: true, id: 'sep-5' },
{ id: 'find', label: 'Find', shortcut: 'Ctrl+F' },
{ id: 'replace', label: 'Replace', shortcut: 'Ctrl+H' },
],
},
{
label: 'View',
items: [
{ id: 'refresh', label: 'Refresh', shortcut: 'F5' },
{ separator: true, id: 'sep-6' },
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', shortcut: 'Ctrl+B' },
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'Ctrl++' },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'Ctrl+-' },
{ id: 'reset-zoom', label: 'Reset Zoom', shortcut: 'Ctrl+0' },
],
},
{
label: 'Query',
items: [
{ id: 'run-query', label: 'Run Query', shortcut: 'Ctrl+Enter' },
{ id: 'explain-query', label: 'Explain Query', shortcut: 'Ctrl+Shift+E' },
{ separator: true, id: 'sep-7' },
{ id: 'format-sql', label: 'Format SQL', shortcut: 'Ctrl+Shift+F' },
{ id: 'query-history', label: 'Query History', shortcut: 'Ctrl+Shift+H' },
],
},
{
label: 'Tools',
items: [
{ id: 'data-export', label: 'Data Export Wizard...' },
{ id: 'data-import', label: 'Data Import Wizard...' },
{ separator: true, id: 'sep-8' },
{ id: 'connection-manager', label: 'Connection Manager...' },
{ id: 'preferences', label: 'Preferences', shortcut: 'Ctrl+,' },
],
},
{
label: 'Help',
items: [
{ id: 'documentation', label: 'Documentation', shortcut: 'F1' },
{ id: 'check-updates', label: 'Check for Updates...' },
{ separator: true, id: 'sep-9' },
{ id: 'about', label: 'About uzdb' },
],
},
];
/**
* Dropdown menu component
*/
interface MenuDropdownProps {
menu: MenuDefinition;
isOpen: boolean;
onClose: () => void;
onItemClick: (itemId: string) => void;
}
const MenuDropdown: React.FC<MenuDropdownProps> = ({
menu,
isOpen,
onClose,
onItemClick,
}) => {
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="menu-dropdown" ref={menuRef}>
{menu.items.map((item) => {
if (item.separator) {
return <div key={item.id} className="menu-separator" />;
}
return (
<button
key={item.id}
className={`menu-item ${item.disabled ? 'disabled' : ''}`}
onClick={() => !item.disabled && onItemClick(item.id)}
disabled={item.disabled}
>
<span className="menu-item-label">{item.label}</span>
{item.shortcut && (
<span className="menu-item-shortcut">{item.shortcut}</span>
)}
</button>
);
})}
</div>
);
};
/**
* Main MenuBar component
*/
export const MenuBar: React.FC<MenuBarProps> = ({
menus = defaultMenus,
title = 'uzdb',
onMenuItemClick,
}) => {
const [openMenuIndex, setOpenMenuIndex] = useState<number | null>(null);
const handleMenuClick = (index: number, menu: MenuDefinition) => {
if (openMenuIndex === index) {
setOpenMenuIndex(null);
} else {
setOpenMenuIndex(index);
}
};
const handleMenuItemClick = (menuLabel: string, itemId: string) => {
console.log(`Menu "${menuLabel}" -> Item "${itemId}"`);
onMenuItemClick?.(menuLabel.toLowerCase(), itemId);
setOpenMenuIndex(null);
};
const handleCloseMenu = () => {
setOpenMenuIndex(null);
};
// Handle escape key to close menu
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setOpenMenuIndex(null);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, []);
return (
<div className="menubar">
<div className="menubar-left">
{/* Application icon/title */}
<div className="menubar-title">
<span className="menubar-icon">🗄</span>
<span className="menubar-app-name">{title}</span>
</div>
{/* Menu items */}
<nav className="menubar-nav" role="menubar" aria-label="Application menu">
{menus.map((menu, index) => (
<div key={menu.label} className="menubar-item">
<button
className={`menubar-button ${openMenuIndex === index ? 'active' : ''}`}
onClick={() => handleMenuClick(index, menu)}
aria-haspopup="true"
aria-expanded={openMenuIndex === index}
>
{menu.label}
</button>
<MenuDropdown
menu={menu}
isOpen={openMenuIndex === index}
onClose={handleCloseMenu}
onItemClick={(itemId) => handleMenuItemClick(menu.label, itemId)}
/>
</div>
))}
</nav>
</div>
<div className="menubar-right">
{/* Placeholder for user account, theme toggle, etc. */}
</div>
</div>
);
};
export default MenuBar;

View File

@@ -0,0 +1,235 @@
/**
* ConnectionPanel Component Styles
*/
.connection-panel {
display: flex;
flex-direction: column;
height: 100%;
width: var(--sidebar-width);
min-width: var(--sidebar-min-width);
max-width: var(--sidebar-max-width);
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow: hidden;
}
.connection-panel-collapsed {
width: 48px;
background-color: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--space-2);
}
/* Header */
.connection-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
background-color: var(--bg-tertiary);
flex-shrink: 0;
}
.connection-panel-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-primary);
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
/* Connection List */
.connection-list {
flex: 1;
overflow-y: auto;
padding: var(--space-2) 0;
}
.empty-state {
padding: var(--space-6);
text-align: center;
color: var(--text-muted);
}
.empty-state p {
font-size: var(--text-sm);
}
/* Connection Item */
.connection-item {
cursor: pointer;
user-select: none;
}
.connection-item:hover {
background-color: var(--bg-tertiary);
}
.connection-item.selected {
background-color: rgba(59, 130, 246, 0.1);
}
.connection-item.active {
background-color: rgba(59, 130, 246, 0.15);
box-shadow: inset 2px 0 0 var(--primary);
}
.connection-header {
display: flex;
align-items: center;
padding: var(--space-2) var(--space-4);
gap: var(--space-2);
}
.connection-name {
font-size: var(--text-sm);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.db-type-icon {
font-size: var(--text-sm);
margin-left: var(--space-1);
}
/* Tree Node */
.tree-node {
display: flex;
flex-direction: column;
}
.tree-node-content {
display: flex;
align-items: center;
padding: var(--space-1) var(--space-2);
gap: var(--space-1);
cursor: pointer;
transition: background-color var(--transition-fast) var(--ease-in-out);
}
.tree-node-content:hover {
background-color: var(--bg-tertiary);
}
.tree-node-content.active {
background-color: rgba(59, 130, 246, 0.1);
}
.tree-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: var(--text-secondary);
transition: transform var(--transition-fast) var(--ease-in-out);
flex-shrink: 0;
}
.tree-toggle.expanded {
transform: rotate(90deg);
}
.tree-toggle-placeholder {
width: 16px;
flex-shrink: 0;
}
.tree-icon {
font-size: var(--text-sm);
margin-right: var(--space-1);
}
.tree-label {
font-size: var(--text-sm);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-node-children {
display: flex;
flex-direction: column;
}
/* Footer */
.connection-panel-footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--border);
background-color: var(--bg-tertiary);
flex-shrink: 0;
}
.btn-new-connection {
width: 100%;
justify-content: center;
}
/* Collapsed state */
.collapsed-connections {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
align-items: center;
}
.collapsed-connection-item {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color var(--transition-fast) var(--ease-in-out);
}
.collapsed-connection-item:hover {
background-color: var(--bg-tertiary);
}
/* Context Menu (placeholder for future implementation) */
.connection-context-menu {
position: fixed;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
padding: var(--space-2) 0;
min-width: 200px;
z-index: 1000;
}
.context-menu-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
cursor: pointer;
font-size: var(--text-sm);
color: var(--text-primary);
transition: background-color var(--transition-fast) var(--ease-in-out);
}
.context-menu-item:hover {
background-color: var(--bg-tertiary);
}
.context-menu-separator {
height: 1px;
background-color: var(--border);
margin: var(--space-2) 0;
}

View File

@@ -0,0 +1,434 @@
/**
* ConnectionPanel Component
*
* Left sidebar panel displaying database connections and schema tree.
* Based on layout-design.md section "左侧连接面板设计"
*/
import React, { useState, KeyboardEvent } from 'react';
import { StatusIndicator, StatusType } from '../common/StatusIndicator';
import './ConnectionPanel.css';
/**
* Database connection data structure
*/
export interface DatabaseConnection {
id: string;
name: string;
type: 'mysql' | 'postgresql' | 'sqlite' | 'mariadb';
host?: string;
port?: number;
status: StatusType;
databases?: Schema[];
}
/**
* Schema/database structure
*/
export interface Schema {
id: string;
name: string;
tables?: Table[];
views?: View[];
functions?: Function[];
procedures?: Procedure[];
}
/**
* Table structure
*/
export interface Table {
id: string;
name: string;
schema?: string;
}
/**
* View structure
*/
export interface View {
id: string;
name: string;
}
/**
* Function structure
*/
export interface Function {
id: string;
name: string;
}
/**
* Procedure structure
*/
export interface Procedure {
id: string;
name: string;
}
export interface ConnectionPanelProps {
/** List of database connections */
connections: DatabaseConnection[];
/** Currently selected connection ID */
selectedConnectionId?: string;
/** Currently active connection ID */
activeConnectionId?: string;
/** Handler when connection is clicked */
onConnectionClick?: (connection: DatabaseConnection) => void;
/** Handler when new connection is requested */
onNewConnection?: () => void;
/** Handler when connection context menu is requested */
onContextMenu?: (connection: DatabaseConnection, event: React.MouseEvent) => void;
/** Handler when table is double-clicked */
onTableDoubleClick?: (table: Table, schema: Schema, connection: DatabaseConnection) => void;
/** Collapsed state */
collapsed?: boolean;
}
/**
* Tree node item component for rendering hierarchical data
*/
interface TreeNodeProps {
label: string;
icon: string;
expanded?: boolean;
onClick?: () => void;
onToggle?: () => void;
children?: React.ReactNode;
level: number;
isActive?: boolean;
}
const TreeNode: React.FC<TreeNodeProps> = ({
label,
icon,
expanded,
onClick,
onToggle,
children,
level,
isActive = false,
}) => {
const hasChildren = children !== undefined && children !== null;
return (
<div className="tree-node">
<div
className={`tree-node-content ${isActive ? 'active' : ''}`}
style={{ paddingLeft: `${level * 16 + 8}px` }}
onClick={onClick}
role="treeitem"
tabIndex={0}
aria-expanded={hasChildren ? expanded : undefined}
aria-selected={isActive}
>
{hasChildren && (
<span
className={`tree-toggle ${expanded ? 'expanded' : ''}`}
onClick={(e) => {
e.stopPropagation();
onToggle?.();
}}
role="button"
tabIndex={-1}
>
</span>
)}
{!hasChildren && <span className="tree-toggle-placeholder" />}
<span className="tree-icon">{icon}</span>
<span className="tree-label">{label}</span>
</div>
{expanded && children && (
<div className="tree-node-children" role="group">
{children}
</div>
)}
</div>
);
};
/**
* ConnectionItem component renders a single connection with its schema tree
*/
interface ConnectionItemProps {
connection: DatabaseConnection;
isActive: boolean;
isSelected: boolean;
expandedSchemas: Set<string>;
expandedTables: Set<string>;
onToggleSchema: (schemaId: string) => void;
onToggleTable: (tableId: string) => void;
onClick: () => void;
onContextMenu: (event: React.MouseEvent) => void;
onTableDoubleClick: (table: Table, schema: Schema) => void;
}
const ConnectionItem: React.FC<ConnectionItemProps> = ({
connection,
isActive,
isSelected,
expandedSchemas,
expandedTables,
onToggleSchema,
onToggleTable,
onClick,
onContextMenu,
onTableDoubleClick,
}) => {
// Get database type icon
const getDbTypeIcon = (): string => {
switch (connection.type) {
case 'mysql':
return '🗄️';
case 'postgresql':
return '🐘';
case 'sqlite':
return '◪';
case 'mariadb':
return '🗄️';
default:
return '🗄️';
}
};
return (
<div
className={`connection-item ${isActive ? 'active' : ''} ${isSelected ? 'selected' : ''}`}
onClick={onClick}
onContextMenu={onContextMenu}
role="treeitem"
tabIndex={-1}
>
{/* Connection header */}
<div className="connection-header">
<StatusIndicator status={connection.status} />
<span className="db-type-icon">{getDbTypeIcon()}</span>
<span className="connection-name">{connection.name}</span>
</div>
{/* Schema tree - only show if connected and has databases */}
{(connection.status === 'connected' || connection.status === 'active') &&
connection.databases &&
connection.databases.map((schema) => {
const isSchemaExpanded = expandedSchemas.has(schema.id);
return (
<TreeNode
key={schema.id}
label={schema.name}
icon="📊"
level={1}
expanded={isSchemaExpanded}
onToggle={() => onToggleSchema(schema.id)}
>
{/* Tables */}
{schema.tables && schema.tables.length > 0 && (
<div>
{schema.tables.map((table) => {
const isTableExpanded = expandedTables.has(table.id);
return (
<TreeNode
key={table.id}
label={table.name}
icon="📋"
level={2}
expanded={isTableExpanded}
onToggle={() => onToggleTable(table.id)}
onClick={() => onTableDoubleClick(table, schema)}
/>
);
})}
</div>
)}
{/* Views count */}
{schema.views && schema.views.length > 0 && (
<TreeNode
label={`views (${schema.views.length})`}
icon="👁️"
level={2}
/>
)}
{/* Functions count */}
{schema.functions && schema.functions.length > 0 && (
<TreeNode
label={`functions (${schema.functions.length})`}
icon="⚡"
level={2}
/>
)}
{/* Procedures count */}
{schema.procedures && schema.procedures.length > 0 && (
<TreeNode
label={`procedures (${schema.procedures.length})`}
icon="📝"
level={2}
/>
)}
</TreeNode>
);
})}
</div>
);
};
/**
* Main ConnectionPanel component
*/
export const ConnectionPanel: React.FC<ConnectionPanelProps> = ({
connections,
selectedConnectionId,
activeConnectionId,
onConnectionClick,
onNewConnection,
onContextMenu,
onTableDoubleClick,
collapsed = false,
}) => {
// Track expanded schemas and tables
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
// Toggle schema expansion
const handleToggleSchema = (schemaId: string) => {
setExpandedSchemas((prev) => {
const next = new Set(prev);
if (next.has(schemaId)) {
next.delete(schemaId);
} else {
next.add(schemaId);
}
return next;
});
};
// Toggle table expansion
const handleToggleTable = (tableId: string) => {
setExpandedTables((prev) => {
const next = new Set(prev);
if (next.has(tableId)) {
next.delete(tableId);
} else {
next.add(tableId);
}
return next;
});
};
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
// TODO: Implement arrow key navigation
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
// Navigate up
break;
case 'ArrowDown':
e.preventDefault();
// Navigate down
break;
case 'ArrowRight':
e.preventDefault();
// Expand node or connect
break;
case 'ArrowLeft':
e.preventDefault();
// Collapse node
break;
case 'Enter':
e.preventDefault();
// Connect/confirm
break;
case ' ':
e.preventDefault();
// Toggle expand/collapse
break;
case 'F2':
e.preventDefault();
// Rename (not implemented)
break;
case 'Delete':
e.preventDefault();
// Delete connection (not implemented)
break;
case 'F5':
e.preventDefault();
// Refresh schema (not implemented)
break;
}
};
if (collapsed) {
return (
<div className="connection-panel-collapsed">
<div className="collapsed-connections">
{connections.map((conn) => (
<div
key={conn.id}
className="collapsed-connection-item"
title={conn.name}
onClick={() => onConnectionClick?.(conn)}
>
<StatusIndicator status={conn.status} />
</div>
))}
</div>
</div>
);
}
return (
<div className="connection-panel" onKeyDown={handleKeyDown}>
{/* Panel Header */}
<div className="connection-panel-header">
<h3 className="connection-panel-title">
🗄 Connections
</h3>
<button className="btn-icon" title="Settings" aria-label="Connection settings">
</button>
</div>
{/* Connection List */}
<div className="connection-list" role="tree" aria-label="Database connections">
{connections.length === 0 ? (
<div className="empty-state">
<p>No connections yet</p>
</div>
) : (
connections.map((connection) => (
<ConnectionItem
key={connection.id}
connection={connection}
isActive={connection.id === activeConnectionId}
isSelected={connection.id === selectedConnectionId}
expandedSchemas={expandedSchemas}
expandedTables={expandedTables}
onToggleSchema={handleToggleSchema}
onToggleTable={handleToggleTable}
onClick={() => onConnectionClick?.(connection)}
onContextMenu={(e) => onContextMenu?.(connection, e)}
onTableDoubleClick={(table, schema) =>
onTableDoubleClick?.(table, schema, connection)
}
/>
))
)}
</div>
{/* New Connection Button */}
<div className="connection-panel-footer">
<button className="btn btn-primary btn-new-connection" onClick={onNewConnection}>
+ New Connection
</button>
</div>
</div>
);
};
export default ConnectionPanel;

View File

@@ -0,0 +1,73 @@
/**
* StatusIndicator Component Styles
*/
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: var(--space-2);
transition: all var(--transition-normal) var(--ease-in-out);
flex-shrink: 0;
}
/* Connected state - Green with glow */
.status-indicator.status-connected {
background-color: var(--success);
box-shadow: 0 0 4px var(--success);
}
/* Active state - Blue with pulse animation */
.status-indicator.status-active {
background-color: var(--primary);
box-shadow: 0 0 8px var(--primary);
animation: pulse 2s infinite;
}
/* Disconnected state - Gray with border */
.status-indicator.status-disconnected {
background-color: var(--text-muted);
border: 1px solid var(--border);
}
/* Connecting state - Orange spinning animation */
.status-indicator.status-connecting {
width: 10px;
height: 10px;
border: 2px solid var(--warning);
border-top-color: transparent;
animation: spin 1s linear infinite;
}
/* Error state - Red with help cursor */
.status-indicator.status-error {
background-color: var(--error);
cursor: help;
}
/* Hover effect for clickable indicators */
.status-indicator[tabindex]:hover {
transform: scale(1.2);
}
.status-indicator[tabindex]:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,93 @@
/**
* StatusIndicator Component
*
* Displays connection status with appropriate color and animation.
* Based on layout-design.md section "连接状态指示器"
*/
import React from 'react';
import './StatusIndicator.css';
/**
* Connection status types
*/
export type StatusType =
| 'connected' // Green - Connection successful and available
| 'active' // Blue - Currently in use with pulse effect
| 'disconnected' // Gray - Saved but not connected
| 'connecting' // Orange - Establishing connection (spinning)
| 'error'; // Red - Connection failed
export interface StatusIndicatorProps {
/** Current connection status */
status: StatusType;
/** Optional tooltip text shown on hover */
tooltip?: string;
/** Additional CSS class name */
className?: string;
/** Click handler */
onClick?: () => void;
}
/**
* StatusIndicator component renders a colored dot indicating connection state
*/
export const StatusIndicator: React.FC<StatusIndicatorProps> = ({
status,
tooltip,
className = '',
onClick,
}) => {
// Map status to CSS class
const getStatusClass = (): string => {
switch (status) {
case 'connected':
return 'status-connected';
case 'active':
return 'status-active';
case 'disconnected':
return 'status-disconnected';
case 'connecting':
return 'status-connecting';
case 'error':
return 'status-error';
default:
return 'status-disconnected';
}
};
// Get aria label for accessibility
const getAriaLabel = (): string => {
switch (status) {
case 'connected':
return 'Connected';
case 'active':
return 'Active connection';
case 'disconnected':
return 'Disconnected';
case 'connecting':
return 'Connecting...';
case 'error':
return 'Connection error';
}
};
return (
<span
className={`status-indicator ${getStatusClass()} ${className}`}
role="status"
aria-label={getAriaLabel()}
title={tooltip}
onClick={onClick}
tabIndex={onClick ? 0 : undefined}
onKeyDown={(e) => {
if (onClick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick();
}
}}
/>
);
};
export default StatusIndicator;

View File

@@ -0,0 +1,63 @@
/**
* uzdb Frontend Components
*
* Central export file for all components.
*/
// Common components
export { StatusIndicator } from './common/StatusIndicator';
export type { StatusIndicatorProps, StatusType } from './common/StatusIndicator';
// Layout components
export { AppLayout } from './Layout/AppLayout';
export type { AppLayoutProps } from './Layout/AppLayout';
export { StatusBar } from './Layout/StatusBar';
export type { StatusBarProps, StatusType as StatusBarStatusType } from './Layout/StatusBar';
export { ToolBar } from './Layout/ToolBar';
export type { ToolBarProps, ToolButton } from './Layout/ToolBar';
// MenuBar components
export { MenuBar } from './MenuBar/MenuBar';
export type { MenuBarProps, MenuItem, MenuDefinition } from './MenuBar/MenuBar';
// Sidebar components
export { ConnectionPanel } from './Sidebar/ConnectionPanel';
export type {
ConnectionPanelProps,
DatabaseConnection,
Schema,
Table,
View,
Function,
Procedure,
} from './Sidebar/ConnectionPanel';
// MainArea components
export { QueryEditor } from './MainArea/QueryEditor';
export type {
QueryEditorProps,
QueryTab,
QueryResult,
} from './MainArea/QueryEditor';
export { DataGrid } from './MainArea/DataGrid';
export type {
DataGridProps,
Column,
DataRow,
PaginationState,
SortState,
FilterState,
} from './MainArea/DataGrid';
export { TableStructure } from './MainArea/TableStructure';
export type {
TableStructureProps,
TableColumn,
Index,
ForeignKey,
TableInfo,
StructureTab,
} from './MainArea/TableStructure';

364
uzdb/frontend/src/index.css Normal file
View File

@@ -0,0 +1,364 @@
/**
* uzdb Global Styles & CSS Variables
* Based on design-system.md specifications
*/
/* ============================================
CSS Custom Properties (Design Tokens)
============================================ */
:root {
/* Primary Colors */
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-active: #1d4ed8;
/* Semantic Colors */
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--info: #06b6d4;
/* Neutral Colors */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--border: #e2e8f0;
--text-primary: #0f172a;
--text-secondary: #64748b;
--text-muted: #94a3b8;
/* Database Type Colors */
--db-mysql: #00758f;
--db-postgresql: #336791;
--db-sqlite: #003b57;
--db-mariadb: #003541;
/* Typography */
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
/* Font Sizes */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
--text-lg: 18px;
--text-xl: 20px;
--text-2xl: 24px;
/* Spacing (4px grid) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
/* Layout Dimensions */
--sidebar-width: 240px;
--sidebar-min-width: 180px;
--sidebar-max-width: 400px;
--menubar-height: 32px;
--toolbar-height: 40px;
--statusbar-height: 28px;
--tab-height: 36px;
--row-height: 36px;
--header-height: 40px;
/* Transitions */
--transition-fast: 100ms;
--transition-normal: 150ms;
--transition-slow: 300ms;
--ease-in-out: ease-in-out;
/* Border Radius */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* ============================================
Reset & Base Styles
============================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
font-size: var(--text-sm);
line-height: 1.5;
color: var(--text-primary);
background-color: var(--bg-primary);
overflow: hidden;
}
#root {
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* ============================================
Typography
============================================ */
h1, h2, h3, h4, h5, h6 {
margin: 0;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: var(--text-2xl); }
h2 { font-size: var(--text-xl); }
h3 { font-size: var(--text-lg); }
h4 { font-size: var(--text-base); }
p {
margin: 0;
}
a {
color: var(--primary);
text-decoration: none;
transition: color var(--transition-normal) var(--ease-in-out);
}
a:hover {
color: var(--primary-hover);
text-decoration: underline;
}
/* ============================================
Focus States (Accessibility)
============================================ */
:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--primary);
outline-offset: 2px;
}
/* ============================================
Button Styles
============================================ */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
font-size: var(--text-sm);
font-weight: 500;
line-height: 1;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-normal) var(--ease-in-out);
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: var(--primary-hover);
}
.btn-primary:active:not(:disabled) {
background-color: var(--primary-active);
}
.btn-secondary {
background-color: transparent;
border: 1px solid var(--border);
color: var(--text-primary);
}
.btn-secondary:hover:not(:disabled) {
background-color: var(--bg-tertiary);
border-color: var(--text-secondary);
}
.btn-icon {
padding: var(--space-2);
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast) var(--ease-in-out);
}
.btn-icon:hover:not(:disabled) {
background-color: var(--bg-tertiary);
color: var(--text-primary);
}
/* ============================================
Input Styles
============================================ */
.input {
width: 100%;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
font-family: var(--font-sans);
color: var(--text-primary);
background-color: var(--bg-primary);
border: 1px solid var(--border);
border-radius: var(--radius-md);
transition: all var(--transition-fast) var(--ease-in-out);
}
.input::placeholder {
color: var(--text-muted);
}
.input:hover {
border-color: var(--text-secondary);
}
.input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.input:disabled {
background-color: var(--bg-tertiary);
cursor: not-allowed;
opacity: 0.7;
}
/* ============================================
Scrollbar Styles
============================================ */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* ============================================
Animation Keyframes
============================================ */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideIn {
from {
transform: translateY(-10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* ============================================
Utility Classes
============================================ */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 { gap: var(--space-1); }
.gap-2 { gap: var(--space-2); }
.gap-3 { gap: var(--space-3); }
.gap-4 { gap: var(--space-4); }
.text-xs { font-size: var(--text-xs); }
.text-sm { font-size: var(--text-sm); }
.text-base { font-size: var(--text-base); }
.text-primary { color: var(--text-primary); }
.text-secondary { color: var(--text-secondary); }
.text-muted { color: var(--text-muted); }

View File

@@ -0,0 +1,14 @@
import React from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import App from './App'
const container = document.getElementById('root')
const root = createRoot(container!)
root.render(
<React.StrictMode>
<App/>
</React.StrictMode>
)

View File

@@ -0,0 +1,134 @@
/**
* Mock Database Connections Data
*
* Sample data for development and testing purposes.
*/
import { DatabaseConnection } from '../components/Sidebar/ConnectionPanel';
/**
* Sample database connections
*/
export const mockConnections: DatabaseConnection[] = [
{
id: 'conn-1',
name: 'MySQL @ localhost',
type: 'mysql',
host: 'localhost',
port: 3306,
status: 'active',
databases: [
{
id: 'schema-1',
name: 'public',
tables: [
{ id: 'table-1', name: 'users', schema: 'public' },
{ id: 'table-2', name: 'products', schema: 'public' },
{ id: 'table-3', name: 'orders', schema: 'public' },
{ id: 'table-4', name: 'order_items', schema: 'public' },
{ id: 'table-5', name: 'categories', schema: 'public' },
],
views: [
{ id: 'view-1', name: 'active_users' },
{ id: 'view-2', name: 'product_stats' },
],
functions: [
{ id: 'func-1', name: 'calculate_total' },
{ id: 'func-2', name: 'get_user_role' },
],
procedures: [
{ id: 'proc-1', name: 'sp_create_order' },
{ id: 'proc-2', name: 'sp_update_inventory' },
],
},
{
id: 'schema-2',
name: 'information_schema',
tables: [],
},
],
},
{
id: 'conn-2',
name: 'PostgreSQL @ prod-db',
type: 'postgresql',
host: 'prod-db.example.com',
port: 5432,
status: 'connected',
databases: [
{
id: 'schema-3',
name: 'public',
tables: [
{ id: 'table-6', name: 'customers', schema: 'public' },
{ id: 'table-7', name: 'transactions', schema: 'public' },
{ id: 'table-8', name: 'audit_log', schema: 'public' },
],
views: [{ id: 'view-3', name: 'customer_summary' }],
functions: [{ id: 'func-3', name: 'generate_report' }],
procedures: [],
},
],
},
{
id: 'conn-3',
name: 'SQLite @ local',
type: 'sqlite',
status: 'disconnected',
databases: [],
},
{
id: 'conn-4',
name: 'MariaDB @ staging',
type: 'mariadb',
host: 'staging.example.com',
port: 3306,
status: 'error',
databases: [],
},
{
id: 'conn-5',
name: 'MySQL @ dev-server',
type: 'mysql',
host: 'dev.example.com',
port: 3306,
status: 'connecting',
databases: [],
},
];
/**
* Get connection by ID
*/
export const getConnectionById = (id: string): DatabaseConnection | undefined => {
return mockConnections.find((conn) => conn.id === id);
};
/**
* Get connections by status
*/
export const getConnectionsByStatus = (status: DatabaseConnection['status']): DatabaseConnection[] => {
return mockConnections.filter((conn) => conn.status === status);
};
/**
* Add a new mock connection (for testing)
*/
export const addMockConnection = (connection: DatabaseConnection): void => {
mockConnections.push(connection);
};
/**
* Update connection status (for testing)
*/
export const updateConnectionStatus = (
id: string,
status: DatabaseConnection['status']
): void => {
const conn = mockConnections.find((c) => c.id === id);
if (conn) {
conn.status = status;
}
};
export default mockConnections;

View File

@@ -0,0 +1,232 @@
/**
* Mock Query Results Data
*
* Sample data for development and testing purposes.
*/
import { QueryResult, QueryTab } from '../components/MainArea/QueryEditor';
import { TableColumn, Index, ForeignKey, TableInfo } from '../components/MainArea/TableStructure';
import { Column, DataRow } from '../components/MainArea/DataGrid';
/**
* Sample query results
*/
export const mockQueryResults: QueryResult = {
columns: ['id', 'name', 'email', 'created_at', 'active', 'role'],
rows: [
[1, 'Alice Johnson', 'alice@example.com', '2024-01-15 10:30:00', true, 'admin'],
[2, 'Bob Smith', 'bob@example.com', '2024-01-16 14:22:00', true, 'user'],
[3, 'Carol Williams', 'carol@example.com', '2024-01-17 09:15:00', false, 'user'],
[4, 'David Brown', 'david@example.com', '2024-01-18 16:45:00', true, 'moderator'],
[5, 'Eve Davis', 'eve@example.com', '2024-01-19 11:00:00', true, 'user'],
[6, 'Frank Miller', 'frank@example.com', '2024-01-20 08:30:00', true, 'user'],
[7, 'Grace Wilson', 'grace@example.com', '2024-01-21 13:20:00', true, 'user'],
[8, 'Henry Moore', 'henry@example.com', '2024-01-22 15:10:00', false, 'user'],
[9, 'Ivy Taylor', 'ivy@example.com', '2024-01-23 10:45:00', true, 'admin'],
[10, 'Jack Anderson', 'jack@example.com', '2024-01-24 12:00:00', true, 'user'],
],
rowCount: 127,
executionTime: 0.045,
message: '127 rows affected',
};
/**
* Sample error query result
*/
export const mockErrorResult: QueryResult = {
columns: [],
rows: [],
error: "ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'SELEC * FROM users' at line 1",
};
/**
* Sample query tabs
*/
export const mockQueryTabs: QueryTab[] = [
{
id: 'tab-1',
title: 'query_1.sql',
content: `-- Get active users with their orders
SELECT
u.id,
u.name,
u.email,
COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.active = true
AND u.created_at >= '2024-01-01'
GROUP BY u.id, u.name, u.email
HAVING COUNT(o.id) > 0
ORDER BY order_count DESC
LIMIT 100;`,
isDirty: false,
},
{
id: 'tab-2',
title: 'unsaved_query.sql',
content: `SELECT * FROM products WHERE price > 100;`,
isDirty: true,
},
];
/**
* Sample table columns
*/
export const mockTableColumns: TableColumn[] = [
{
name: 'id',
type: 'INT',
nullable: false,
isPrimaryKey: true,
extra: 'auto_increment',
},
{
name: 'name',
type: 'VARCHAR(100)',
nullable: false,
},
{
name: 'email',
type: 'VARCHAR(255)',
nullable: false,
isUnique: true,
},
{
name: 'created_at',
type: 'TIMESTAMP',
nullable: true,
defaultValue: 'NOW()',
},
{
name: 'active',
type: 'BOOLEAN',
nullable: false,
defaultValue: 'TRUE',
},
{
name: 'role',
type: 'ENUM(\'user\', \'admin\', \'moderator\')',
nullable: false,
defaultValue: "'user'",
},
];
/**
* Sample indexes
*/
export const mockIndexes: Index[] = [
{
name: 'PRIMARY',
type: 'BTREE',
columns: ['id'],
isUnique: true,
method: 'BTREE',
},
{
name: 'idx_email',
type: 'BTREE',
columns: ['email'],
isUnique: true,
method: 'BTREE',
},
{
name: 'idx_created_at',
type: 'BTREE',
columns: ['created_at'],
isUnique: false,
method: 'BTREE',
},
];
/**
* Sample foreign keys
*/
export const mockForeignKeys: ForeignKey[] = [
{
name: 'fk_user_role',
column: 'role_id',
referencesTable: 'roles',
referencesColumn: 'id',
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
},
{
name: 'fk_user_department',
column: 'department_id',
referencesTable: 'departments',
referencesColumn: 'id',
onUpdate: 'CASCADE',
onDelete: 'RESTRICT',
},
];
/**
* Sample table info
*/
export const mockTableInfo: TableInfo = {
engine: 'InnoDB',
collation: 'utf8mb4_unicode_ci',
rowCount: 1247,
size: '256 KB',
autoIncrement: 1248,
comment: 'User accounts table',
};
/**
* Sample data grid columns
*/
export const mockDataGridColumns: Column[] = [
{ id: 'id', name: 'ID', type: 'INT', width: 80, sortable: true },
{ id: 'name', name: 'Name', type: 'VARCHAR', width: 200, sortable: true, editable: true },
{ id: 'email', name: 'Email', type: 'VARCHAR', width: 250, sortable: true, editable: true },
{ id: 'created_at', name: 'Created At', type: 'TIMESTAMP', width: 180, sortable: true },
{ id: 'active', name: 'Active', type: 'BOOLEAN', width: 80, sortable: true, editable: true },
{ id: 'role', name: 'Role', type: 'ENUM', width: 120, sortable: true, editable: true },
];
/**
* Sample data grid rows
*/
export const mockDataGridRows: DataRow[] = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', created_at: '2024-01-15', active: true, role: 'admin' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', created_at: '2024-01-16', active: true, role: 'user' },
{ id: 3, name: 'Carol Williams', email: 'carol@example.com', created_at: '2024-01-17', active: false, role: 'user' },
{ id: 4, name: 'David Brown', email: 'david@example.com', created_at: '2024-01-18', active: true, role: 'moderator' },
{ id: 5, name: 'Eve Davis', email: 'eve@example.com', created_at: '2024-01-19', active: true, role: 'user' },
{ id: 6, name: 'Frank Miller', email: 'frank@example.com', created_at: '2024-01-20', active: true, role: 'user' },
{ id: 7, name: 'Grace Wilson', email: 'grace@example.com', created_at: '2024-01-21', active: true, role: 'user' },
{ id: 8, name: 'Henry Moore', email: 'henry@example.com', created_at: '2024-01-22', active: false, role: 'user' },
{ id: 9, name: 'Ivy Taylor', email: 'ivy@example.com', created_at: '2024-01-23', active: true, role: 'admin' },
{ id: 10, name: 'Jack Anderson', email: 'jack@example.com', created_at: '2024-01-24', active: true, role: 'user' },
];
/**
* Generate more rows for testing pagination
*/
export const generateMockRows = (count: number): DataRow[] => {
const roles = ['user', 'admin', 'moderator'];
const firstNames = ['Alice', 'Bob', 'Carol', 'David', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack'];
const lastNames = ['Johnson', 'Smith', 'Williams', 'Brown', 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor', 'Anderson'];
return Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `${firstNames[i % firstNames.length]} ${lastNames[i % lastNames.length]}`,
email: `user${i + 1}@example.com`,
created_at: `2024-01-${((i % 31) + 1).toString().padStart(2, '0')}`,
active: i % 5 !== 0,
role: roles[i % roles.length],
}));
};
export default {
mockQueryResults,
mockErrorResult,
mockQueryTabs,
mockTableColumns,
mockIndexes,
mockForeignKeys,
mockTableInfo,
mockDataGridColumns,
mockDataGridRows,
};

View File

@@ -0,0 +1,40 @@
/**
* uzdb Global Styles
*
* Base styles for the application.
* Additional styles are in index.css
*/
html {
background-color: var(--bg-primary);
}
body {
margin: 0;
padding: 0;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* Prevent text selection in UI elements */
.menubar,
.toolbar,
.statusbar,
.view-tabs,
.query-tabs {
user-select: none;
}
/* Allow text selection in content areas */
.sql-editor,
.data-grid-table,
.structure-table {
user-select: text;
}

1
uzdb/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()]
})

4
uzdb/frontend/wailsjs/go/main/App.d.ts vendored Executable file
View File

@@ -0,0 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1:string):Promise<string>;

View File

@@ -0,0 +1,7 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

82
uzdb/go.mod Normal file
View File

@@ -0,0 +1,82 @@
module uzdb
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.9.3
github.com/lib/pq v1.12.0
github.com/wailsapp/wails/v2 v2.12.0
go.uber.org/zap v1.27.1
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
modernc.org/sqlite v1.48.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
// replace github.com/wailsapp/wails/v2 v2.12.0 => /root/go/pkg/mod

219
uzdb/go.sum Normal file
View File

@@ -0,0 +1,219 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c=
github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

324
uzdb/internal/app/app.go Normal file
View File

@@ -0,0 +1,324 @@
package app
import (
"context"
"time"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/database"
"uzdb/internal/handler"
"uzdb/internal/models"
"uzdb/internal/services"
)
// App is the main application structure for Wails bindings
type App struct {
ctx context.Context
config *config.Config
connectionSvc *services.ConnectionService
querySvc *services.QueryService
httpServer *handler.HTTPServer
shutdownFunc context.CancelFunc
}
// NewApp creates a new App instance
func NewApp() *App {
return &App{}
}
// Initialize initializes the application with all services
func (a *App) Initialize(
cfg *config.Config,
connectionSvc *services.ConnectionService,
querySvc *services.QueryService,
httpServer *handler.HTTPServer,
) {
a.config = cfg
a.connectionSvc = connectionSvc
a.querySvc = querySvc
a.httpServer = httpServer
}
// OnStartup is called when the app starts (public method for Wails)
func (a *App) OnStartup(ctx context.Context) {
a.ctx = ctx
config.GetLogger().Info("Wails application started")
}
// GetConnections returns all user connections
// Wails binding: frontend can call window.go.app.GetConnections()
func (a *App) GetConnections() []models.UserConnection {
if a.connectionSvc == nil {
return []models.UserConnection{}
}
connections, err := a.connectionSvc.GetAllConnections(a.ctx)
if err != nil {
config.GetLogger().Error("failed to get connections", zap.Error(err))
return []models.UserConnection{}
}
return connections
}
// CreateConnection creates a new database connection
// Returns error message or empty string on success
func (a *App) CreateConnection(conn models.CreateConnectionRequest) string {
if a.connectionSvc == nil {
return "Service not initialized"
}
_, err := a.connectionSvc.CreateConnection(a.ctx, &conn)
if err != nil {
config.GetLogger().Error("failed to create connection", zap.Error(err))
return err.Error()
}
return ""
}
// UpdateConnection updates an existing database connection
// Returns error message or empty string on success
func (a *App) UpdateConnection(conn models.UserConnection) string {
if a.connectionSvc == nil {
return "Service not initialized"
}
req := &models.UpdateConnectionRequest{
Name: conn.Name,
Type: conn.Type,
Host: conn.Host,
Port: conn.Port,
Username: conn.Username,
Password: conn.Password,
Database: conn.Database,
SSLMode: conn.SSLMode,
Timeout: conn.Timeout,
}
_, err := a.connectionSvc.UpdateConnection(a.ctx, conn.ID, req)
if err != nil {
config.GetLogger().Error("failed to update connection", zap.Error(err))
return err.Error()
}
return ""
}
// DeleteConnection deletes a database connection
// Returns error message or empty string on success
func (a *App) DeleteConnection(id string) string {
if a.connectionSvc == nil {
return "Service not initialized"
}
err := a.connectionSvc.DeleteConnection(a.ctx, id)
if err != nil {
config.GetLogger().Error("failed to delete connection", zap.Error(err))
return err.Error()
}
return ""
}
// TestConnection tests a database connection
// Returns (success, error_message)
func (a *App) TestConnection(id string) (bool, string) {
if a.connectionSvc == nil {
return false, "Service not initialized"
}
result, err := a.connectionSvc.TestConnection(a.ctx, id)
if err != nil {
config.GetLogger().Error("failed to test connection", zap.Error(err))
return false, err.Error()
}
return result.Success, result.Message
}
// ExecuteQuery executes a SQL query on a connection
// Returns query result or error message
func (a *App) ExecuteQuery(connectionID, sql string) (*models.QueryResult, string) {
if a.connectionSvc == nil {
return nil, "Service not initialized"
}
result, err := a.connectionSvc.ExecuteQuery(a.ctx, connectionID, sql)
if err != nil {
config.GetLogger().Error("failed to execute query",
zap.String("connection_id", connectionID),
zap.String("sql", sql),
zap.Error(err))
return nil, err.Error()
}
return result, ""
}
// GetTables returns all tables for a connection
func (a *App) GetTables(connectionID string) ([]models.Table, string) {
if a.connectionSvc == nil {
return []models.Table{}, "Service not initialized"
}
tables, err := a.connectionSvc.GetTables(a.ctx, connectionID)
if err != nil {
config.GetLogger().Error("failed to get tables",
zap.String("connection_id", connectionID),
zap.Error(err))
return []models.Table{}, err.Error()
}
return tables, ""
}
// GetTableData returns data from a table
func (a *App) GetTableData(connectionID, tableName string, limit, offset int) (*models.QueryResult, string) {
if a.connectionSvc == nil {
return nil, "Service not initialized"
}
result, err := a.connectionSvc.GetTableData(a.ctx, connectionID, tableName, limit, offset)
if err != nil {
config.GetLogger().Error("failed to get table data",
zap.String("connection_id", connectionID),
zap.String("table", tableName),
zap.Error(err))
return nil, err.Error()
}
return result, ""
}
// GetTableStructure returns the structure of a table
func (a *App) GetTableStructure(connectionID, tableName string) (*models.TableStructure, string) {
if a.connectionSvc == nil {
return nil, "Service not initialized"
}
structure, err := a.connectionSvc.GetTableStructure(a.ctx, connectionID, tableName)
if err != nil {
config.GetLogger().Error("failed to get table structure",
zap.String("connection_id", connectionID),
zap.String("table", tableName),
zap.Error(err))
return nil, err.Error()
}
return structure, ""
}
// GetQueryHistory returns query history with pagination
func (a *App) GetQueryHistory(connectionID string, page, pageSize int) ([]models.QueryHistory, int64, string) {
if a.querySvc == nil {
return []models.QueryHistory{}, 0, "Service not initialized"
}
history, total, err := a.querySvc.GetQueryHistory(a.ctx, connectionID, page, pageSize)
if err != nil {
config.GetLogger().Error("failed to get query history", zap.Error(err))
return []models.QueryHistory{}, 0, err.Error()
}
return history, total, ""
}
// GetSavedQueries returns all saved queries
func (a *App) GetSavedQueries(connectionID string) ([]models.SavedQuery, string) {
if a.querySvc == nil {
return []models.SavedQuery{}, "Service not initialized"
}
queries, err := a.querySvc.GetSavedQueries(a.ctx, connectionID)
if err != nil {
config.GetLogger().Error("failed to get saved queries", zap.Error(err))
return []models.SavedQuery{}, err.Error()
}
return queries, ""
}
// CreateSavedQuery creates a new saved query
func (a *App) CreateSavedQuery(req models.CreateSavedQueryRequest) (*models.SavedQuery, string) {
if a.querySvc == nil {
return nil, "Service not initialized"
}
query, err := a.querySvc.CreateSavedQuery(a.ctx, &req)
if err != nil {
config.GetLogger().Error("failed to create saved query", zap.Error(err))
return nil, err.Error()
}
return query, ""
}
// UpdateSavedQuery updates a saved query
func (a *App) UpdateSavedQuery(id uint, req models.UpdateSavedQueryRequest) (*models.SavedQuery, string) {
if a.querySvc == nil {
return nil, "Service not initialized"
}
query, err := a.querySvc.UpdateSavedQuery(a.ctx, id, &req)
if err != nil {
config.GetLogger().Error("failed to update saved query", zap.Error(err))
return nil, err.Error()
}
return query, ""
}
// DeleteSavedQuery deletes a saved query
func (a *App) DeleteSavedQuery(id uint) string {
if a.querySvc == nil {
return "Service not initialized"
}
err := a.querySvc.DeleteSavedQuery(a.ctx, id)
if err != nil {
config.GetLogger().Error("failed to delete saved query", zap.Error(err))
return err.Error()
}
return ""
}
// StartHTTPServer starts the HTTP API server in background
func (a *App) StartHTTPServer() string {
if a.httpServer == nil {
return "HTTP server not initialized"
}
go func() {
port := a.config.API.Port
if err := a.httpServer.Start(port); err != nil {
config.GetLogger().Error("HTTP server error", zap.Error(err))
}
}()
return ""
}
// Shutdown gracefully shuts down the application
func (a *App) Shutdown() {
config.GetLogger().Info("shutting down application")
if a.shutdownFunc != nil {
a.shutdownFunc()
}
if a.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
a.httpServer.Shutdown(ctx)
}
// Close all database connections
database.CloseSQLite()
config.Sync()
}

View File

@@ -0,0 +1,261 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
// Environment represents the application environment type
type Environment string
const (
// EnvDevelopment represents development environment
EnvDevelopment Environment = "development"
// EnvProduction represents production environment
EnvProduction Environment = "production"
)
// Config holds all configuration for the application
type Config struct {
// App settings
AppName string `json:"app_name"`
Version string `json:"version"`
Environment Environment `json:"environment"`
// Database settings (SQLite for app data)
Database DatabaseConfig `json:"database"`
// Encryption settings
Encryption EncryptionConfig `json:"encryption"`
// Logger settings
Logger LoggerConfig `json:"logger"`
// API settings (for debug HTTP server)
API APIConfig `json:"api"`
// File paths
DataDir string `json:"-"`
}
// DatabaseConfig holds database configuration
type DatabaseConfig struct {
// SQLite database file path for app data
SQLitePath string `json:"sqlite_path"`
// Max open connections
MaxOpenConns int `json:"max_open_conns"`
// Max idle connections
MaxIdleConns int `json:"max_idle_conns"`
// Connection max lifetime in minutes
MaxLifetime int `json:"max_lifetime"`
}
// EncryptionConfig holds encryption configuration
type EncryptionConfig struct {
// Key for encrypting sensitive data (passwords, etc.)
// In production, this should be loaded from secure storage
Key string `json:"-"`
// KeyFile path to load encryption key from
KeyFile string `json:"key_file"`
}
// LoggerConfig holds logger configuration
type LoggerConfig struct {
// Log level: debug, info, warn, error
Level string `json:"level"`
// Log format: json, console
Format string `json:"format"`
// Output file path (empty for stdout)
OutputPath string `json:"output_path"`
}
// APIConfig holds HTTP API configuration
type APIConfig struct {
// Enable HTTP API server (for debugging)
Enabled bool `json:"enabled"`
// Port for HTTP API server
Port string `json:"port"`
}
var (
instance *Config
once sync.Once
logger *zap.Logger
)
// Get returns the singleton config instance
func Get() *Config {
return instance
}
// GetLogger returns the zap logger
func GetLogger() *zap.Logger {
return logger
}
// Init initializes the configuration
// If config file doesn't exist, creates default config
func Init(dataDir string) (*Config, error) {
var err error
once.Do(func() {
instance = &Config{
DataDir: dataDir,
}
err = instance.load(dataDir)
})
return instance, err
}
// load loads configuration from file or creates default
func (c *Config) load(dataDir string) error {
configPath := filepath.Join(dataDir, "config.json")
// Try to load existing config
if _, err := os.Stat(configPath); err == nil {
data, err := os.ReadFile(configPath)
if err != nil {
return err
}
if err := json.Unmarshal(data, c); err != nil {
return err
}
} else {
// Create default config
c.setDefaults()
if err := c.save(configPath); err != nil {
return err
}
}
// Override with environment variables
c.loadEnv()
// Initialize logger
if err := c.initLogger(); err != nil {
return err
}
logger.Info("configuration loaded",
zap.String("environment", string(c.Environment)),
zap.String("data_dir", c.DataDir),
)
return nil
}
// setDefaults sets default configuration values
func (c *Config) setDefaults() {
c.AppName = "uzdb"
c.Version = "1.0.0"
c.Environment = EnvDevelopment
c.Database = DatabaseConfig{
SQLitePath: filepath.Join(c.DataDir, "uzdb.db"),
MaxOpenConns: 25,
MaxIdleConns: 5,
MaxLifetime: 5,
}
c.Encryption = EncryptionConfig{
Key: "", // Will be generated if empty
KeyFile: filepath.Join(c.DataDir, "encryption.key"),
}
c.Logger = LoggerConfig{
Level: "debug",
Format: "console",
OutputPath: "",
}
c.API = APIConfig{
Enabled: true,
Port: "8080",
}
}
// loadEnv loads configuration from environment variables
func (c *Config) loadEnv() {
if env := os.Getenv("UZDB_ENV"); env != "" {
c.Environment = Environment(env)
}
if port := os.Getenv("UZDB_API_PORT"); port != "" {
c.API.Port = port
}
if logLevel := os.Getenv("UZDB_LOG_LEVEL"); logLevel != "" {
c.Logger.Level = logLevel
}
if dbPath := os.Getenv("UZDB_DB_PATH"); dbPath != "" {
c.Database.SQLitePath = dbPath
}
}
// save saves configuration to file
func (c *Config) save(path string) error {
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0600)
}
// initLogger initializes the zap logger
func (c *Config) initLogger() error {
var cfg zap.Config
switch c.Logger.Format {
case "json":
cfg = zap.NewProductionConfig()
default:
cfg = zap.NewDevelopmentConfig()
}
// Set log level
level, parseErr := zapcore.ParseLevel(c.Logger.Level)
if parseErr != nil {
level = zapcore.InfoLevel
}
cfg.Level.SetLevel(level)
// Configure output
if c.Logger.OutputPath != "" {
cfg.OutputPaths = []string{c.Logger.OutputPath}
cfg.ErrorOutputPaths = []string{c.Logger.OutputPath}
}
var buildErr error
logger, buildErr = cfg.Build(
zap.AddCaller(),
zap.AddStacktrace(zapcore.ErrorLevel),
)
if buildErr != nil {
return buildErr
}
return nil
}
// IsDevelopment returns true if running in development mode
func (c *Config) IsDevelopment() bool {
return c.Environment == EnvDevelopment
}
// IsProduction returns true if running in production mode
func (c *Config) IsProduction() bool {
return c.Environment == EnvProduction
}
// Sync flushes any buffered log entries
func Sync() error {
if logger != nil {
return logger.Sync()
}
return nil
}

View File

@@ -0,0 +1,137 @@
package database
import (
"database/sql"
"fmt"
"sync"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
)
// ConnectionManager manages database connections for different database types
type ConnectionManager struct {
connections map[string]DatabaseConnector
mu sync.RWMutex
}
// DatabaseConnector is the interface for all database connections
type DatabaseConnector interface {
GetDB() *sql.DB
Close() error
IsConnected() bool
ExecuteQuery(sql string, args ...interface{}) (*models.QueryResult, error)
ExecuteStatement(sql string, args ...interface{}) (*models.QueryResult, error)
GetTables(schema string) ([]models.Table, error)
GetTableStructure(tableName string) (*models.TableStructure, error)
GetMetadata() (*models.DBMetadata, error)
}
// NewConnectionManager creates a new connection manager
func NewConnectionManager() *ConnectionManager {
return &ConnectionManager{
connections: make(map[string]DatabaseConnector),
}
}
// GetConnection gets or creates a connection for a user connection config
func (m *ConnectionManager) GetConnection(conn *models.UserConnection, password string) (DatabaseConnector, error) {
m.mu.Lock()
defer m.mu.Unlock()
// Check if connection already exists
if existing, ok := m.connections[conn.ID]; ok {
if existing.IsConnected() {
return existing, nil
}
// Connection is dead, remove it
existing.Close()
delete(m.connections, conn.ID)
}
// Create new connection based on type
var connector DatabaseConnector
var err error
switch conn.Type {
case models.ConnectionTypeMySQL:
connector, err = NewMySQLConnection(conn, password)
case models.ConnectionTypePostgreSQL:
connector, err = NewPostgreSQLConnection(conn, password)
case models.ConnectionTypeSQLite:
connector, err = NewSQLiteConnection(conn)
default:
return nil, fmt.Errorf("unsupported database type: %s", conn.Type)
}
if err != nil {
return nil, err
}
m.connections[conn.ID] = connector
config.GetLogger().Info("connection created in manager",
zap.String("connection_id", conn.ID),
zap.String("type", string(conn.Type)),
)
return connector, nil
}
// RemoveConnection removes a connection from the manager
func (m *ConnectionManager) RemoveConnection(connectionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if conn, ok := m.connections[connectionID]; ok {
if err := conn.Close(); err != nil {
return err
}
delete(m.connections, connectionID)
config.GetLogger().Info("connection removed from manager",
zap.String("connection_id", connectionID),
)
}
return nil
}
// GetAllConnections returns all managed connections
func (m *ConnectionManager) GetAllConnections() map[string]DatabaseConnector {
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]DatabaseConnector)
for k, v := range m.connections {
result[k] = v
}
return result
}
// CloseAll closes all managed connections
func (m *ConnectionManager) CloseAll() error {
m.mu.Lock()
defer m.mu.Unlock()
var lastErr error
for id, conn := range m.connections {
if err := conn.Close(); err != nil {
lastErr = err
config.GetLogger().Error("failed to close connection",
zap.String("connection_id", id),
zap.Error(err),
)
}
}
m.connections = make(map[string]DatabaseConnector)
if lastErr != nil {
return lastErr
}
config.GetLogger().Info("all connections closed")
return nil
}

View File

@@ -0,0 +1,414 @@
package database
import (
"database/sql"
"fmt"
"strings"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
)
// MySQLConnection represents a MySQL connection
type MySQLConnection struct {
db *sql.DB
dsn string
host string
port int
database string
username string
mu sync.RWMutex
}
// BuildMySQLDSN builds MySQL DSN connection string
func BuildMySQLDSN(conn *models.UserConnection, password string) string {
// MySQL DSN format: user:pass@tcp(host:port)/dbname?params
params := []string{
"parseTime=true",
"loc=Local",
fmt.Sprintf("timeout=%ds", conn.Timeout),
"charset=utf8mb4",
"collation=utf8mb4_unicode_ci",
}
if conn.SSLMode != "" && conn.SSLMode != "disable" {
switch conn.SSLMode {
case "require":
params = append(params, "tls=required")
case "verify-ca":
params = append(params, "tls=skip-verify")
case "verify-full":
params = append(params, "tls=skip-verify")
}
}
queryString := strings.Join(params, "&")
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?%s",
conn.Username,
password,
conn.Host,
conn.Port,
conn.Database,
queryString,
)
}
// NewMySQLConnection creates a new MySQL connection
func NewMySQLConnection(conn *models.UserConnection, password string) (*MySQLConnection, error) {
dsn := BuildMySQLDSN(conn, password)
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open MySQL connection: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Test connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping MySQL: %w", err)
}
mysqlConn := &MySQLConnection{
db: db,
dsn: dsn,
host: conn.Host,
port: conn.Port,
database: conn.Database,
username: conn.Username,
}
config.GetLogger().Info("MySQL connection established",
zap.String("host", conn.Host),
zap.Int("port", conn.Port),
zap.String("database", conn.Database),
)
return mysqlConn, nil
}
// GetDB returns the underlying sql.DB
func (m *MySQLConnection) GetDB() *sql.DB {
m.mu.RLock()
defer m.mu.RUnlock()
return m.db
}
// Close closes the MySQL connection
func (m *MySQLConnection) Close() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.db != nil {
if err := m.db.Close(); err != nil {
return fmt.Errorf("failed to close MySQL connection: %w", err)
}
m.db = nil
config.GetLogger().Info("MySQL connection closed",
zap.String("host", m.host),
zap.Int("port", m.port),
)
}
return nil
}
// IsConnected checks if the connection is alive
func (m *MySQLConnection) IsConnected() bool {
m.mu.RLock()
defer m.mu.RUnlock()
if m.db == nil {
return false
}
err := m.db.Ping()
return err == nil
}
// ExecuteQuery executes a SQL query and returns results
func (m *MySQLConnection) ExecuteQuery(sql string, args ...interface{}) (*models.QueryResult, error) {
startTime := time.Now()
db := m.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
rows, err := db.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("query execution failed: %w", err)
}
defer rows.Close()
result := &models.QueryResult{
Success: true,
Duration: time.Since(startTime).Milliseconds(),
}
// Get column names
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("failed to get columns: %w", err)
}
result.Columns = columns
// Scan rows
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
row := make([]interface{}, len(columns))
for i, v := range values {
row[i] = convertValue(v)
}
result.Rows = append(result.Rows, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("row iteration error: %w", err)
}
result.RowCount = int64(len(result.Rows))
return result, nil
}
// ExecuteStatement executes a SQL statement (INSERT, UPDATE, DELETE, etc.)
func (m *MySQLConnection) ExecuteStatement(sql string, args ...interface{}) (*models.QueryResult, error) {
startTime := time.Now()
db := m.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
res, err := db.Exec(sql, args...)
if err != nil {
return nil, fmt.Errorf("statement execution failed: %w", err)
}
rowsAffected, _ := res.RowsAffected()
lastInsertID, _ := res.LastInsertId()
result := &models.QueryResult{
Success: true,
AffectedRows: rowsAffected,
Duration: time.Since(startTime).Milliseconds(),
Rows: [][]interface{}{{lastInsertID}},
Columns: []string{"last_insert_id"},
}
return result, nil
}
// GetTables returns all tables in the database
func (m *MySQLConnection) GetTables(schema string) ([]models.Table, error) {
db := m.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
query := `
SELECT
TABLE_NAME as table_name,
TABLE_TYPE as table_type,
TABLE_ROWS as row_count
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = ?
ORDER BY TABLE_NAME
`
rows, err := db.Query(query, m.database)
if err != nil {
return nil, fmt.Errorf("failed to query tables: %w", err)
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
var rowCount sql.NullInt64
if err := rows.Scan(&t.Name, &t.Type, &rowCount); err != nil {
return nil, fmt.Errorf("failed to scan table: %w", err)
}
if rowCount.Valid {
t.RowCount = rowCount.Int64
}
tables = append(tables, t)
}
return tables, nil
}
// GetTableStructure returns the structure of a table
func (m *MySQLConnection) GetTableStructure(tableName string) (*models.TableStructure, error) {
db := m.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
structure := &models.TableStructure{
TableName: tableName,
}
// Get columns
query := `
SELECT
COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT,
COLUMN_KEY, EXTRA, CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION,
NUMERIC_SCALE, COLUMN_COMMENT
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION
`
rows, err := db.Query(query, m.database, tableName)
if err != nil {
return nil, fmt.Errorf("failed to query columns: %w", err)
}
defer rows.Close()
for rows.Next() {
var col models.TableColumn
var nullable, columnKey, extra string
var defaultVal, comment sql.NullString
var maxLength, precision, scale sql.NullInt32
if err := rows.Scan(
&col.Name, &col.DataType, &nullable, &defaultVal,
&columnKey, &extra, &maxLength, &precision,
&scale, &comment,
); err != nil {
return nil, fmt.Errorf("failed to scan column: %w", err)
}
col.Nullable = nullable == "YES"
col.IsPrimary = columnKey == "PRI"
col.IsUnique = columnKey == "UNI"
col.AutoIncrement = strings.Contains(extra, "auto_increment")
if defaultVal.Valid {
col.Default = defaultVal.String
}
if maxLength.Valid {
col.Length = int(maxLength.Int32)
}
if precision.Valid {
col.Scale = int(precision.Int32)
}
if scale.Valid {
col.Scale = int(scale.Int32)
}
if comment.Valid {
col.Comment = comment.String
}
structure.Columns = append(structure.Columns, col)
}
// Get indexes
indexQuery := `
SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE, SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY INDEX_NAME, SEQ_IN_INDEX
`
idxRows, err := db.Query(indexQuery, m.database, tableName)
if err != nil {
config.GetLogger().Warn("failed to query indexes", zap.Error(err))
} else {
defer idxRows.Close()
indexMap := make(map[string]*models.TableIndex)
for idxRows.Next() {
var indexName, columnName string
var nonUnique bool
var seqInIndex int
if err := idxRows.Scan(&indexName, &columnName, &nonUnique, &seqInIndex); err != nil {
continue
}
idx, exists := indexMap[indexName]
if !exists {
idx = &models.TableIndex{
Name: indexName,
IsUnique: !nonUnique,
IsPrimary: indexName == "PRIMARY",
Columns: []string{},
}
indexMap[indexName] = idx
}
idx.Columns = append(idx.Columns, columnName)
}
for _, idx := range indexMap {
structure.Indexes = append(structure.Indexes, *idx)
}
}
return structure, nil
}
// GetMetadata returns database metadata
func (m *MySQLConnection) GetMetadata() (*models.DBMetadata, error) {
db := m.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
metadata := &models.DBMetadata{
Database: m.database,
User: m.username,
Host: m.host,
Port: m.port,
}
// Get MySQL version
var version string
err := db.QueryRow("SELECT VERSION()").Scan(&version)
if err == nil {
metadata.Version = version
}
// Get server time
var serverTime string
err = db.QueryRow("SELECT NOW()").Scan(&serverTime)
if err == nil {
metadata.ServerTime = serverTime
}
return metadata, nil
}
// convertValue converts database value to interface{}
func convertValue(val interface{}) interface{} {
if val == nil {
return nil
}
// Handle []byte (common from MySQL driver)
if b, ok := val.([]byte); ok {
return string(b)
}
return val
}

View File

@@ -0,0 +1,449 @@
package database
import (
"database/sql"
"fmt"
"strings"
"sync"
"time"
_ "github.com/lib/pq"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
)
// PostgreSQLConnection represents a PostgreSQL connection
type PostgreSQLConnection struct {
db *sql.DB
dsn string
host string
port int
database string
username string
mu sync.RWMutex
}
// BuildPostgreSQLDSN builds PostgreSQL DSN connection string
func BuildPostgreSQLDSN(conn *models.UserConnection, password string) string {
// PostgreSQL DSN format: postgres://user:pass@host:port/dbname?params
params := []string{
fmt.Sprintf("connect_timeout=%d", conn.Timeout),
"sslmode=disable",
}
if conn.SSLMode != "" {
switch conn.SSLMode {
case "require":
params = append(params, "sslmode=require")
case "verify-ca":
params = append(params, "sslmode=verify-ca")
case "verify-full":
params = append(params, "sslmode=verify-full")
case "disable":
params = append(params, "sslmode=disable")
default:
params = append(params, fmt.Sprintf("sslmode=%s", conn.SSLMode))
}
}
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?%s",
conn.Username,
password,
conn.Host,
conn.Port,
conn.Database,
strings.Join(params, "&"),
)
}
// NewPostgreSQLConnection creates a new PostgreSQL connection
func NewPostgreSQLConnection(conn *models.UserConnection, password string) (*PostgreSQLConnection, error) {
dsn := BuildPostgreSQLDSN(conn, password)
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open PostgreSQL connection: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(5 * time.Minute)
// Test connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping PostgreSQL: %w", err)
}
pgConn := &PostgreSQLConnection{
db: db,
dsn: dsn,
host: conn.Host,
port: conn.Port,
database: conn.Database,
username: conn.Username,
}
config.GetLogger().Info("PostgreSQL connection established",
zap.String("host", conn.Host),
zap.Int("port", conn.Port),
zap.String("database", conn.Database),
)
return pgConn, nil
}
// GetDB returns the underlying sql.DB
func (p *PostgreSQLConnection) GetDB() *sql.DB {
p.mu.RLock()
defer p.mu.RUnlock()
return p.db
}
// Close closes the PostgreSQL connection
func (p *PostgreSQLConnection) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.db != nil {
if err := p.db.Close(); err != nil {
return fmt.Errorf("failed to close PostgreSQL connection: %w", err)
}
p.db = nil
config.GetLogger().Info("PostgreSQL connection closed",
zap.String("host", p.host),
zap.Int("port", p.port),
)
}
return nil
}
// IsConnected checks if the connection is alive
func (p *PostgreSQLConnection) IsConnected() bool {
p.mu.RLock()
defer p.mu.RUnlock()
if p.db == nil {
return false
}
err := p.db.Ping()
return err == nil
}
// ExecuteQuery executes a SQL query and returns results
func (p *PostgreSQLConnection) ExecuteQuery(sql string, args ...interface{}) (*models.QueryResult, error) {
startTime := time.Now()
db := p.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
rows, err := db.Query(sql, args...)
if err != nil {
return nil, fmt.Errorf("query execution failed: %w", err)
}
defer rows.Close()
result := &models.QueryResult{
Success: true,
Duration: time.Since(startTime).Milliseconds(),
}
// Get column names
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("failed to get columns: %w", err)
}
result.Columns = columns
// Scan rows
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
row := make([]interface{}, len(columns))
for i, v := range values {
row[i] = convertValue(v)
}
result.Rows = append(result.Rows, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("row iteration error: %w", err)
}
result.RowCount = int64(len(result.Rows))
return result, nil
}
// ExecuteStatement executes a SQL statement (INSERT, UPDATE, DELETE, etc.)
func (p *PostgreSQLConnection) ExecuteStatement(sql string, args ...interface{}) (*models.QueryResult, error) {
startTime := time.Now()
db := p.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
res, err := db.Exec(sql, args...)
if err != nil {
return nil, fmt.Errorf("statement execution failed: %w", err)
}
rowsAffected, _ := res.RowsAffected()
result := &models.QueryResult{
Success: true,
AffectedRows: rowsAffected,
Duration: time.Since(startTime).Milliseconds(),
}
return result, nil
}
// GetTables returns all tables in the database
func (p *PostgreSQLConnection) GetTables(schema string) ([]models.Table, error) {
db := p.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
if schema == "" {
schema = "public"
}
query := `
SELECT
table_name,
table_type,
(SELECT COUNT(*) FROM information_schema.columns c
WHERE c.table_name = t.table_name AND c.table_schema = t.table_schema) as column_count
FROM information_schema.tables t
WHERE table_schema = $1
ORDER BY table_name
`
rows, err := db.Query(query, schema)
if err != nil {
return nil, fmt.Errorf("failed to query tables: %w", err)
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
var columnCount int
t.Schema = schema
if err := rows.Scan(&t.Name, &t.Type, &columnCount); err != nil {
return nil, fmt.Errorf("failed to scan table: %w", err)
}
tables = append(tables, t)
}
return tables, nil
}
// GetTableStructure returns the structure of a table
func (p *PostgreSQLConnection) GetTableStructure(tableName string) (*models.TableStructure, error) {
db := p.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
structure := &models.TableStructure{
TableName: tableName,
Schema: "public",
}
// Get columns
query := `
SELECT
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
c.character_maximum_length,
c.numeric_precision,
c.numeric_scale,
pg_catalog.col_description(c.oid, c.ordinal_position) as comment
FROM information_schema.columns c
JOIN pg_class cl ON cl.relname = c.table_name
WHERE c.table_schema = 'public' AND c.table_name = $1
ORDER BY c.ordinal_position
`
rows, err := db.Query(query, tableName)
if err != nil {
return nil, fmt.Errorf("failed to query columns: %w", err)
}
defer rows.Close()
for rows.Next() {
var col models.TableColumn
var nullable string
var defaultVal, comment sql.NullString
var maxLength, precision, scale sql.NullInt32
if err := rows.Scan(
&col.Name, &col.DataType, &nullable, &defaultVal,
&maxLength, &precision, &scale, &comment,
); err != nil {
return nil, fmt.Errorf("failed to scan column: %w", err)
}
col.Nullable = nullable == "YES"
if defaultVal.Valid {
col.Default = defaultVal.String
}
if maxLength.Valid && maxLength.Int32 > 0 {
col.Length = int(maxLength.Int32)
}
if precision.Valid {
col.Scale = int(precision.Int32)
}
if scale.Valid {
col.Scale = int(scale.Int32)
}
if comment.Valid {
col.Comment = comment.String
}
structure.Columns = append(structure.Columns, col)
}
// Get primary key information
pkQuery := `
SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary
`
pkRows, err := db.Query(pkQuery, tableName)
if err != nil {
config.GetLogger().Warn("failed to query primary keys", zap.Error(err))
} else {
defer pkRows.Close()
pkColumns := make(map[string]bool)
for pkRows.Next() {
var colName string
if err := pkRows.Scan(&colName); err != nil {
continue
}
pkColumns[colName] = true
}
// Mark primary key columns
for i := range structure.Columns {
if pkColumns[structure.Columns[i].Name] {
structure.Columns[i].IsPrimary = true
}
}
}
// Get indexes
indexQuery := `
SELECT
i.indexname,
i.indexdef,
i.indisunique
FROM pg_indexes i
WHERE i.schemaname = 'public' AND i.tablename = $1
`
idxRows, err := db.Query(indexQuery, tableName)
if err != nil {
config.GetLogger().Warn("failed to query indexes", zap.Error(err))
} else {
defer idxRows.Close()
for idxRows.Next() {
var idx models.TableIndex
var indexDef string
if err := idxRows.Scan(&idx.Name, &indexDef, &idx.IsUnique); err != nil {
continue
}
idx.IsPrimary = idx.Name == (tableName + "_pkey")
// Extract column names from index definition
idx.Columns = extractColumnsFromIndexDef(indexDef)
idx.Type = "btree" // Default for PostgreSQL
structure.Indexes = append(structure.Indexes, idx)
}
}
return structure, nil
}
// GetMetadata returns database metadata
func (p *PostgreSQLConnection) GetMetadata() (*models.DBMetadata, error) {
db := p.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
metadata := &models.DBMetadata{
Database: p.database,
User: p.username,
Host: p.host,
Port: p.port,
}
// Get PostgreSQL version
var version string
err := db.QueryRow("SELECT version()").Scan(&version)
if err == nil {
metadata.Version = version
}
// Get server time
var serverTime string
err = db.QueryRow("SELECT NOW()").Scan(&serverTime)
if err == nil {
metadata.ServerTime = serverTime
}
return metadata, nil
}
// extractColumnsFromIndexDef extracts column names from PostgreSQL index definition
func extractColumnsFromIndexDef(indexDef string) []string {
var columns []string
// Simple extraction - look for content between parentheses
start := strings.Index(indexDef, "(")
end := strings.LastIndex(indexDef, ")")
if start != -1 && end != -1 && end > start {
content := indexDef[start+1 : end]
parts := strings.Split(content, ",")
for _, part := range parts {
col := strings.TrimSpace(part)
// Remove any expressions like "LOWER(column)"
if parenIdx := strings.Index(col, "("); parenIdx != -1 {
continue
}
if col != "" {
columns = append(columns, col)
}
}
}
return columns
}

View File

@@ -0,0 +1,128 @@
package database
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
"go.uber.org/zap"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"uzdb/internal/config"
"uzdb/internal/models"
)
var (
sqliteDB *gorm.DB
sqliteMu sync.RWMutex
)
// InitSQLite initializes the SQLite database for application data
func InitSQLite(dbPath string, cfg *config.DatabaseConfig) (*gorm.DB, error) {
sqliteMu.Lock()
defer sqliteMu.Unlock()
// Ensure directory exists
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
// Configure GORM logger
var gormLogger logger.Interface
if config.Get().IsDevelopment() {
gormLogger = logger.Default.LogMode(logger.Info)
} else {
gormLogger = logger.Default.LogMode(logger.Silent)
}
// Open SQLite connection
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: gormLogger,
})
if err != nil {
return nil, fmt.Errorf("failed to open SQLite database: %w", err)
}
// Set connection pool settings
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get underlying DB: %w", err)
}
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetConnMaxLifetime(time.Duration(cfg.MaxLifetime) * time.Minute)
// Enable WAL mode for better concurrency
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
config.GetLogger().Warn("failed to enable WAL mode", zap.Error(err))
}
// Run migrations
if err := runMigrations(db); err != nil {
return nil, fmt.Errorf("migration failed: %w", err)
}
sqliteDB = db
config.GetLogger().Info("SQLite database initialized",
zap.String("path", dbPath),
zap.Int("max_open_conns", cfg.MaxOpenConns),
zap.Int("max_idle_conns", cfg.MaxIdleConns),
)
return db, nil
}
// runMigrations runs database migrations
func runMigrations(db *gorm.DB) error {
migrations := []interface{}{
&models.UserConnection{},
&models.QueryHistory{},
&models.SavedQuery{},
}
for _, model := range migrations {
if err := db.AutoMigrate(model); err != nil {
return fmt.Errorf("failed to migrate %T: %w", model, err)
}
}
config.GetLogger().Info("database migrations completed")
return nil
}
// GetSQLiteDB returns the SQLite database instance
func GetSQLiteDB() *gorm.DB {
sqliteMu.RLock()
defer sqliteMu.RUnlock()
return sqliteDB
}
// CloseSQLite closes the SQLite database connection
func CloseSQLite() error {
sqliteMu.Lock()
defer sqliteMu.Unlock()
if sqliteDB == nil {
return nil
}
sqlDB, err := sqliteDB.DB()
if err != nil {
return err
}
if err := sqlDB.Close(); err != nil {
return fmt.Errorf("failed to close SQLite database: %w", err)
}
sqliteDB = nil
config.GetLogger().Info("SQLite database closed")
return nil
}

View File

@@ -0,0 +1,369 @@
package database
import (
"database/sql"
"fmt"
"sync"
"time"
_ "modernc.org/sqlite"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
)
// SQLiteConnection represents a SQLite connection
type SQLiteConnection struct {
db *sql.DB
filePath string
mu sync.RWMutex
}
// NewSQLiteConnection creates a new SQLite connection
func NewSQLiteConnection(conn *models.UserConnection) (*SQLiteConnection, error) {
filePath := conn.Database
// Ensure file exists for new connections
db, err := sql.Open("sqlite", filePath)
if err != nil {
return nil, fmt.Errorf("failed to open SQLite connection: %w", err)
}
// Configure connection pool
db.SetMaxOpenConns(1) // SQLite only supports one writer
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(5 * time.Minute)
// Enable WAL mode
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
config.GetLogger().Warn("failed to enable WAL mode", zap.Error(err))
}
// Test connection
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("failed to ping SQLite: %w", err)
}
sqliteConn := &SQLiteConnection{
db: db,
filePath: filePath,
}
config.GetLogger().Info("SQLite connection established",
zap.String("path", filePath),
)
return sqliteConn, nil
}
// GetDB returns the underlying sql.DB
func (s *SQLiteConnection) GetDB() *sql.DB {
s.mu.RLock()
defer s.mu.RUnlock()
return s.db
}
// Close closes the SQLite connection
func (s *SQLiteConnection) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.db != nil {
if err := s.db.Close(); err != nil {
return fmt.Errorf("failed to close SQLite connection: %w", err)
}
s.db = nil
config.GetLogger().Info("SQLite connection closed",
zap.String("path", s.filePath),
)
}
return nil
}
// IsConnected checks if the connection is alive
func (s *SQLiteConnection) IsConnected() bool {
s.mu.RLock()
defer s.mu.RUnlock()
if s.db == nil {
return false
}
err := s.db.Ping()
return err == nil
}
// ExecuteQuery executes a SQL query and returns results
func (s *SQLiteConnection) ExecuteQuery(query string, args ...interface{}) (*models.QueryResult, error) {
startTime := time.Now()
db := s.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
rows, err := db.Query(query, args...)
if err != nil {
return nil, fmt.Errorf("query execution failed: %w", err)
}
defer rows.Close()
result := &models.QueryResult{
Success: true,
Duration: time.Since(startTime).Milliseconds(),
}
// Get column names
columns, err := rows.Columns()
if err != nil {
return nil, fmt.Errorf("failed to get columns: %w", err)
}
result.Columns = columns
// Scan rows
for rows.Next() {
values := make([]interface{}, len(columns))
valuePtrs := make([]interface{}, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
row := make([]interface{}, len(columns))
for i, v := range values {
row[i] = convertValue(v)
}
result.Rows = append(result.Rows, row)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("row iteration error: %w", err)
}
result.RowCount = int64(len(result.Rows))
return result, nil
}
// ExecuteStatement executes a SQL statement (INSERT, UPDATE, DELETE, etc.)
func (s *SQLiteConnection) ExecuteStatement(stmt string, args ...interface{}) (*models.QueryResult, error) {
startTime := time.Now()
db := s.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
res, err := db.Exec(stmt, args...)
if err != nil {
return nil, fmt.Errorf("statement execution failed: %w", err)
}
rowsAffected, _ := res.RowsAffected()
lastInsertID, _ := res.LastInsertId()
result := &models.QueryResult{
Success: true,
AffectedRows: rowsAffected,
Duration: time.Since(startTime).Milliseconds(),
Rows: [][]interface{}{{lastInsertID}},
Columns: []string{"last_insert_id"},
}
return result, nil
}
// GetTables returns all tables in the database
func (s *SQLiteConnection) GetTables(schema string) ([]models.Table, error) {
db := s.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
query := `
SELECT name, type
FROM sqlite_master
WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
ORDER BY name
`
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query tables: %w", err)
}
defer rows.Close()
var tables []models.Table
for rows.Next() {
var t models.Table
if err := rows.Scan(&t.Name, &t.Type); err != nil {
return nil, fmt.Errorf("failed to scan table: %w", err)
}
// Get row count for tables
if t.Type == "table" {
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM \"%s\"", t.Name)
var rowCount int64
if err := db.QueryRow(countQuery).Scan(&rowCount); err == nil {
t.RowCount = rowCount
}
}
tables = append(tables, t)
}
return tables, nil
}
// GetTableStructure returns the structure of a table
func (s *SQLiteConnection) GetTableStructure(tableName string) (*models.TableStructure, error) {
db := s.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
structure := &models.TableStructure{
TableName: tableName,
}
// Get table info using PRAGMA
query := fmt.Sprintf("PRAGMA table_info(\"%s\")", tableName)
rows, err := db.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query table info: %w", err)
}
defer rows.Close()
for rows.Next() {
var col models.TableColumn
var pk int
if err := rows.Scan(&col.Name, &col.DataType, &col.Default, &col.Nullable, &pk, &col.IsPrimary); err != nil {
return nil, fmt.Errorf("failed to scan column: %w", err)
}
col.IsPrimary = pk > 0
col.Nullable = col.Nullable || !col.IsPrimary
structure.Columns = append(structure.Columns, col)
}
// Get indexes
idxQuery := fmt.Sprintf("PRAGMA index_list(\"%s\")", tableName)
idxRows, err := db.Query(idxQuery)
if err != nil {
config.GetLogger().Warn("failed to query indexes", zap.Error(err))
} else {
defer idxRows.Close()
for idxRows.Next() {
var idx models.TableIndex
var origin string
if err := idxRows.Scan(&idx.Name, &idx.IsUnique, &origin); err != nil {
continue
}
idx.IsPrimary = idx.Name == "sqlite_autoindex_"+tableName+"_1"
idx.Type = origin
// Get index columns
colQuery := fmt.Sprintf("PRAGMA index_info(\"%s\")", idx.Name)
colRows, err := db.Query(colQuery)
if err == nil {
defer colRows.Close()
for colRows.Next() {
var seqno int
var colName string
if err := colRows.Scan(&seqno, &colName, &colName); err != nil {
continue
}
idx.Columns = append(idx.Columns, colName)
}
}
structure.Indexes = append(structure.Indexes, idx)
}
}
// Get foreign keys
fkQuery := fmt.Sprintf("PRAGMA foreign_key_list(\"%s\")", tableName)
fkRows, err := db.Query(fkQuery)
if err != nil {
config.GetLogger().Warn("failed to query foreign keys", zap.Error(err))
} else {
defer fkRows.Close()
fkMap := make(map[string]*models.ForeignKey)
for fkRows.Next() {
var fk models.ForeignKey
var id, seq int
if err := fkRows.Scan(&id, &seq, &fk.Name, &fk.ReferencedTable,
&fk.Columns, &fk.ReferencedColumns, &fk.OnUpdate, &fk.OnDelete, ""); err != nil {
continue
}
// Handle array fields properly
fk.Columns = []string{}
fk.ReferencedColumns = []string{}
var fromCol, toCol string
if err := fkRows.Scan(&id, &seq, &fk.Name, &fk.ReferencedTable,
&fromCol, &toCol, &fk.OnUpdate, &fk.OnDelete, ""); err != nil {
continue
}
existingFk, exists := fkMap[fk.Name]
if !exists {
existingFk = &models.ForeignKey{
Name: fk.Name,
ReferencedTable: fk.ReferencedTable,
OnDelete: fk.OnDelete,
OnUpdate: fk.OnUpdate,
Columns: []string{},
ReferencedColumns: []string{},
}
fkMap[fk.Name] = existingFk
}
existingFk.Columns = append(existingFk.Columns, fromCol)
existingFk.ReferencedColumns = append(existingFk.ReferencedColumns, toCol)
}
for _, fk := range fkMap {
structure.ForeignKeys = append(structure.ForeignKeys, *fk)
}
}
return structure, nil
}
// GetMetadata returns database metadata
func (s *SQLiteConnection) GetMetadata() (*models.DBMetadata, error) {
db := s.GetDB()
if db == nil {
return nil, fmt.Errorf("connection is closed")
}
metadata := &models.DBMetadata{
Database: s.filePath,
Version: "3", // SQLite version 3
}
// Get SQLite version
var version string
err := db.QueryRow("SELECT sqlite_version()").Scan(&version)
if err == nil {
metadata.Version = version
}
// Get current time
metadata.ServerTime = time.Now().Format(time.RFC3339)
return metadata, nil
}

View File

@@ -0,0 +1,195 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
"uzdb/internal/services"
"uzdb/internal/utils"
)
// ConnectionHandler handles connection-related HTTP requests
type ConnectionHandler struct {
connectionSvc *services.ConnectionService
}
// NewConnectionHandler creates a new connection handler
func NewConnectionHandler(connectionSvc *services.ConnectionService) *ConnectionHandler {
return &ConnectionHandler{
connectionSvc: connectionSvc,
}
}
// GetAllConnections handles GET /api/connections
func (h *ConnectionHandler) GetAllConnections(c *gin.Context) {
ctx := c.Request.Context()
connections, err := h.connectionSvc.GetAllConnections(ctx)
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get connections")
return
}
utils.SuccessResponse(c, gin.H{
"connections": connections,
})
}
// GetConnection handles GET /api/connections/:id
func (h *ConnectionHandler) GetConnection(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
if id == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID is required")
return
}
conn, err := h.connectionSvc.GetConnectionByID(ctx, id)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get connection")
return
}
utils.SuccessResponse(c, gin.H{
"connection": conn,
})
}
// CreateConnection handles POST /api/connections
func (h *ConnectionHandler) CreateConnection(c *gin.Context) {
ctx := c.Request.Context()
var req models.CreateConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid request body")
return
}
conn, err := h.connectionSvc.CreateConnection(ctx, &req)
if err != nil {
if err == models.ErrValidationFailed {
utils.ErrorResponse(c, http.StatusBadRequest, err, "Validation failed")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to create connection")
return
}
config.GetLogger().Info("connection created via API",
zap.String("id", conn.ID),
zap.String("name", conn.Name))
utils.CreatedResponse(c, gin.H{
"connection": conn,
})
}
// UpdateConnection handles PUT /api/connections/:id
func (h *ConnectionHandler) UpdateConnection(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
if id == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID is required")
return
}
var req models.UpdateConnectionRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid request body")
return
}
conn, err := h.connectionSvc.UpdateConnection(ctx, id, &req)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to update connection")
return
}
utils.SuccessResponse(c, gin.H{
"connection": conn,
})
}
// DeleteConnection handles DELETE /api/connections/:id
func (h *ConnectionHandler) DeleteConnection(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
if id == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID is required")
return
}
if err := h.connectionSvc.DeleteConnection(ctx, id); err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to delete connection")
return
}
utils.SuccessResponse(c, gin.H{
"message": "Connection deleted successfully",
})
}
// TestConnection handles POST /api/connections/:id/test
func (h *ConnectionHandler) TestConnection(c *gin.Context) {
ctx := c.Request.Context()
id := c.Param("id")
if id == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID is required")
return
}
result, err := h.connectionSvc.TestConnection(ctx, id)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to test connection")
return
}
statusCode := http.StatusOK
if !result.Success {
statusCode = http.StatusBadGateway
}
c.JSON(statusCode, gin.H{
"success": result.Success,
"message": result.Message,
"duration_ms": result.Duration,
"metadata": result.Metadata,
})
}
// RegisterRoutes registers connection routes
func (h *ConnectionHandler) RegisterRoutes(router *gin.RouterGroup) {
connections := router.Group("/connections")
{
connections.GET("", h.GetAllConnections)
connections.GET("/:id", h.GetConnection)
connections.POST("", h.CreateConnection)
connections.PUT("/:id", h.UpdateConnection)
connections.DELETE("/:id", h.DeleteConnection)
connections.POST("/:id/test", h.TestConnection)
}
}

View File

@@ -0,0 +1,287 @@
package handler
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"uzdb/internal/models"
"uzdb/internal/services"
"uzdb/internal/utils"
)
// QueryHandler handles query-related HTTP requests
type QueryHandler struct {
connectionSvc *services.ConnectionService
querySvc *services.QueryService
}
// NewQueryHandler creates a new query handler
func NewQueryHandler(
connectionSvc *services.ConnectionService,
querySvc *services.QueryService,
) *QueryHandler {
return &QueryHandler{
connectionSvc: connectionSvc,
querySvc: querySvc,
}
}
// ExecuteQuery handles POST /api/query
func (h *QueryHandler) ExecuteQuery(c *gin.Context) {
ctx := c.Request.Context()
var req struct {
ConnectionID string `json:"connection_id" binding:"required"`
SQL string `json:"sql" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid request body")
return
}
result, err := h.connectionSvc.ExecuteQuery(ctx, req.ConnectionID, req.SQL)
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Query execution failed")
return
}
utils.SuccessResponse(c, gin.H{
"result": result,
})
}
// GetTables handles GET /api/connections/:id/tables
func (h *QueryHandler) GetTables(c *gin.Context) {
ctx := c.Request.Context()
connectionID := c.Param("id")
if connectionID == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID is required")
return
}
tables, err := h.connectionSvc.GetTables(ctx, connectionID)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get tables")
return
}
utils.SuccessResponse(c, gin.H{
"tables": tables,
})
}
// GetTableData handles GET /api/connections/:id/tables/:name/data
func (h *QueryHandler) GetTableData(c *gin.Context) {
ctx := c.Request.Context()
connectionID := c.Param("id")
tableName := c.Param("name")
if connectionID == "" || tableName == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID and table name are required")
return
}
// Parse limit and offset
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
result, err := h.connectionSvc.GetTableData(ctx, connectionID, tableName, limit, offset)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get table data")
return
}
utils.SuccessResponse(c, gin.H{
"result": result,
"table": tableName,
"limit": limit,
"offset": offset,
})
}
// GetTableStructure handles GET /api/connections/:id/tables/:name/structure
func (h *QueryHandler) GetTableStructure(c *gin.Context) {
ctx := c.Request.Context()
connectionID := c.Param("id")
tableName := c.Param("name")
if connectionID == "" || tableName == "" {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Connection ID and table name are required")
return
}
structure, err := h.connectionSvc.GetTableStructure(ctx, connectionID, tableName)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Connection not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get table structure")
return
}
utils.SuccessResponse(c, gin.H{
"structure": structure,
})
}
// GetQueryHistory handles GET /api/history
func (h *QueryHandler) GetQueryHistory(c *gin.Context) {
ctx := c.Request.Context()
connectionID := c.Query("connection_id")
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
history, total, err := h.querySvc.GetQueryHistory(ctx, connectionID, page, pageSize)
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get query history")
return
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": gin.H{
"history": history,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": totalPages,
},
})
}
// GetSavedQueries handles GET /api/saved-queries
func (h *QueryHandler) GetSavedQueries(c *gin.Context) {
ctx := c.Request.Context()
connectionID := c.Query("connection_id")
queries, err := h.querySvc.GetSavedQueries(ctx, connectionID)
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to get saved queries")
return
}
utils.SuccessResponse(c, gin.H{
"queries": queries,
})
}
// CreateSavedQuery handles POST /api/saved-queries
func (h *QueryHandler) CreateSavedQuery(c *gin.Context) {
ctx := c.Request.Context()
var req models.CreateSavedQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid request body")
return
}
query, err := h.querySvc.CreateSavedQuery(ctx, &req)
if err != nil {
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to save query")
return
}
utils.CreatedResponse(c, gin.H{
"query": query,
})
}
// UpdateSavedQuery handles PUT /api/saved-queries/:id
func (h *QueryHandler) UpdateSavedQuery(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid query ID")
return
}
var req models.UpdateSavedQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid request body")
return
}
query, err := h.querySvc.UpdateSavedQuery(ctx, uint(id), &req)
if err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Saved query not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to update saved query")
return
}
utils.SuccessResponse(c, gin.H{
"query": query,
})
}
// DeleteSavedQuery handles DELETE /api/saved-queries/:id
func (h *QueryHandler) DeleteSavedQuery(c *gin.Context) {
ctx := c.Request.Context()
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
utils.ErrorResponse(c, http.StatusBadRequest, models.ErrValidationFailed, "Invalid query ID")
return
}
if err := h.querySvc.DeleteSavedQuery(ctx, uint(id)); err != nil {
if err == models.ErrNotFound {
utils.ErrorResponse(c, http.StatusNotFound, err, "Saved query not found")
return
}
utils.ErrorResponse(c, http.StatusInternalServerError, err, "Failed to delete saved query")
return
}
utils.SuccessResponse(c, gin.H{
"message": "Saved query deleted successfully",
})
}
// RegisterRoutes registers query routes
func (h *QueryHandler) RegisterRoutes(router *gin.RouterGroup) {
// Query execution
router.POST("/query", h.ExecuteQuery)
// Table operations
router.GET("/connections/:id/tables", h.GetTables)
router.GET("/connections/:id/tables/:name/data", h.GetTableData)
router.GET("/connections/:id/tables/:name/structure", h.GetTableStructure)
// Query history
router.GET("/history", h.GetQueryHistory)
// Saved queries
savedQueries := router.Group("/saved-queries")
{
savedQueries.GET("", h.GetSavedQueries)
savedQueries.POST("", h.CreateSavedQuery)
savedQueries.PUT("/:id", h.UpdateSavedQuery)
savedQueries.DELETE("/:id", h.DeleteSavedQuery)
}
}

View File

@@ -0,0 +1,95 @@
package handler
import (
"context"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/middleware"
"uzdb/internal/services"
)
// HTTPServer represents the HTTP API server
type HTTPServer struct {
engine *gin.Engine
server *http.Server
}
// NewHTTPServer creates a new HTTP server
func NewHTTPServer(
connectionSvc *services.ConnectionService,
querySvc *services.QueryService,
) *HTTPServer {
// Set Gin mode based on environment
cfg := config.Get()
if cfg.IsProduction() {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
// Create handlers
connectionHandler := NewConnectionHandler(connectionSvc)
queryHandler := NewQueryHandler(connectionSvc, querySvc)
// Setup middleware
engine.Use(middleware.RecoveryMiddleware())
engine.Use(middleware.LoggerMiddleware())
engine.Use(middleware.CORSMiddleware())
engine.Use(middleware.SecureHeadersMiddleware())
// Setup routes
api := engine.Group("/api")
{
connectionHandler.RegisterRoutes(api)
queryHandler.RegisterRoutes(api)
}
// Health check endpoint
engine.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"timestamp": time.Now().Format(time.RFC3339),
})
})
return &HTTPServer{
engine: engine,
}
}
// Start starts the HTTP server
func (s *HTTPServer) Start(port string) error {
s.server = &http.Server{
Addr: ":" + port,
Handler: s.engine,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
config.GetLogger().Info("starting HTTP API server",
zap.String("port", port))
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("failed to start HTTP server: %w", err)
}
return nil
}
// Shutdown gracefully shuts down the HTTP server
func (s *HTTPServer) Shutdown(ctx context.Context) error {
if s.server == nil {
return nil
}
config.GetLogger().Info("shutting down HTTP server")
return s.server.Shutdown(ctx)
}

View File

@@ -0,0 +1,101 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"uzdb/internal/config"
)
// CORSMiddleware returns a CORS middleware
func CORSMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
cfg := config.Get()
// Set CORS headers
c.Header("Access-Control-Allow-Origin", getAllowedOrigin(c, cfg))
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, X-Request-ID")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, PATCH, DELETE")
c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type, X-Request-ID")
c.Header("Access-Control-Max-Age", "43200") // 12 hours
// Handle preflight requests
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
// getAllowedOrigin returns the allowed origin based on configuration
func getAllowedOrigin(c *gin.Context, cfg *config.Config) string {
origin := c.GetHeader("Origin")
// If no origin header, return empty (same-origin)
if origin == "" {
return ""
}
// In development mode, allow all origins
if cfg.IsDevelopment() {
return origin
}
// In production, validate against allowed origins
allowedOrigins := []string{
"http://localhost:3000",
"http://localhost:8080",
"http://127.0.0.1:3000",
"http://127.0.0.1:8080",
}
for _, allowed := range allowedOrigins {
if origin == allowed {
return origin
}
}
// Check for wildcard patterns
for _, allowed := range allowedOrigins {
if strings.HasSuffix(allowed, "*") {
prefix := strings.TrimSuffix(allowed, "*")
if strings.HasPrefix(origin, prefix) {
return origin
}
}
}
// Default to empty (deny) in production if not matched
if cfg.IsProduction() {
return ""
}
return origin
}
// SecureHeadersMiddleware adds security-related headers
func SecureHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Prevent MIME type sniffing
c.Header("X-Content-Type-Options", "nosniff")
// Enable XSS filter
c.Header("X-XSS-Protection", "1; mode=block")
// Prevent clickjacking
c.Header("X-Frame-Options", "DENY")
// Referrer policy
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy (adjust as needed)
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'")
c.Next()
}
}

View File

@@ -0,0 +1,125 @@
package middleware
import (
"errors"
"net/http"
"runtime/debug"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
)
// RecoveryMiddleware returns a recovery middleware that handles panics
func RecoveryMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
logger := config.GetLogger()
// Log the panic with stack trace
logger.Error("panic recovered",
zap.Any("error", err),
zap.String("stack", string(debug.Stack())),
zap.String("path", c.Request.URL.Path),
zap.String("method", c.Request.Method),
)
// Send error response
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Error: "INTERNAL_ERROR",
Message: "An unexpected error occurred",
Timestamp: time.Now(),
Path: c.Request.URL.Path,
})
c.Abort()
}
}()
c.Next()
}
}
// ErrorMiddleware returns an error handling middleware
func ErrorMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
// Check if there are any errors
if len(c.Errors) > 0 {
handleErrors(c)
}
}
}
// handleErrors processes and formats errors
func handleErrors(c *gin.Context) {
logger := config.GetLogger()
for _, e := range c.Errors {
err := e.Err
// Log the error
logger.Error("request error",
zap.String("error", err.Error()),
zap.Any("type", e.Type),
zap.String("path", c.Request.URL.Path),
)
// Determine status code
statusCode := http.StatusInternalServerError
// Map specific errors to status codes
switch {
case errors.Is(err, models.ErrNotFound):
statusCode = http.StatusNotFound
case errors.Is(err, models.ErrValidationFailed):
statusCode = http.StatusBadRequest
case errors.Is(err, models.ErrUnauthorized):
statusCode = http.StatusUnauthorized
case errors.Is(err, models.ErrForbidden):
statusCode = http.StatusForbidden
case errors.Is(err, models.ErrConnectionFailed):
statusCode = http.StatusBadGateway
}
// Send response if not already sent
if !c.Writer.Written() {
c.JSON(statusCode, models.ErrorResponse{
Error: getErrorCode(err),
Message: err.Error(),
Timestamp: time.Now(),
Path: c.Request.URL.Path,
})
}
// Only handle first error
break
}
}
// getErrorCode maps errors to error codes
func getErrorCode(err error) string {
switch {
case errors.Is(err, models.ErrNotFound):
return string(models.CodeNotFound)
case errors.Is(err, models.ErrValidationFailed):
return string(models.CodeValidation)
case errors.Is(err, models.ErrUnauthorized):
return string(models.CodeUnauthorized)
case errors.Is(err, models.ErrForbidden):
return string(models.CodeForbidden)
case errors.Is(err, models.ErrConnectionFailed):
return string(models.CodeConnection)
case errors.Is(err, models.ErrQueryFailed):
return string(models.CodeQuery)
case errors.Is(err, models.ErrEncryptionFailed):
return string(models.CodeEncryption)
default:
return string(models.CodeInternal)
}
}

View File

@@ -0,0 +1,98 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"uzdb/internal/config"
)
// LoggerMiddleware returns a logging middleware using Zap
func LoggerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
logger := config.GetLogger()
// Start timer
start := time.Now()
// Process request
c.Next()
// Calculate duration
duration := time.Since(start)
// Get client IP
clientIP := c.ClientIP()
// Get method
method := c.Request.Method
// Get path
path := c.Request.URL.Path
// Get status code
statusCode := c.Writer.Status()
// Get body size
bodySize := c.Writer.Size()
// Determine log level based on status code
var level zapcore.Level
switch {
case statusCode >= 500:
level = zapcore.ErrorLevel
case statusCode >= 400:
level = zapcore.WarnLevel
default:
level = zapcore.InfoLevel
}
// Create fields
fields := []zap.Field{
zap.Int("status", statusCode),
zap.String("method", method),
zap.String("path", path),
zap.String("ip", clientIP),
zap.Duration("duration", duration),
zap.Int("body_size", bodySize),
}
// Add error message if any
if len(c.Errors) > 0 {
fields = append(fields, zap.String("error", c.Errors.String()))
}
// Log the request
logger.Log(level, "HTTP request", fields...)
}
}
// RequestIDMiddleware adds a request ID to each request
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Try to get request ID from header
requestID := c.GetHeader("X-Request-ID")
// Generate if not present
if requestID == "" {
requestID = generateRequestID()
}
// Set request ID in context
c.Set("request_id", requestID)
// Add to response header
c.Header("X-Request-ID", requestID)
c.Next()
}
}
// generateRequestID generates a simple request ID
func generateRequestID() string {
return time.Now().Format("20060102150405") + "-" +
time.Now().Format("000000.000000")[7:]
}

View File

@@ -0,0 +1,85 @@
package models
import (
"time"
)
// ConnectionType represents the type of database connection
type ConnectionType string
const (
// ConnectionTypeMySQL represents MySQL database
ConnectionTypeMySQL ConnectionType = "mysql"
// ConnectionTypePostgreSQL represents PostgreSQL database
ConnectionTypePostgreSQL ConnectionType = "postgres"
// ConnectionTypeSQLite represents SQLite database
ConnectionTypeSQLite ConnectionType = "sqlite"
)
// UserConnection represents a user's database connection configuration
// This model is stored in the local SQLite database
type UserConnection struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Type ConnectionType `gorm:"type:varchar(20);not null" json:"type"`
Host string `gorm:"type:varchar(255)" json:"host,omitempty"`
Port int `gorm:"type:integer" json:"port,omitempty"`
Username string `gorm:"type:varchar(100)" json:"username,omitempty"`
Password string `gorm:"type:text" json:"password"` // Encrypted password
Database string `gorm:"type:varchar(255)" json:"database"`
SSLMode string `gorm:"type:varchar(50)" json:"ssl_mode,omitempty"`
Timeout int `gorm:"type:integer;default:30" json:"timeout"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// For SQLite connections, Database field contains the file path
}
// TableName returns the table name for UserConnection
func (UserConnection) TableName() string {
return "user_connections"
}
// CreateConnectionRequest represents a request to create a new connection
type CreateConnectionRequest struct {
Name string `json:"name" binding:"required"`
Type ConnectionType `json:"type" binding:"required"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database" binding:"required"`
SSLMode string `json:"ssl_mode"`
Timeout int `json:"timeout"`
}
// UpdateConnectionRequest represents a request to update an existing connection
type UpdateConnectionRequest struct {
Name string `json:"name"`
Type ConnectionType `json:"type"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
Password string `json:"password"`
Database string `json:"database"`
SSLMode string `json:"ssl_mode"`
Timeout int `json:"timeout"`
}
// Validate validates the connection request
func (r *CreateConnectionRequest) Validate() error {
switch r.Type {
case ConnectionTypeMySQL, ConnectionTypePostgreSQL:
if r.Host == "" {
return ErrValidationFailed
}
if r.Port <= 0 || r.Port > 65535 {
return ErrValidationFailed
}
case ConnectionTypeSQLite:
if r.Database == "" {
return ErrValidationFailed
}
}
return nil
}

View File

@@ -0,0 +1,74 @@
package models
import "errors"
// Application errors
var (
// ErrNotFound resource not found
ErrNotFound = errors.New("resource not found")
// ErrAlreadyExists resource already exists
ErrAlreadyExists = errors.New("resource already exists")
// ErrValidationFailed validation failed
ErrValidationFailed = errors.New("validation failed")
// ErrUnauthorized unauthorized access
ErrUnauthorized = errors.New("unauthorized access")
// ErrForbidden forbidden access
ErrForbidden = errors.New("forbidden access")
// ErrInternalServer internal server error
ErrInternalServer = errors.New("internal server error")
// ErrConnectionFailed connection failed
ErrConnectionFailed = errors.New("connection failed")
// ErrQueryFailed query execution failed
ErrQueryFailed = errors.New("query execution failed")
// ErrEncryptionFailed encryption/decryption failed
ErrEncryptionFailed = errors.New("encryption/decryption failed")
// ErrInvalidConfig invalid configuration
ErrInvalidConfig = errors.New("invalid configuration")
// ErrDatabaseLocked database is locked
ErrDatabaseLocked = errors.New("database is locked")
// ErrTimeout operation timeout
ErrTimeout = errors.New("operation timeout")
)
// ErrorCode represents error codes for API responses
type ErrorCode string
const (
// CodeSuccess successful operation
CodeSuccess ErrorCode = "SUCCESS"
// CodeNotFound resource not found
CodeNotFound ErrorCode = "NOT_FOUND"
// CodeValidation validation error
CodeValidation ErrorCode = "VALIDATION_ERROR"
// CodeUnauthorized unauthorized
CodeUnauthorized ErrorCode = "UNAUTHORIZED"
// CodeForbidden forbidden
CodeForbidden ErrorCode = "FORBIDDEN"
// CodeInternal internal error
CodeInternal ErrorCode = "INTERNAL_ERROR"
// CodeConnection connection error
CodeConnection ErrorCode = "CONNECTION_ERROR"
// CodeQuery query error
CodeQuery ErrorCode = "QUERY_ERROR"
// CodeEncryption encryption error
CodeEncryption ErrorCode = "ENCRYPTION_ERROR"
)

View File

@@ -0,0 +1,58 @@
package models
import (
"time"
)
// QueryHistory represents a record of executed queries
type QueryHistory struct {
ID uint `gorm:"primaryKey" json:"id"`
ConnectionID string `gorm:"type:varchar(36);not null;index" json:"connection_id"`
SQL string `gorm:"type:text;not null" json:"sql"`
Duration int64 `gorm:"type:bigint" json:"duration_ms"` // Duration in milliseconds
ExecutedAt time.Time `gorm:"autoCreateTime;index" json:"executed_at"`
RowsAffected int64 `gorm:"type:bigint" json:"rows_affected"`
Error string `gorm:"type:text" json:"error,omitempty"`
Success bool `gorm:"type:boolean;default:false" json:"success"`
ResultPreview string `gorm:"type:text" json:"result_preview,omitempty"` // JSON preview of first few rows
}
// TableName returns the table name for QueryHistory
func (QueryHistory) TableName() string {
return "query_history"
}
// SavedQuery represents a saved SQL query
type SavedQuery struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"type:varchar(255);not null" json:"name"`
Description string `gorm:"type:text" json:"description"`
SQL string `gorm:"type:text;not null" json:"sql"`
ConnectionID string `gorm:"type:varchar(36);index" json:"connection_id"`
Tags string `gorm:"type:text" json:"tags"` // Comma-separated tags
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
// TableName returns the table name for SavedQuery
func (SavedQuery) TableName() string {
return "saved_queries"
}
// CreateSavedQueryRequest represents a request to save a query
type CreateSavedQueryRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
SQL string `json:"sql" binding:"required"`
ConnectionID string `json:"connection_id"`
Tags string `json:"tags"`
}
// UpdateSavedQueryRequest represents a request to update a saved query
type UpdateSavedQueryRequest struct {
Name string `json:"name"`
Description string `json:"description"`
SQL string `json:"sql"`
ConnectionID string `json:"connection_id"`
Tags string `json:"tags"`
}

View File

@@ -0,0 +1,128 @@
package models
import (
"time"
)
// APIResponse represents a standard API response
type APIResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// QueryResult represents the result of a SQL query execution
type QueryResult struct {
Columns []string `json:"columns"`
Rows [][]interface{} `json:"rows"`
RowCount int64 `json:"row_count"`
AffectedRows int64 `json:"affected_rows"`
Duration int64 `json:"duration_ms"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// Table represents a database table
type Table struct {
Name string `json:"name"`
Schema string `json:"schema,omitempty"`
Type string `json:"type"` // table, view, etc.
RowCount int64 `json:"row_count,omitempty"`
Description string `json:"description,omitempty"`
}
// TableStructure represents the structure of a database table
type TableStructure struct {
TableName string `json:"table_name"`
Schema string `json:"schema,omitempty"`
Columns []TableColumn `json:"columns"`
Indexes []TableIndex `json:"indexes,omitempty"`
ForeignKeys []ForeignKey `json:"foreign_keys,omitempty"`
}
// TableColumn represents a column in a database table
type TableColumn struct {
Name string `json:"name"`
DataType string `json:"data_type"`
Nullable bool `json:"nullable"`
Default string `json:"default,omitempty"`
IsPrimary bool `json:"is_primary"`
IsUnique bool `json:"is_unique"`
AutoIncrement bool `json:"auto_increment"`
Length int `json:"length,omitempty"`
Scale int `json:"scale,omitempty"`
Comment string `json:"comment,omitempty"`
}
// TableIndex represents an index on a database table
type TableIndex struct {
Name string `json:"name"`
Columns []string `json:"columns"`
IsUnique bool `json:"is_unique"`
IsPrimary bool `json:"is_primary"`
Type string `json:"type,omitempty"`
}
// ForeignKey represents a foreign key constraint
type ForeignKey struct {
Name string `json:"name"`
Columns []string `json:"columns"`
ReferencedTable string `json:"referenced_table"`
ReferencedColumns []string `json:"referenced_columns"`
OnDelete string `json:"on_delete,omitempty"`
OnUpdate string `json:"on_update,omitempty"`
}
// ConnectionTestResult represents the result of a connection test
type ConnectionTestResult struct {
Success bool `json:"success"`
Message string `json:"message"`
Duration int64 `json:"duration_ms"`
Metadata *DBMetadata `json:"metadata,omitempty"`
}
// DBMetadata represents database metadata
type DBMetadata struct {
Version string `json:"version"`
Database string `json:"database"`
User string `json:"user"`
Host string `json:"host"`
Port int `json:"port"`
ServerTime string `json:"server_time"`
}
// ErrorResponse represents an error response
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
Code string `json:"code,omitempty"`
Details map[string]interface{} `json:"details,omitempty"`
Timestamp time.Time `json:"timestamp"`
Path string `json:"path,omitempty"`
}
// PaginatedResponse represents a paginated response
type PaginatedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// NewAPIResponse creates a new API response
func NewAPIResponse(data interface{}) *APIResponse {
return &APIResponse{
Success: true,
Data: data,
}
}
// NewErrorResponse creates a new error response
func NewErrorResponse(message string) *APIResponse {
return &APIResponse{
Success: false,
Error: message,
}
}

View File

@@ -0,0 +1,382 @@
package services
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"uzdb/internal/config"
"uzdb/internal/database"
"uzdb/internal/models"
"uzdb/internal/utils"
)
// ConnectionService manages database connections
type ConnectionService struct {
db *gorm.DB
connManager *database.ConnectionManager
encryptSvc *EncryptionService
}
// NewConnectionService creates a new connection service
func NewConnectionService(
db *gorm.DB,
connManager *database.ConnectionManager,
encryptSvc *EncryptionService,
) *ConnectionService {
return &ConnectionService{
db: db,
connManager: connManager,
encryptSvc: encryptSvc,
}
}
// GetAllConnections returns all user connections
func (s *ConnectionService) GetAllConnections(ctx context.Context) ([]models.UserConnection, error) {
var connections []models.UserConnection
result := s.db.WithContext(ctx).Find(&connections)
if result.Error != nil {
return nil, fmt.Errorf("failed to get connections: %w", result.Error)
}
// Mask passwords in response
for i := range connections {
connections[i].Password = s.encryptSvc.MaskPasswordForLogging(connections[i].Password)
}
config.GetLogger().Debug("retrieved all connections",
zap.Int("count", len(connections)))
return connections, nil
}
// GetConnectionByID returns a connection by ID
func (s *ConnectionService) GetConnectionByID(ctx context.Context, id string) (*models.UserConnection, error) {
var conn models.UserConnection
result := s.db.WithContext(ctx).First(&conn, "id = ?", id)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, models.ErrNotFound
}
return nil, fmt.Errorf("failed to get connection: %w", result.Error)
}
return &conn, nil
}
// CreateConnection creates a new connection
func (s *ConnectionService) CreateConnection(ctx context.Context, req *models.CreateConnectionRequest) (*models.UserConnection, error) {
// Validate request
if err := req.Validate(); err != nil {
return nil, models.ErrValidationFailed
}
// Encrypt password
encryptedPassword := req.Password
if req.Password != "" {
var err error
encryptedPassword, err = s.encryptSvc.EncryptPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to encrypt password: %w", err)
}
}
conn := &models.UserConnection{
ID: utils.GenerateID(),
Name: req.Name,
Type: req.Type,
Host: req.Host,
Port: req.Port,
Username: req.Username,
Password: encryptedPassword,
Database: req.Database,
SSLMode: req.SSLMode,
Timeout: req.Timeout,
}
if conn.Timeout <= 0 {
conn.Timeout = 30
}
result := s.db.WithContext(ctx).Create(conn)
if result.Error != nil {
return nil, fmt.Errorf("failed to create connection: %w", result.Error)
}
// Mask password in response
conn.Password = s.encryptSvc.MaskPasswordForLogging(conn.Password)
config.GetLogger().Info("connection created",
zap.String("id", conn.ID),
zap.String("name", conn.Name),
zap.String("type", string(conn.Type)))
return conn, nil
}
// UpdateConnection updates an existing connection
func (s *ConnectionService) UpdateConnection(ctx context.Context, id string, req *models.UpdateConnectionRequest) (*models.UserConnection, error) {
// Get existing connection
existing, err := s.GetConnectionByID(ctx, id)
if err != nil {
return nil, err
}
// Update fields
if req.Name != "" {
existing.Name = req.Name
}
if req.Type != "" {
existing.Type = req.Type
}
if req.Host != "" {
existing.Host = req.Host
}
if req.Port > 0 {
existing.Port = req.Port
}
if req.Username != "" {
existing.Username = req.Username
}
if req.Password != "" {
// Encrypt new password
encryptedPassword, err := s.encryptSvc.EncryptPassword(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to encrypt password: %w", err)
}
existing.Password = encryptedPassword
}
if req.Database != "" {
existing.Database = req.Database
}
if req.SSLMode != "" {
existing.SSLMode = req.SSLMode
}
if req.Timeout > 0 {
existing.Timeout = req.Timeout
}
result := s.db.WithContext(ctx).Save(existing)
if result.Error != nil {
return nil, fmt.Errorf("failed to update connection: %w", result.Error)
}
// Remove cached connection if exists
if err := s.connManager.RemoveConnection(id); err != nil {
config.GetLogger().Warn("failed to remove cached connection", zap.Error(err))
}
// Mask password in response
existing.Password = s.encryptSvc.MaskPasswordForLogging(existing.Password)
config.GetLogger().Info("connection updated",
zap.String("id", id),
zap.String("name", existing.Name))
return existing, nil
}
// DeleteConnection deletes a connection
func (s *ConnectionService) DeleteConnection(ctx context.Context, id string) error {
// Check if connection exists
if _, err := s.GetConnectionByID(ctx, id); err != nil {
return err
}
// Remove from connection manager
if err := s.connManager.RemoveConnection(id); err != nil {
config.GetLogger().Warn("failed to remove from connection manager", zap.Error(err))
}
// Delete from database
result := s.db.WithContext(ctx).Delete(&models.UserConnection{}, "id = ?", id)
if result.Error != nil {
return fmt.Errorf("failed to delete connection: %w", result.Error)
}
config.GetLogger().Info("connection deleted", zap.String("id", id))
return nil
}
// TestConnection tests a database connection
func (s *ConnectionService) TestConnection(ctx context.Context, id string) (*models.ConnectionTestResult, error) {
// Get connection config
conn, err := s.GetConnectionByID(ctx, id)
if err != nil {
return nil, err
}
// Decrypt password
password, err := s.encryptSvc.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
}
startTime := time.Now()
// Create temporary connection
tempConn, err := s.connManager.GetConnection(conn, password)
if err != nil {
return &models.ConnectionTestResult{
Success: false,
Message: fmt.Sprintf("Connection failed: %v", err),
Duration: time.Since(startTime).Milliseconds(),
}, nil
}
// Get metadata
metadata, err := tempConn.GetMetadata()
if err != nil {
config.GetLogger().Warn("failed to get metadata", zap.Error(err))
}
return &models.ConnectionTestResult{
Success: true,
Message: "Connection successful",
Duration: time.Since(startTime).Milliseconds(),
Metadata: metadata,
}, nil
}
// ExecuteQuery executes a SQL query on a connection
func (s *ConnectionService) ExecuteQuery(ctx context.Context, connectionID, sql string) (*models.QueryResult, error) {
// Get connection config
conn, err := s.GetConnectionByID(ctx, connectionID)
if err != nil {
return nil, err
}
// Decrypt password
password, err := s.encryptSvc.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
}
// Get or create connection
dbConn, err := s.connManager.GetConnection(conn, password)
if err != nil {
return nil, fmt.Errorf("failed to get connection: %w", err)
}
// Execute query
startTime := time.Now()
var result *models.QueryResult
if utils.IsReadOnlyQuery(sql) {
result, err = dbConn.ExecuteQuery(sql)
} else {
result, err = dbConn.ExecuteStatement(sql)
}
duration := time.Since(startTime)
// Record in history
history := &models.QueryHistory{
ConnectionID: connectionID,
SQL: utils.TruncateString(sql, 10000),
Duration: duration.Milliseconds(),
Success: err == nil,
}
if result != nil {
history.RowsAffected = result.AffectedRows
}
if err != nil {
history.Error = err.Error()
}
s.db.WithContext(ctx).Create(history)
if err != nil {
return nil, fmt.Errorf("query execution failed: %w", err)
}
return result, nil
}
// GetTables returns all tables for a connection
func (s *ConnectionService) GetTables(ctx context.Context, connectionID string) ([]models.Table, error) {
// Get connection config
conn, err := s.GetConnectionByID(ctx, connectionID)
if err != nil {
return nil, err
}
// Decrypt password
password, err := s.encryptSvc.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
}
// Get or create connection
dbConn, err := s.connManager.GetConnection(conn, password)
if err != nil {
return nil, fmt.Errorf("failed to get connection: %w", err)
}
return dbConn.GetTables("")
}
// GetTableData returns data from a table
func (s *ConnectionService) GetTableData(
ctx context.Context,
connectionID, tableName string,
limit, offset int,
) (*models.QueryResult, error) {
// Validate limit
if limit <= 0 || limit > 1000 {
limit = 100
}
if offset < 0 {
offset = 0
}
// Build query based on connection type
conn, err := s.GetConnectionByID(ctx, connectionID)
if err != nil {
return nil, err
}
var query string
switch conn.Type {
case models.ConnectionTypeMySQL:
query = fmt.Sprintf("SELECT * FROM `%s` LIMIT %d OFFSET %d", tableName, limit, offset)
case models.ConnectionTypePostgreSQL:
query = fmt.Sprintf(`SELECT * FROM "%s" LIMIT %d OFFSET %d`, tableName, limit, offset)
case models.ConnectionTypeSQLite:
query = fmt.Sprintf(`SELECT * FROM "%s" LIMIT %d OFFSET %d`, tableName, limit, offset)
default:
return nil, models.ErrValidationFailed
}
return s.ExecuteQuery(ctx, connectionID, query)
}
// GetTableStructure returns the structure of a table
func (s *ConnectionService) GetTableStructure(ctx context.Context, connectionID, tableName string) (*models.TableStructure, error) {
// Get connection config
conn, err := s.GetConnectionByID(ctx, connectionID)
if err != nil {
return nil, err
}
// Decrypt password
password, err := s.encryptSvc.DecryptPassword(conn.Password)
if err != nil {
return nil, fmt.Errorf("failed to decrypt password: %w", err)
}
// Get or create connection
dbConn, err := s.connManager.GetConnection(conn, password)
if err != nil {
return nil, fmt.Errorf("failed to get connection: %w", err)
}
return dbConn.GetTableStructure(tableName)
}

View File

@@ -0,0 +1,199 @@
package services
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"os"
"sync"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
"uzdb/internal/utils"
)
// EncryptionService handles encryption and decryption of sensitive data
type EncryptionService struct {
key []byte
cipher cipher.Block
gcm cipher.AEAD
mu sync.RWMutex
}
var (
encryptionInstance *EncryptionService
encryptionOnce sync.Once
)
// GetEncryptionService returns the singleton encryption service instance
func GetEncryptionService() *EncryptionService {
return encryptionInstance
}
// InitEncryptionService initializes the encryption service
func InitEncryptionService(cfg *config.EncryptionConfig) (*EncryptionService, error) {
var err error
encryptionOnce.Do(func() {
encryptionInstance = &EncryptionService{}
err = encryptionInstance.init(cfg)
})
return encryptionInstance, err
}
// init initializes the encryption service with a key
func (s *EncryptionService) init(cfg *config.EncryptionConfig) error {
s.mu.Lock()
defer s.mu.Unlock()
// Try to load existing key or generate new one
key, err := s.loadOrGenerateKey(cfg)
if err != nil {
return fmt.Errorf("failed to load/generate key: %w", err)
}
s.key = key
// Create AES cipher
block, err := aes.NewCipher(key)
if err != nil {
return fmt.Errorf("failed to create cipher: %w", err)
}
s.cipher = block
// Create GCM mode
gcm, err := cipher.NewGCM(block)
if err != nil {
return fmt.Errorf("failed to create GCM: %w", err)
}
s.gcm = gcm
config.GetLogger().Info("encryption service initialized")
return nil
}
// loadOrGenerateKey loads existing key or generates a new one
func (s *EncryptionService) loadOrGenerateKey(cfg *config.EncryptionConfig) ([]byte, error) {
// Use provided key if available
if cfg.Key != "" {
key := []byte(cfg.Key)
// Ensure key is correct length (32 bytes for AES-256)
if len(key) < 32 {
// Pad key
padded := make([]byte, 32)
copy(padded, key)
key = padded
} else if len(key) > 32 {
key = key[:32]
}
return key, nil
}
// Try to load from file
if cfg.KeyFile != "" {
if data, err := os.ReadFile(cfg.KeyFile); err == nil {
key := []byte(data)
if len(key) >= 32 {
return key[:32], nil
}
}
}
// Generate new key
key := make([]byte, 32) // AES-256
if _, err := io.ReadFull(rand.Reader, key); err != nil {
return nil, fmt.Errorf("failed to generate key: %w", err)
}
// Save key to file if path provided
if cfg.KeyFile != "" {
if err := os.WriteFile(cfg.KeyFile, key, 0600); err != nil {
config.GetLogger().Warn("failed to save encryption key", zap.Error(err))
} else {
config.GetLogger().Info("encryption key generated and saved",
zap.String("path", cfg.KeyFile))
}
}
return key, nil
}
// Encrypt encrypts plaintext using AES-GCM
func (s *EncryptionService) Encrypt(plaintext string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.gcm == nil {
return "", models.ErrEncryptionFailed
}
// Generate nonce
nonce := make([]byte, s.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt
ciphertext := s.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
// Encode to base64
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// Decrypt decrypts ciphertext using AES-GCM
func (s *EncryptionService) Decrypt(ciphertext string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.gcm == nil {
return "", models.ErrEncryptionFailed
}
// Decode from base64
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
// Verify nonce size
nonceSize := s.gcm.NonceSize()
if len(data) < nonceSize {
return "", models.ErrEncryptionFailed
}
// Extract nonce and ciphertext
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
// Decrypt
plaintext, err := s.gcm.Open(nil, nonce, ciphertextBytes, nil)
if err != nil {
return "", fmt.Errorf("failed to decrypt: %w", err)
}
return string(plaintext), nil
}
// EncryptPassword encrypts a password for storage
func (s *EncryptionService) EncryptPassword(password string) (string, error) {
if password == "" {
return "", nil
}
return s.Encrypt(password)
}
// DecryptPassword decrypts a stored password
func (s *EncryptionService) DecryptPassword(encryptedPassword string) (string, error) {
if encryptedPassword == "" {
return "", nil
}
return s.Decrypt(encryptedPassword)
}
// MaskPasswordForLogging masks password for safe logging
func (s *EncryptionService) MaskPasswordForLogging(password string) string {
return utils.MaskPassword(password)
}

View File

@@ -0,0 +1,236 @@
package services
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"uzdb/internal/config"
"uzdb/internal/models"
)
// QueryService handles query-related operations
type QueryService struct {
db *gorm.DB
}
// NewQueryService creates a new query service
func NewQueryService(db *gorm.DB) *QueryService {
return &QueryService{
db: db,
}
}
// GetQueryHistory returns query history with pagination
func (s *QueryService) GetQueryHistory(
ctx context.Context,
connectionID string,
page, pageSize int,
) ([]models.QueryHistory, int64, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 || pageSize > 100 {
pageSize = 20
}
var total int64
var history []models.QueryHistory
query := s.db.WithContext(ctx).Model(&models.QueryHistory{})
if connectionID != "" {
query = query.Where("connection_id = ?", connectionID)
}
// Get total count
if err := query.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count history: %w", err)
}
// Get paginated results
offset := (page - 1) * pageSize
if err := query.Order("executed_at DESC").
Offset(offset).
Limit(pageSize).
Find(&history).Error; err != nil {
return nil, 0, fmt.Errorf("failed to get history: %w", err)
}
config.GetLogger().Debug("retrieved query history",
zap.String("connection_id", connectionID),
zap.Int("page", page),
zap.Int("page_size", pageSize),
zap.Int64("total", total))
return history, total, nil
}
// GetSavedQueries returns all saved queries
func (s *QueryService) GetSavedQueries(
ctx context.Context,
connectionID string,
) ([]models.SavedQuery, error) {
var queries []models.SavedQuery
query := s.db.WithContext(ctx)
if connectionID != "" {
query = query.Where("connection_id = ?", connectionID)
}
result := query.Order("name ASC").Find(&queries)
if result.Error != nil {
return nil, fmt.Errorf("failed to get saved queries: %w", result.Error)
}
return queries, nil
}
// GetSavedQueryByID returns a saved query by ID
func (s *QueryService) GetSavedQueryByID(ctx context.Context, id uint) (*models.SavedQuery, error) {
var query models.SavedQuery
result := s.db.WithContext(ctx).First(&query, "id = ?", id)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, models.ErrNotFound
}
return nil, fmt.Errorf("failed to get saved query: %w", result.Error)
}
return &query, nil
}
// CreateSavedQuery creates a new saved query
func (s *QueryService) CreateSavedQuery(
ctx context.Context,
req *models.CreateSavedQueryRequest,
) (*models.SavedQuery, error) {
query := &models.SavedQuery{
Name: req.Name,
Description: req.Description,
SQL: req.SQL,
ConnectionID: req.ConnectionID,
Tags: req.Tags,
}
result := s.db.WithContext(ctx).Create(query)
if result.Error != nil {
return nil, fmt.Errorf("failed to create saved query: %w", result.Error)
}
config.GetLogger().Info("saved query created",
zap.Uint("id", query.ID),
zap.String("name", query.Name))
return query, nil
}
// UpdateSavedQuery updates an existing saved query
func (s *QueryService) UpdateSavedQuery(
ctx context.Context,
id uint,
req *models.UpdateSavedQueryRequest,
) (*models.SavedQuery, error) {
// Get existing query
existing, err := s.GetSavedQueryByID(ctx, id)
if err != nil {
return nil, err
}
// Update fields
if req.Name != "" {
existing.Name = req.Name
}
if req.Description != "" {
existing.Description = req.Description
}
if req.SQL != "" {
existing.SQL = req.SQL
}
if req.ConnectionID != "" {
existing.ConnectionID = req.ConnectionID
}
if req.Tags != "" {
existing.Tags = req.Tags
}
result := s.db.WithContext(ctx).Save(existing)
if result.Error != nil {
return nil, fmt.Errorf("failed to update saved query: %w", result.Error)
}
config.GetLogger().Info("saved query updated",
zap.Uint("id", id),
zap.String("name", existing.Name))
return existing, nil
}
// DeleteSavedQuery deletes a saved query
func (s *QueryService) DeleteSavedQuery(ctx context.Context, id uint) error {
// Check if exists
if _, err := s.GetSavedQueryByID(ctx, id); err != nil {
return err
}
result := s.db.WithContext(ctx).Delete(&models.SavedQuery{}, "id = ?", id)
if result.Error != nil {
return fmt.Errorf("failed to delete saved query: %w", result.Error)
}
config.GetLogger().Info("saved query deleted", zap.Uint("id", id))
return nil
}
// ClearOldHistory clears query history older than specified days
func (s *QueryService) ClearOldHistory(ctx context.Context, days int) (int64, error) {
if days <= 0 {
days = 30 // Default to 30 days
}
cutoffTime := time.Now().AddDate(0, 0, -days)
result := s.db.WithContext(ctx).
Where("executed_at < ?", cutoffTime).
Delete(&models.QueryHistory{})
if result.Error != nil {
return 0, fmt.Errorf("failed to clear old history: %w", result.Error)
}
config.GetLogger().Info("cleared old query history",
zap.Int64("deleted_count", result.RowsAffected),
zap.Int("days", days))
return result.RowsAffected, nil
}
// GetRecentQueries returns recent queries for quick access
func (s *QueryService) GetRecentQueries(
ctx context.Context,
connectionID string,
limit int,
) ([]models.QueryHistory, error) {
if limit <= 0 || limit > 50 {
limit = 10
}
var queries []models.QueryHistory
query := s.db.WithContext(ctx).
Where("connection_id = ? AND success = ?", connectionID, true).
Order("executed_at DESC").
Limit(limit).
Find(&queries)
if query.Error != nil {
return nil, fmt.Errorf("failed to get recent queries: %w", query.Error)
}
return queries, nil
}

View File

@@ -0,0 +1,136 @@
package utils
import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
"time"
)
// GenerateID generates a unique ID (UUID-like)
func GenerateID() string {
b := make([]byte, 16)
rand.Read(b)
return formatUUID(b)
}
// formatUUID formats bytes as UUID string
func formatUUID(b []byte) string {
uuid := make([]byte, 36)
hex.Encode(uuid[0:8], b[0:4])
hex.Encode(uuid[9:13], b[4:6])
hex.Encode(uuid[14:18], b[6:8])
hex.Encode(uuid[19:23], b[8:10])
hex.Encode(uuid[24:], b[10:])
uuid[8] = '-'
uuid[13] = '-'
uuid[18] = '-'
uuid[23] = '-'
return string(uuid)
}
// FormatDuration formats duration in milliseconds
func FormatDuration(d time.Duration) int64 {
return d.Milliseconds()
}
// SanitizeSQL removes potentially dangerous SQL patterns
// Note: This is not a replacement for parameterized queries
func SanitizeSQL(sql string) string {
// Remove multiple semicolons
sql = strings.ReplaceAll(sql, ";;", ";")
// Trim whitespace
sql = strings.TrimSpace(sql)
return sql
}
// TruncateString truncates a string to max length
func TruncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
// GenerateRandomBytes generates random bytes
func GenerateRandomBytes(n int) ([]byte, error) {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
}
return b, nil
}
// EncodeBase64 encodes bytes to base64 string
func EncodeBase64(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
// DecodeBase64 decodes base64 string to bytes
func DecodeBase64(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
// MaskPassword masks password for logging
func MaskPassword(password string) string {
if len(password) <= 4 {
return strings.Repeat("*", len(password))
}
return password[:2] + strings.Repeat("*", len(password)-2)
}
// ContainsAny checks if string contains any of the substrings
func ContainsAny(s string, substrings ...string) bool {
for _, sub := range substrings {
if strings.Contains(s, sub) {
return true
}
}
return false
}
// IsReadOnlyQuery checks if SQL query is read-only
func IsReadOnlyQuery(sql string) bool {
sql = strings.TrimSpace(strings.ToUpper(sql))
// Read-only operations
readOnlyPrefixes := []string{
"SELECT",
"SHOW",
"DESCRIBE",
"EXPLAIN",
"WITH",
}
for _, prefix := range readOnlyPrefixes {
if strings.HasPrefix(sql, prefix) {
return true
}
}
return false
}
// IsDDLQuery checks if SQL query is DDL
func IsDDLQuery(sql string) bool {
sql = strings.TrimSpace(strings.ToUpper(sql))
ddlKeywords := []string{
"CREATE",
"ALTER",
"DROP",
"TRUNCATE",
"RENAME",
}
for _, keyword := range ddlKeywords {
if strings.HasPrefix(sql, keyword) {
return true
}
}
return false
}

View File

@@ -0,0 +1,102 @@
package utils
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"uzdb/internal/config"
"uzdb/internal/models"
)
// ErrorResponse sends an error response
func ErrorResponse(c *gin.Context, statusCode int, err error, message string) {
logger := config.GetLogger()
response := models.ErrorResponse{
Error: getErrorCode(err),
Message: message,
Timestamp: time.Now(),
Path: c.Request.URL.Path,
}
if message == "" {
response.Message = err.Error()
}
// Log the error
logger.Error("error response",
zap.Int("status_code", statusCode),
zap.String("error", response.Error),
zap.String("message", response.Message),
zap.String("path", response.Path),
)
c.JSON(statusCode, response)
}
// SuccessResponse sends a success response
func SuccessResponse(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, models.NewAPIResponse(data))
}
// CreatedResponse sends a created response
func CreatedResponse(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, models.NewAPIResponse(data))
}
// getErrorCode maps errors to error codes
func getErrorCode(err error) string {
if err == nil {
return string(models.CodeSuccess)
}
switch {
case errors.Is(err, models.ErrNotFound):
return string(models.CodeNotFound)
case errors.Is(err, models.ErrValidationFailed):
return string(models.CodeValidation)
case errors.Is(err, models.ErrUnauthorized):
return string(models.CodeUnauthorized)
case errors.Is(err, models.ErrForbidden):
return string(models.CodeForbidden)
case errors.Is(err, models.ErrConnectionFailed):
return string(models.CodeConnection)
case errors.Is(err, models.ErrQueryFailed):
return string(models.CodeQuery)
case errors.Is(err, models.ErrEncryptionFailed):
return string(models.CodeEncryption)
default:
return string(models.CodeInternal)
}
}
// WrapError wraps an error with context
func WrapError(err error, message string) error {
if err == nil {
return nil
}
return &wrappedError{
err: err,
message: message,
}
}
type wrappedError struct {
err error
message string
}
func (w *wrappedError) Error() string {
if w.message != "" {
return w.message + ": " + w.err.Error()
}
return w.err.Error()
}
func (w *wrappedError) Unwrap() error {
return w.err
}

197
uzdb/main.go Normal file
View File

@@ -0,0 +1,197 @@
package main
import (
"context"
"embed"
"fmt"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"go.uber.org/zap"
"uzdb/internal/app"
"uzdb/internal/config"
"uzdb/internal/database"
"uzdb/internal/handler"
"uzdb/internal/services"
)
//go:embed all:frontend/dist
var assets embed.FS
// application holds the global application state
type application struct {
app *app.App
config *config.Config
connManager *database.ConnectionManager
encryptSvc *services.EncryptionService
connectionSvc *services.ConnectionService
querySvc *services.QueryService
httpServer *handler.HTTPServer
}
func main() {
// Initialize application
appState, err := initApplication()
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to initialize application: %v\n", err)
os.Exit(1)
}
// Create Wails app with options
err = wails.Run(&options.App{
Title: "uzdb",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: func(ctx context.Context) {
appState.app.OnStartup(ctx)
},
OnShutdown: func(ctx context.Context) {
appState.app.Shutdown()
},
Bind: []interface{}{
appState.app,
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error running application: %v\n", err)
os.Exit(1)
}
}
// initApplication initializes all application components
func initApplication() (*application, error) {
appState := &application{}
// Determine data directory
dataDir, err := getDataDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get data directory: %w", err)
}
// Initialize configuration
cfg, err := config.Init(dataDir)
if err != nil {
return nil, fmt.Errorf("failed to initialize config: %w", err)
}
appState.config = cfg
logger := config.GetLogger()
logger.Info("initializing application", zap.String("data_dir", dataDir))
// Initialize encryption service
encryptSvc, err := services.InitEncryptionService(&cfg.Encryption)
if err != nil {
logger.Error("failed to initialize encryption service", zap.Error(err))
return nil, fmt.Errorf("failed to initialize encryption: %w", err)
}
appState.encryptSvc = encryptSvc
// Initialize SQLite database (for app data)
sqliteDB, err := database.InitSQLite(cfg.Database.SQLitePath, &cfg.Database)
if err != nil {
logger.Error("failed to initialize SQLite", zap.Error(err))
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize connection manager
appState.connManager = database.NewConnectionManager()
// Initialize services
appState.connectionSvc = services.NewConnectionService(
sqliteDB,
appState.connManager,
encryptSvc,
)
appState.querySvc = services.NewQueryService(sqliteDB)
// Initialize HTTP server (for debugging/API access)
appState.httpServer = handler.NewHTTPServer(
appState.connectionSvc,
appState.querySvc,
)
// Initialize Wails app wrapper
appState.app = app.NewApp()
appState.app.Initialize(
cfg,
appState.connectionSvc,
appState.querySvc,
appState.httpServer,
)
logger.Info("application initialized successfully")
return appState, nil
}
// getDataDirectory returns the appropriate data directory based on platform
func getDataDirectory() (string, error) {
// Check environment variable first
if envDir := os.Getenv("UZDB_DATA_DIR"); envDir != "" {
return envDir, nil
}
var dataDir string
// Platform-specific directories
switch os.Getenv("GOOS") {
case "windows":
appData := os.Getenv("APPDATA")
if appData == "" {
return "", fmt.Errorf("APPDATA environment variable not set")
}
dataDir = filepath.Join(appData, "uzdb")
case "darwin": // macOS
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
dataDir = filepath.Join(homeDir, "Library", "Application Support", "uzdb")
default: // Linux and others
// Try XDG data directory
xdgData := os.Getenv("XDG_DATA_HOME")
if xdgData != "" {
dataDir = filepath.Join(xdgData, "uzdb")
} else {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
dataDir = filepath.Join(homeDir, ".local", "share", "uzdb")
}
}
// For development, use local directory
if _, err := os.Stat("frontend"); err == nil {
dataDir = "./data"
}
// Ensure directory exists
if err := os.MkdirAll(dataDir, 0755); err != nil {
return "", fmt.Errorf("failed to create data directory: %w", err)
}
return dataDir, nil
}
// Helper functions exposed for testing
func GetVersion() string {
return "1.0.0"
}
func GetBuildTime() string {
return time.Now().Format(time.RFC3339)
}

14
uzdb/test-api.sh Normal file
View File

@@ -0,0 +1,14 @@
# Quick start for testing connections and queries
curl -X GET http://localhost:8080/api/connections
curl -X POST http://localhost:8080/api/connections \
-H "Content-Type: application/json" \
-d '{
"name": "Test MySQL",
"type": "mysql",
"host": "localhost",
"port": 3306,
"username": "root",
"password": "password",
"database": "test"
}'

13
uzdb/wails.json Normal file
View File

@@ -0,0 +1,13 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "uzdb",
"outputfilename": "uzdb",
"frontend:install": "npm install",
"frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "loveuer",
"email": "loveuer@live.com"
}
}