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:
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal 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
|
||||||
34
.qoder/agents/uiux-designer.md
Normal file
34
.qoder/agents/uiux-designer.md
Normal 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
186
doc/README.md
Normal 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
217
doc/design-system.md
Normal 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
377
doc/features.md
Normal 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
691
doc/layout-design.md
Normal 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
396
doc/user-flows.md
Normal 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
228
doc/wireframes.md
Normal 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
46
uzdb/.gitignore
vendored
Normal 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
164
uzdb/API_TEST.md
Normal 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
228
uzdb/README.md
Normal 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
27
uzdb/app.go
Normal 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
5
uzdb/build.sh
Normal 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
35
uzdb/build/README.md
Normal 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
BIN
uzdb/build/appicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
68
uzdb/build/darwin/Info.dev.plist
Normal file
68
uzdb/build/darwin/Info.dev.plist
Normal 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>
|
||||||
63
uzdb/build/darwin/Info.plist
Normal file
63
uzdb/build/darwin/Info.plist
Normal 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
BIN
uzdb/build/windows/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
15
uzdb/build/windows/info.json
Normal file
15
uzdb/build/windows/info.json
Normal 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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
uzdb/build/windows/installer/project.nsi
Normal file
114
uzdb/build/windows/installer/project.nsi
Normal 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
|
||||||
249
uzdb/build/windows/installer/wails_tools.nsh
Normal file
249
uzdb/build/windows/installer/wails_tools.nsh
Normal 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
|
||||||
15
uzdb/build/windows/wails.exe.manifest
Normal file
15
uzdb/build/windows/wails.exe.manifest
Normal 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
13
uzdb/frontend/index.html
Normal 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
2121
uzdb/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
uzdb/frontend/package.json
Normal file
22
uzdb/frontend/package.json
Normal 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
1
uzdb/frontend/package.json.md5
Executable file
@@ -0,0 +1 @@
|
|||||||
|
f26173c7304a0bf8ea5c86eb567e7db2
|
||||||
105
uzdb/frontend/src/App.css
Normal file
105
uzdb/frontend/src/App.css
Normal 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
307
uzdb/frontend/src/App.tsx
Normal 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;
|
||||||
93
uzdb/frontend/src/assets/fonts/OFL.txt
Normal file
93
uzdb/frontend/src/assets/fonts/OFL.txt
Normal 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.
|
||||||
BIN
uzdb/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
BIN
uzdb/frontend/src/assets/fonts/nunito-v16-latin-regular.woff2
Normal file
Binary file not shown.
BIN
uzdb/frontend/src/assets/images/logo-universal.png
Normal file
BIN
uzdb/frontend/src/assets/images/logo-universal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
134
uzdb/frontend/src/components/Layout/AppLayout.css
Normal file
134
uzdb/frontend/src/components/Layout/AppLayout.css
Normal 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;
|
||||||
|
}
|
||||||
155
uzdb/frontend/src/components/Layout/AppLayout.tsx
Normal file
155
uzdb/frontend/src/components/Layout/AppLayout.tsx
Normal 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;
|
||||||
120
uzdb/frontend/src/components/Layout/StatusBar.css
Normal file
120
uzdb/frontend/src/components/Layout/StatusBar.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
uzdb/frontend/src/components/Layout/StatusBar.tsx
Normal file
126
uzdb/frontend/src/components/Layout/StatusBar.tsx
Normal 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;
|
||||||
124
uzdb/frontend/src/components/Layout/ToolBar.css
Normal file
124
uzdb/frontend/src/components/Layout/ToolBar.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
uzdb/frontend/src/components/Layout/ToolBar.tsx
Normal file
104
uzdb/frontend/src/components/Layout/ToolBar.tsx
Normal 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;
|
||||||
330
uzdb/frontend/src/components/MainArea/DataGrid.css
Normal file
330
uzdb/frontend/src/components/MainArea/DataGrid.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
495
uzdb/frontend/src/components/MainArea/DataGrid.tsx
Normal file
495
uzdb/frontend/src/components/MainArea/DataGrid.tsx
Normal 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;
|
||||||
347
uzdb/frontend/src/components/MainArea/QueryEditor.css
Normal file
347
uzdb/frontend/src/components/MainArea/QueryEditor.css
Normal 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);
|
||||||
|
}
|
||||||
301
uzdb/frontend/src/components/MainArea/QueryEditor.tsx
Normal file
301
uzdb/frontend/src/components/MainArea/QueryEditor.tsx
Normal 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... -- Example: SELECT * FROM users; "
|
||||||
|
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;
|
||||||
279
uzdb/frontend/src/components/MainArea/TableStructure.css
Normal file
279
uzdb/frontend/src/components/MainArea/TableStructure.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
347
uzdb/frontend/src/components/MainArea/TableStructure.tsx
Normal file
347
uzdb/frontend/src/components/MainArea/TableStructure.tsx
Normal 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;
|
||||||
170
uzdb/frontend/src/components/MenuBar/MenuBar.css
Normal file
170
uzdb/frontend/src/components/MenuBar/MenuBar.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
267
uzdb/frontend/src/components/MenuBar/MenuBar.tsx
Normal file
267
uzdb/frontend/src/components/MenuBar/MenuBar.tsx
Normal 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;
|
||||||
235
uzdb/frontend/src/components/Sidebar/ConnectionPanel.css
Normal file
235
uzdb/frontend/src/components/Sidebar/ConnectionPanel.css
Normal 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;
|
||||||
|
}
|
||||||
434
uzdb/frontend/src/components/Sidebar/ConnectionPanel.tsx
Normal file
434
uzdb/frontend/src/components/Sidebar/ConnectionPanel.tsx
Normal 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;
|
||||||
73
uzdb/frontend/src/components/common/StatusIndicator.css
Normal file
73
uzdb/frontend/src/components/common/StatusIndicator.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
uzdb/frontend/src/components/common/StatusIndicator.tsx
Normal file
93
uzdb/frontend/src/components/common/StatusIndicator.tsx
Normal 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;
|
||||||
63
uzdb/frontend/src/components/index.ts
Normal file
63
uzdb/frontend/src/components/index.ts
Normal 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
364
uzdb/frontend/src/index.css
Normal 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); }
|
||||||
14
uzdb/frontend/src/main.tsx
Normal file
14
uzdb/frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
134
uzdb/frontend/src/mock/connections.ts
Normal file
134
uzdb/frontend/src/mock/connections.ts
Normal 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;
|
||||||
232
uzdb/frontend/src/mock/queryResults.ts
Normal file
232
uzdb/frontend/src/mock/queryResults.ts
Normal 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,
|
||||||
|
};
|
||||||
40
uzdb/frontend/src/style.css
Normal file
40
uzdb/frontend/src/style.css
Normal 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
1
uzdb/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
31
uzdb/frontend/tsconfig.json
Normal file
31
uzdb/frontend/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
uzdb/frontend/tsconfig.node.json
Normal file
11
uzdb/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"vite.config.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
7
uzdb/frontend/vite.config.ts
Normal file
7
uzdb/frontend/vite.config.ts
Normal 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
4
uzdb/frontend/wailsjs/go/main/App.d.ts
vendored
Executable 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>;
|
||||||
7
uzdb/frontend/wailsjs/go/main/App.js
Executable file
7
uzdb/frontend/wailsjs/go/main/App.js
Executable 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);
|
||||||
|
}
|
||||||
24
uzdb/frontend/wailsjs/runtime/package.json
Normal file
24
uzdb/frontend/wailsjs/runtime/package.json
Normal 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"
|
||||||
|
}
|
||||||
330
uzdb/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
330
uzdb/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal 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>;
|
||||||
298
uzdb/frontend/wailsjs/runtime/runtime.js
Normal file
298
uzdb/frontend/wailsjs/runtime/runtime.js
Normal 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
82
uzdb/go.mod
Normal 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
219
uzdb/go.sum
Normal 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
324
uzdb/internal/app/app.go
Normal 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()
|
||||||
|
}
|
||||||
261
uzdb/internal/config/config.go
Normal file
261
uzdb/internal/config/config.go
Normal 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
|
||||||
|
}
|
||||||
137
uzdb/internal/database/manager.go
Normal file
137
uzdb/internal/database/manager.go
Normal 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
|
||||||
|
}
|
||||||
414
uzdb/internal/database/mysql.go
Normal file
414
uzdb/internal/database/mysql.go
Normal 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
|
||||||
|
}
|
||||||
449
uzdb/internal/database/postgres.go
Normal file
449
uzdb/internal/database/postgres.go
Normal 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
|
||||||
|
}
|
||||||
128
uzdb/internal/database/sqlite.go
Normal file
128
uzdb/internal/database/sqlite.go
Normal 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
|
||||||
|
}
|
||||||
369
uzdb/internal/database/sqlite_driver.go
Normal file
369
uzdb/internal/database/sqlite_driver.go
Normal 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
|
||||||
|
}
|
||||||
195
uzdb/internal/handler/connection.go
Normal file
195
uzdb/internal/handler/connection.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
287
uzdb/internal/handler/query.go
Normal file
287
uzdb/internal/handler/query.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
uzdb/internal/handler/server.go
Normal file
95
uzdb/internal/handler/server.go
Normal 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)
|
||||||
|
}
|
||||||
101
uzdb/internal/middleware/cors.go
Normal file
101
uzdb/internal/middleware/cors.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
125
uzdb/internal/middleware/error.go
Normal file
125
uzdb/internal/middleware/error.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
uzdb/internal/middleware/logger.go
Normal file
98
uzdb/internal/middleware/logger.go
Normal 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:]
|
||||||
|
}
|
||||||
85
uzdb/internal/models/connection.go
Normal file
85
uzdb/internal/models/connection.go
Normal 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
|
||||||
|
}
|
||||||
74
uzdb/internal/models/errors.go
Normal file
74
uzdb/internal/models/errors.go
Normal 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"
|
||||||
|
)
|
||||||
58
uzdb/internal/models/query.go
Normal file
58
uzdb/internal/models/query.go
Normal 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"`
|
||||||
|
}
|
||||||
128
uzdb/internal/models/response.go
Normal file
128
uzdb/internal/models/response.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
382
uzdb/internal/services/connection.go
Normal file
382
uzdb/internal/services/connection.go
Normal 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)
|
||||||
|
}
|
||||||
199
uzdb/internal/services/encryption.go
Normal file
199
uzdb/internal/services/encryption.go
Normal 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)
|
||||||
|
}
|
||||||
236
uzdb/internal/services/query.go
Normal file
236
uzdb/internal/services/query.go
Normal 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
|
||||||
|
}
|
||||||
136
uzdb/internal/utils/errors.go
Normal file
136
uzdb/internal/utils/errors.go
Normal 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
|
||||||
|
}
|
||||||
102
uzdb/internal/utils/response.go
Normal file
102
uzdb/internal/utils/response.go
Normal 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
197
uzdb/main.go
Normal 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
14
uzdb/test-api.sh
Normal 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
13
uzdb/wails.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user