This commit is contained in:
2026-03-19 14:36:35 +07:00
parent 6d7d86befd
commit 96635dbcf2
28 changed files with 4332 additions and 1683 deletions

View File

@@ -1,138 +1,156 @@
# ============================================================================= # ============================================================================
# Git # Git & Version Control
# ============================================================================= # ============================================================================
.git .git
.gitea .gitea
.github .github
.gitlab .gitlab
.gitlab-ci.yml
.gitattributes .gitattributes
.gitignore
.pre-commit-config.yaml .pre-commit-config.yaml
# ============================================================================= # ============================================================================
# Python virtual environments # Node.js / npm
# ============================================================================= # ============================================================================
.venv
venv
env
ENV
# =============================================================================
# Python cache
# =============================================================================
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
# =============================================================================
# Python tooling
# =============================================================================
.mypy_cache/
.pytest_cache/
.ruff_cache/
.pytype/
.pyre/
.pyright/
# =============================================================================
# Testing / Coverage
# =============================================================================
.coverage
.coverage.*
htmlcov/
.tox/
.nox/
tests/
test/
coverage.xml
# =============================================================================
# Build artifacts
# =============================================================================
build/
dist/
.eggs/
*.egg-info/
pip-wheel-metadata/
# =============================================================================
# Logs
# =============================================================================
*.log
logs/
log/
# =============================================================================
# Node / Frontend
# =============================================================================
node_modules/ node_modules/
.next/ npm-debug.log*
.nuxt/ yarn-debug.log*
out/ yarn-error.log*
coverage/ pnpm-debug.log*
*.tsbuildinfo pnpm-lock.yaml
yarn.lock
package-lock.json
# ============================================================================= # ============================================================================
# IDE / Editor # IDE & Editor
# ============================================================================= # ============================================================================
.idea/
.vscode/ .vscode/
.idea/
*.swp *.swp
*.swo *.swo
*~ *~
.DS_Store .DS_Store
Thumbs.db Thumbs.db
*.sublime-project
*.sublime-workspace
.editorconfig
# ============================================================================= # ============================================================================
# Environment files # Documentation & Configuration
# ============================================================================= # ============================================================================
README.md
CHANGELOG.md
ARCHITECTURE.md
DEVELOPMENT.md
LICENSE
LICENSE.md
CONTRIBUTING.md
.prettierrc*
.eslintrc*
.stylelintrc*
# ============================================================================
# Build & Distribution
# ============================================================================
dist/
build/
out/
coverage/
.next/
.nuxt/
*.tsbuildinfo
# ============================================================================
# Testing
# ============================================================================
__tests__/
__test__/
test/
tests/
*.test.js
*.spec.js
.coverage
.nyc_output/
jest.config.js
karma.conf.js
# ============================================================================
# Environment & Secrets
# ============================================================================
.env .env
.env.* .env.local
!.env.example .env.*.local
!.env.sample .env.test
.env.production
# ============================================================================= .envrc
# Databases .env-cmdrc.json
# ============================================================================= .secrets/
*.db
*.sqlite
*.sqlite3
# =============================================================================
# Secrets
# =============================================================================
*.pem *.pem
*.key *.key
*.crt *.crt
*.p12 *.p12
*.pfx *.pfx
secrets/
# ============================================================================= # ============================================================================
# Temporary # Temporary & Cache Files
# ============================================================================= # ============================================================================
tmp/ tmp/
temp/ temp/
.tmp/
.cache/
*.tmp *.tmp
*.temp *.temp
.cache/ .DS_Store
Thumbs.db
ehthumbs.db
# ============================================================================= # ============================================================================
# Jupyter # Logs
# ============================================================================= # ============================================================================
.ipynb_checkpoints/ *.log
logs/
log/
# ============================================================================= # ============================================================================
# ML artifacts # Database & Data
# ============================================================================= # ============================================================================
*.pt postgres_data/
*.pth database_backups/
*.onnx *.db
*.h5 *.sqlite
*.ckpt *.sqlite3
*.safetensors *.sql
*.npy *.sql.gz
*.npz
*.parquet # ============================================================================
# Docker & Container Files (these files should not be in the container)
# ============================================================================
Dockerfile
docker-compose*.yml
.dockerignore
.docker/
# ============================================================================
# CI/CD
# ============================================================================
.github/
.gitlab-ci.yml
.circleci/
Jenkinsfile
.travis.yml
.appveyor.yml
azure-pipelines.yml
.drone.yml
# ============================================================================
# Misc
# ============================================================================
tsconfig.json
babel.config.js
webpack.config.js
rollup.config.js
gulpfile.js
Makefile
.nvmrc
.node-version
.npmrc

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Environment
NODE_ENV=development
# Server
PORT=3000
SESSION_SECRET=your-secret-key-change-this-in-production
# Database Configuration
DB_HOST=localhost
DB_PORT=5432
DB_NAME=postgres
DB_USER=postgres
DB_PASSWORD=postgres
# Admin credentials for first login
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=admin123

326
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,326 @@
# Архитектура PG-Admin
## Обзор архитектуры
### Трёхслойная архитектура
```
┌─────────────────────────────────────────────┐
│ Presentation Layer │
│ (Frontend - React-like модули) │
│ - module-based structure │
│ - CSS isolation per module │
│ - Theme switching │
└──────────────┬──────────────────────────────┘
┌──────────────┴──────────────────────────────┐
│ Application Layer │
│ (JavaScript - SPA Router, API Client) │
│ - Client-side routing │
│ - State management │
│ - API communication │
└──────────────┬──────────────────────────────┘
┌──────────────┴──────────────────────────────┐
│ API Layer (Backend) │
│ (Express.js - RESTful API) │
│ - Authentication routes │
│ - User management routes │
│ - Database routes │
│ - Admin routes │
└──────────────┬──────────────────────────────┘
┌──────────────┴──────────────────────────────┐
│ Data Access Layer │
│ (PostgreSQL Database) │
│ - users table │
│ - activity_logs table │
│ - user-managed tables │
└─────────────────────────────────────────────┘
```
## Frontend архитектура
### Модульная структура
```
public/
├── index.html # Main SPA container
├── modules/
│ ├── auth/
│ │ └── login.html # Auth module template
│ ├── dashboard/
│ │ └── dashboard.html # Dashboard module template
│ └── admin/
│ └── admin-panel.html # Admin module template
├── styles/
│ ├── main.css # Global styles (components)
│ ├── theme.css # Dark/Light mode
│ ├── responsive.css # Mobile-first responsive
│ └── animations.css # Transitions & animations
└── js/
├── theme.js # Theme management
├── api.js # API client
├── auth.js # Auth logic
├── router.js # SPA routing
└── app.js # App orchestration
```
### Разделение ответственности
**theme.js**
- Управление темой (dark/light)
- Сохранение в localStorage
- Синхронизация с системными настройками
**api.js**
- Централизованный API клиент
- Обработка ошибок
- Таймауты и retries
**auth.js**
- Логика аутентификации
- Проверка прав доступа
- Управление сессией
**router.js**
- SPA навигация (hash-based)
- Проверка авторизации для маршрутов
- Динамическая загрузка модулей
**app.js**
- Координация компонентов
- Локальное состояние приложения
- Обработка глобальных событий
### Модули
Каждый модуль состоит из:
```
module/
├── module.html # HTML/UI
├── styles/
│ └── module.css # Module-specific styles (если нужно)
└── js/
└── module.js # Module-specific logic (если нужно)
```
## Backend архитектура
### Структура маршрутов
```
src/routes/
├── auth.js # POST /api/auth/login
│ # POST /api/auth/register
│ # GET /api/auth/me
│ # POST /api/auth/logout
├── users.js # GET /api/users
│ # POST /api/users
│ # PATCH /api/users/:id
│ # DELETE /api/users/:id
├── db-tables.js # GET /api/db/tables
│ # GET /api/db/tables/:tableName/data
│ # POST /api/db/query
│ # GET /api/db/stats
└── admin.js # GET /api/admin/stats
# GET /api/admin/logs
# GET /api/admin/backups
# POST /api/admin/backups
```
### Middleware
```
┌────────────────────────────┐
│ Request Middleware │
├────────────────────────────┤
│ 1. Parse JSON (express) │
│ 2. CORS │
│ 3. Session │
│ 4. Auth verification │
│ 5. Role verification │
└────────┬───────────────────┘
Route Handler
┌────────┴───────────────────┐
│ Response Middleware │
├────────────────────────────┤
│ 1. Error handling │
│ 2. JSON serialization │
└────────────────────────────┘
```
### Поток аутентификации
```
1. User enters credentials
2. POST /api/auth/login
3. Hash verification (bcryptjs)
├─ SUCCESS ───▶ Create session
│ Set session.userId
│ Return user data
└─ FAIL ───▶ Return error
4. Store session in cookie
5. Client stores auth state
```
### Защита данных
**Password Security**
- Хеширование bcryptjs (10 rounds)
- Никогда не передаем пароль на фронтенд
- Secure cookies (httpOnly, secure flag в production)
**API Security**
- Session-based authentication
- Проверка прав доступа на каждый запрос
- SQL injection protection (parameterized queries)
- CORS с серверной стороны
## Модель данных
### Users Table
```sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL DEFAULT 'viewer',
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### Activity Logs Table
```sql
CREATE TABLE activity_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
action VARCHAR(255),
details JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
```
## Диагностика и мониторинг
### Логирование
```javascript
// Activity logging
await pool.query(
'INSERT INTO activity_logs (user_id, action, details) VALUES ($1, $2, $3)',
[userId, 'action_name', JSON.stringify({...details})]
);
```
### Обработка ошибок
Все ошибки логируются:
- Console.error для разработки
- Activity logs для действий пользователей
- Graceful error responses для клиента
## Масштабирование
### Готовность к масштабированию
**Горизонтальное масштабирование:**
- Session store можно перенести в Redis
- API stateless для load balancing
- Docker контейнеризация
**Вертикальное масштабирование:**
- Database connection pooling (pg library)
- Кеширование запросов
- Индексирование таблиц
## Развертывание
### Development
```bash
npm install
npm run dev
```
Приложение на `localhost:3000` с hot reload
### Production
```bash
# Build Docker image
docker build -t pg-admin .
# Run with Docker Compose
docker-compose up -d
# Or manual run
NODE_ENV=production npm start
```
### Переменные окружения
```
NODE_ENV=production
PORT=3000
SESSION_SECRET=strong-random-secret
DB_HOST=prod-db.example.com
DB_PORT=5432
DB_NAME=app_db
DB_USER=app_user
DB_PASSWORD=app_password
```
## Нефункциональные требования
### Производительность
- **Первая загрузка:** < 2s
- **Навигация:** < 500ms (SPA)
- **Запросы к БД:** < 1s
- **API响応:** < 100ms (от БД)
### Масштабируемость
- **Одновременные пользователи:** 100+
- **Записи в БД:** 1,000,000+
- **Размер БД:** 10GB+
### Доступность
- **Uptime:** 99.5%
- **MTBF:** > 720 часов
- **MTTR:** < 1 часа
## Security Considerations
1. **Input Validation** - Все входные данные проверяются
2. **Output Encoding** - XSS protection через параметризованные шаблоны
3. **Authentication** - Session-based с хешированием
4. **Authorization** - Role-based access control (RBAC)
5. **Encryption** - HTTPS в production
6. **Logging** - Все действия записываются
---
**Дата обновления:** Январь 2024
**Версия:** 2.0.0
**Автор:** PG-Admin Team

391
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,391 @@
# Development Guide
## 🚀 Quick Start for Developers
### Prerequisites
- Node.js 14+
- PostgreSQL 12+
- VS Code (recommended) with extensions:
- ES7+ React/Redux/React-Native snippets
- Tailwind CSS IntelliSense
- PostgreSQL Explorer
### Initial Setup
```bash
# 1. Clone repository
git clone <repo-url>
cd pg-admin
# 2. Install dependencies
npm install
# 3. Create .env from example
cp .env.example .env
# 4. Configure your database
# Edit .env with your PostgreSQL credentials
# 5. Start development server
npm run dev
```
Open http://localhost:3000
## 📝 Coding Standards
### JavaScript/Frontend
```javascript
// Use const by default, let for loops
const config = { ... };
let accumulator = 0;
// Arrow functions for callbacks
const handleClick = (e) => { ... };
// Use template literals
const message = `Hello ${name}`;
// Async/await instead of .then()
const data = await api.fetchUsers();
// Error handling
try {
await api.call();
} catch (error) {
console.error('Error:', error);
showError(error.message);
}
```
### CSS
```css
/* Use Tailwind classes */
<button class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
/* Create custom utility classes in main.css */
.card {
@apply bg-white dark:bg-slate-900 border border-slate-200 rounded-xl shadow-sm;
}
/* Use CSS custom properties for theming */
:root {
--color-primary: #3b82f6;
--color-success: #10b981;
}
.dark {
--color-primary: #60a5fa;
}
```
### SQL
```sql
-- Use parameterized queries to prevent SQL injection
pool.query('SELECT * FROM users WHERE id = $1', [userId]);
-- Use clear naming conventions
SELECT user_id, created_at FROM activity_logs ORDER BY created_at DESC;
-- Create indexes for frequently queried columns
CREATE INDEX idx_activity_logs_user_id ON activity_logs(user_id);
```
## 🏗️ Adding New Features
### Adding a New Module
1. **Create module structure**
```
public/modules/new-feature/
├── new-feature.html # UI template
├── styles/
│ └── new-feature.css # Styles (optional)
└── js/
└── new-feature.js # Logic (optional)
```
2. **Update router.js**
```javascript
routes: {
'new-feature': {
module: 'newFeatureModule',
requireAuth: true,
handler: this.handleNewFeature.bind(this)
}
}
```
3. **Add navigation item**
```html
<a href="#new-feature" class="nav-item">
<i data-lucide="icon" class="w-5 h-5"></i>
<span>New Feature</span>
</a>
```
### Adding a New API Endpoint
1. **Create route file** in `src/routes/`
```javascript
module.exports = (pool) => {
const router = express.Router();
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ success: false });
}
};
// GET endpoint
router.get('/', requireAuth, async (req, res) => {
try {
const result = await pool.query('SELECT * FROM table');
res.json({ success: true, data: result.rows });
} catch (error) {
res.status(500).json({ success: false, error: error.message });
}
});
return router;
};
```
2. **Register in server.js**
```javascript
app.use('/api/endpoint', require('./src/routes/endpoint')(pool));
```
3. **Add API method** in `public/js/api.js`
```javascript
async getEndpoint() {
return this.request('/endpoint', { method: 'GET' });
}
```
## 🐛 Debugging
### Frontend Debugging
```javascript
// Use Chrome DevTools
// F12 → Sources tab for breakpoints
// Use console methods
console.log('Value:', value); // Info
console.error('Error:', error); // Error
console.warn('Warning:', warning); // Warning
console.table(array); // Table view
// Use debugger statement
debugger; // Pauses execution when DevTools open
```
### Backend Debugging
```javascript
// Use node --inspect for debugging
node --inspect server.js
// Or use VS Code debugger with .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/server.js",
"restart": true,
"console": "integratedTerminal"
}
]
}
```
### Database Debugging
```bash
# Connect to PostgreSQL
psql -U postgres -d postgres -h localhost
# Useful queries
\dt # List tables
\d users # Describe table
SELECT COUNT(*) FROM table_name;
EXPLAIN ANALYZE SELECT ...;
```
## 📊 Testing API
### Using curl
```bash
# Register
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com","password":"pass123"}'
# Login
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"pass123"}'
# Get data
curl http://localhost:3000/api/db/tables
# Execute query
curl -X POST http://localhost:3000/api/db/query \
-H "Content-Type: application/json" \
-d '{"sql":"SELECT COUNT(*) FROM users"}'
```
### Using Postman
1. Import requests from `postman_collection.json` (если имеется)
2. Set base URL: `http://localhost:3000`
3. Save responses in collection
## 🗂️ File Organization
**Keep related files together:**
- HTML structure
- CSS styling
- JavaScript logic
**Example - Auth Module:**
```
modules/auth/
├── login.html # Structure + CSS
└── (js in main app.js or auth.js)
```
**Avoid:**
- Mixing concerns (HTML with business logic)
- Creating orphaned files
- Dead code without comments
## 📚 Best Practices
### General
1. **DRY (Don't Repeat Yourself)**
- Extract common functions to utilities
- Create reusable components
2. **KISS (Keep It Simple, Stupid)**
- Write clear, readable code
- Avoid over-engineering
3. **SOLID Principles**
- Single responsibility
- Open/closed (open for extension, closed for modification)
### Frontend
1. **Performance**
- Lazy load modules when possible
- Minimize DOM manipulation
- Use event delegation for dynamic elements
2. **Accessibility**
- Use semantic HTML
- Add ARIA labels where needed
- Test with keyboard navigation
3. **Responsive Design**
- Mobile-first approach
- Test on multiple devices
- Use relative units (rem, %, vw)
### Backend
1. **Database**
- Always use parameterized queries
- Create appropriate indexes
- Regular backups
2. **Security**
- Validate all inputs
- Hash passwords properly
- Use HTTPS in production
- Implement rate limiting
3. **Error Handling**
- Provide meaningful error messages
- Log errors server-side
- Don't expose sensitive information
## 🔑 Environment Variables
Never commit `.env`! Use `.env.example` template.
```bash
# Required for all environments
PORT=3000
DB_HOST=localhost
DB_USER=postgres
# Development only
NODE_ENV=development
DEBUG=*
# Production only (set via deployment system)
SESSION_SECRET=production-secret
SSL_CERT=/path/to/cert.pem
```
## 📦 Dependencies
### Why we use them
- **express** - Web framework
- **pg** - PostgreSQL driver
- **bcryptjs** - Password hashing
- **express-session** - Session management
- **cors** - Cross-origin requests
- **dotenv** - Environment variables
**Keeping dependencies updated:**
```bash
npm outdated # Check for updates
npm update # Update to latest
npm audit # Check for vulnerabilities
npm audit fix # Auto-fix vulnerabilities
```
## 🚀 Performance Tips
1. **Frontend**
- Use production builds
- Minify CSS/JS
- Lazy load images
- Use CSS containment for heavy animations
2. **Backend**
- Use connection pooling
- Add database indexes
- Cache frequently accessed data
- Optimize queries with EXPLAIN
3. **Deployment**
- Use CDN for static files
- Enable gzip compression
- Set appropriate cache headers
- Use monitoring tools
## 📖 Further Reading
- [Express.js Documentation](https://expressjs.com/)
- [PostgreSQL Documentation](https://www.postgresql.org/docs/)
- [MDN Web Docs](https://developer.mozilla.org/)
- [Node.js Best Practices](https://github.com/goldbergyoni/nodebestpractices)
---
Last updated: January 2024

View File

@@ -1,20 +1,54 @@
# Используем легкий Node.js образ # ============================================================================
FROM node:20-alpine # Multi-stage build for optimized production Docker image
# ============================================================================
# Stage 1: Dependencies
FROM node:20-alpine AS dependencies
# Рабочая директория
WORKDIR /app WORKDIR /app
# Копируем package.json # Copy only package files
COPY package*.json ./ COPY package*.json ./
# Устанавливаем зависимости # Install dependencies (using npm ci for production)
RUN npm install --production RUN npm ci --prefer-offline --no-audit
# Копируем весь проект # ============================================================================
# Stage 2: Production
FROM node:20-alpine AS production
# Install curl for healthcheck and dumb-init for signal handling
RUN apk add --no-cache curl dumb-init
# Set working directory
WORKDIR /app
# Set Node.js environment to production
ENV NODE_ENV=production
# Copy node_modules from dependencies stage
COPY --from=dependencies /app/node_modules ./node_modules
# Copy package files
COPY package*.json ./
# Copy application code
COPY . . COPY . .
# Открываем порт # Create non-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs
# Expose port
EXPOSE 3000 EXPOSE 3000
# Запуск сервера # Health check
CMD ["npm", "start"] HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000 || exit 1
# Use dumb-init to handle signals properly (graceful shutdown)
ENTRYPOINT ["/usr/sbin/dumb-init", "--"]
# Start application
CMD ["node", "server.js"]

View File

@@ -1,22 +1,95 @@
# Имя проекта (можно менять) .PHONY: help install dev start stop logs clean build up down restart
PROJECT_NAME=postgres_admin
# Файл compose # Project settings
COMPOSE=docker-compose PROJECT_NAME=pg-admin
DOCKER_COMPOSE=docker-compose
# === Основные команды === # Colors for output
BLUE=\033[0;34m
GREEN=\033[0;32m
YELLOW=\033[0;33m
NC=\033[0m # No Color
# Сборка контейнеров help:
@echo "$(BLUE)=== PG-Admin - PostgreSQL Admin Panel ===$(NC)"
@echo "$(GREEN)Available commands:$(NC)"
@echo " make install - Install dependencies"
@echo " make dev - Run development server"
@echo " make start - Start application"
@echo " make stop - Stop application"
@echo " make restart - Restart application"
@echo " make build - Build Docker image"
@echo " make up - Start Docker containers"
@echo " make down - Stop Docker containers"
@echo " make logs - View Docker logs"
@echo " make clean - Remove containers and volumes"
@echo " make db-reset - Reset database"
@echo ""
# Install dependencies
install:
@echo "$(BLUE)Installing dependencies...$(NC)"
npm install
@echo "$(GREEN)✓ Dependencies installed$(NC)"
# Development server with hot reload
dev:
@echo "$(BLUE)Starting development server...$(NC)"
npm run dev
# Start production server
start:
@echo "$(BLUE)Starting server...$(NC)"
npm start
# Stop application
stop:
@echo "$(BLUE)Stopping server...$(NC)"
@pkill -f "node server.js" || true
# Restart application
restart: stop start
@echo "$(GREEN)✓ Server restarted$(NC)"
# Docker - Build image
build: build:
$(COMPOSE) build @echo "$(BLUE)Building Docker image...$(NC)"
$(DOCKER_COMPOSE) build
@echo "$(GREEN)✓ Image built$(NC)"
# Запуск (в фоне) # Docker - Start containers
up: up: build
$(COMPOSE) up -d @echo "$(BLUE)Starting containers...$(NC)"
$(DOCKER_COMPOSE) up -d
@echo "$(GREEN)✓ Containers started$(NC)"
@echo "Application available at http://localhost:3000"
# Остановка # Docker - Stop containers
down: down:
$(COMPOSE) down @echo "$(BLUE)Stopping containers...$(NC)"
$(DOCKER_COMPOSE) down
@echo "$(GREEN)✓ Containers stopped$(NC)"
# Docker - View logs
logs:
@echo "$(BLUE)Fetching logs...$(NC)"
$(DOCKER_COMPOSE) logs -f
# Docker - Clean (remove containers and volumes)
clean:
@echo "$(YELLOW)Cleaning Docker resources...$(NC)"
$(DOCKER_COMPOSE) down -v
@echo "$(GREEN)✓ Cleaned$(NC)"
# Database - Reset database
db-reset:
@echo "$(YELLOW)Resetting database...$(NC)"
$(DOCKER_COMPOSE) exec postgres psql -U postgres -d postgres -c "DROP DATABASE IF EXISTS postgres;"
$(DOCKER_COMPOSE) exec postgres psql -U postgres -d postgres -c "CREATE DATABASE postgres;"
@echo "$(GREEN)✓ Database reset$(NC)"
# Show all targets
.DEFAULT_GOAL := help
# Перезапуск # Перезапуск
restart: restart:

View File

@@ -1,53 +1,153 @@
version: '3.9'
# ============================================================================
# PostgreSQL Admin Panel - Complete Stack
# ============================================================================
# Services:
# - postgres: PostgreSQL 16 database
# - pgadmin: Node.js application
#
# Usage:
# docker-compose up --build # Build and start
# docker-compose down # Stop and remove
# docker-compose logs -f # View logs
# docker-compose ps # Status
# ============================================================================
services: services:
db: # =========================================================================
image: postgres:16 # PostgreSQL Database Service
container_name: app_postgres # =========================================================================
postgres:
image: postgres:16-alpine
container_name: pg-admin-postgres
restart: unless-stopped restart: unless-stopped
environment: # Network Settings
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
ports: ports:
- "5432:5432" - "5432:5432"
networks:
- pgadmin-network
# Environment Configuration
environment:
# Database initialization
POSTGRES_DB: ${DB_NAME:-postgres}
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
# PostgreSQL configuration
POSTGRES_INITDB_ARGS: >
-c max_connections=200
-c shared_buffers=256MB
-c effective_cache_size=1GB
-c log_statement=all
# Volume Management
volumes: volumes:
# Persist database files
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
networks: # Health Check
- app_network
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-postgres}"]
interval: 5s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 10s
# =========================================================================
# PostgreSQL Admin Panel Application
# =========================================================================
pgadmin:
build:
context: .
dockerfile: Dockerfile
# Build arguments
args:
NODE_ENV: production
backend: container_name: pg-admin-app
build: .
container_name: app_backend
restart: unless-stopped restart: unless-stopped
# Network Settings
ports: ports:
- "3000:3000" - "3000:3000"
networks:
- pgadmin-network
env_file: # Depends On
- .env
depends_on: depends_on:
db: postgres:
condition: service_healthy condition: service_healthy
networks: # Environment Configuration
- app_network environment:
# Application Settings
NODE_ENV: ${NODE_ENV:-production}
PORT: 3000
# Database Connection
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: ${DB_NAME:-postgres}
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
# Security
SESSION_SECRET: ${SESSION_SECRET:-change-this-in-production-to-random-string}
# Volume Management (development - can be removed in production)
volumes:
# Code synchronization
- ./public:/app/public:ro
- ./src:/app/src:ro
# Node modules persistence
- /app/node_modules
# Resource Limits
# Uncomment for production to limit resources
# deploy:
# resources:
# limits:
# cpus: '1'
# memory: 512M
# reservations:
# cpus: '0.5'
# memory: 256M
# Health Check
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Logging Configuration
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# ============================================================================
# Volumes Definition
# ============================================================================
volumes: volumes:
postgres_data: postgres_data:
driver: local
driver_opts:
type: none
o: bind
device: ${PWD}/postgres_data
# ============================================================================
# Networks Definition
# ============================================================================
networks: networks:
app_network: pgadmin-network:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.20.0.0/16

1438
index.html

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,24 @@
{ {
"name": "postgres-admin-panel", "name": "pg-admin-panel",
"version": "1.0.0", "version": "2.0.0",
"description": "PostgreSQL Admin Panel with .env configuration", "description": "Production-ready PostgreSQL Admin Panel with modular architecture, dark mode, and responsive design",
"main": "server.js", "main": "server.js",
"author": "Your Name",
"license": "MIT",
"scripts": { "scripts": {
"start": "node server.js", "start": "node server.js",
"dev": "nodemon server.js" "dev": "nodemon server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"postgresql",
"admin",
"panel",
"database",
"management"
],
"engines": {
"node": ">=14.0.0"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",

47
public/index.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PostgreSQL SensoLab Panel</title>
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
primary: 'var(--color-primary)',
'primary-dark': 'var(--color-primary-dark)',
}
}
}
}
</script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<!-- Main Styles -->
<link rel="stylesheet" href="/styles/main.css">
<link rel="stylesheet" href="/styles/theme.css">
<link rel="stylesheet" href="/styles/responsive.css">
<link rel="stylesheet" href="/styles/animations.css">
</head>
<body class="bg-white dark:bg-slate-950 text-slate-800 dark:text-slate-100 transition-colors duration-300">
<div id="app"></div>
<!-- Scripts -->
<script src="/js/theme.js"></script>
<script src="/js/api.js"></script>
<script src="/js/auth.js"></script>
<script src="/js/router.js"></script>
<script src="/js/app.js"></script>
</body>
</html>

145
public/js/api.js Normal file
View File

@@ -0,0 +1,145 @@
// API Helper - Centralized API calls
class API {
constructor() {
this.baseURL = '/api';
this.timeout = 10000;
}
async request(endpoint, options = {}) {
const {
method = 'GET',
headers = {},
body = null,
timeout = this.timeout
} = options;
const url = `${this.baseURL}${endpoint}`;
const config = {
method,
headers: {
'Content-Type': 'application/json',
...headers
}
};
if (body) {
config.body = JSON.stringify(body);
}
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
...config,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 401) {
// Redirect to login if unauthorized
window.location.hash = '#login';
throw new Error('Unauthorized. Please login again.');
}
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
// Auth endpoints
async login(email, password) {
return this.request('/auth/login', {
method: 'POST',
body: { email, password }
});
}
async register(name, email, password) {
return this.request('/auth/register', {
method: 'POST',
body: { name, email, password }
});
}
async logout() {
return this.request('/auth/logout', { method: 'POST' });
}
async getCurrentUser() {
return this.request('/auth/me');
}
// Users endpoints
async getUsers() {
return this.request('/users');
}
async createUser(userData) {
return this.request('/users', {
method: 'POST',
body: userData
});
}
async updateUser(userId, userData) {
return this.request(`/users/${userId}`, {
method: 'PATCH',
body: userData
});
}
async deleteUser(userId) {
return this.request(`/users/${userId}`, {
method: 'DELETE'
});
}
// Database endpoints
async getTables() {
return this.request('/db/tables');
}
async getTableData(tableName, limit = 100, offset = 0) {
return this.request(`/db/tables/${tableName}/data?limit=${limit}&offset=${offset}`);
}
async executeQuery(sql) {
return this.request('/db/query', {
method: 'POST',
body: { sql }
});
}
async getDatabaseStats() {
return this.request('/db/stats');
}
// Admin endpoints
async getSystemStats() {
return this.request('/admin/stats');
}
async getLogs(limit = 100) {
return this.request(`/admin/logs?limit=${limit}`);
}
async getBackups() {
return this.request('/admin/backups');
}
async createBackup() {
return this.request('/admin/backups', {
method: 'POST'
});
}
}
// Global API instance
const api = new API();

228
public/js/app.js Normal file
View File

@@ -0,0 +1,228 @@
// Main Application Handler
class Application {
constructor() {
this.init();
}
async init() {
// Wait for auth check
const isAuth = await auth.checkAuth();
if (isAuth) {
// Redirect to dashboard
if (window.location.hash === '' || window.location.hash === '#login') {
window.location.hash = '#dashboard';
}
} else {
// Redirect to login
window.location.hash = '#login';
}
}
loadDashboardData() {
this.loadStats();
this.loadTablesList();
}
async loadStats() {
try {
const stats = await api.getDatabaseStats();
document.getElementById('statsTableCount').textContent = stats.tableCount || 0;
document.getElementById('statsRecordCount').textContent = stats.recordCount || 0;
document.getElementById('statsUserCount').textContent = stats.userCount || 0;
document.getElementById('statsDbSize').textContent = stats.dbSize || '-';
} catch (error) {
console.error('Failed to load stats:', error);
}
}
async loadTablesList() {
try {
const tables = await api.getTables();
const tablesList = document.getElementById('tablesList');
if (tables.length === 0) {
tablesList.innerHTML = '<p class="text-slate-500 dark:text-slate-400 text-sm">Таблицы не найдены</p>';
return;
}
tablesList.innerHTML = tables.map(table => `
<div class="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-800 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors cursor-pointer" onclick="window.location.hash='#tables'">
<div class="flex items-center gap-2">
<i data-lucide="table" class="w-4 h-4 text-blue-600 dark:text-blue-400"></i>
<span class="font-medium text-slate-900 dark:text-white">${table.name}</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400">${table.recordCount || 0} записей</span>
</div>
`).join('');
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
} catch (error) {
console.error('Failed to load tables:', error);
}
}
loadAdminPanel() {
this.loadUsers();
}
async loadUsers() {
try {
const users = await api.getUsers();
const usersList = document.getElementById('usersList');
if (!users || users.length === 0) {
usersList.innerHTML = '<tr><td colspan="5" class="py-8 px-4 text-center text-slate-500 dark:text-slate-400">Пользователи не найдены</td></tr>';
return;
}
usersList.innerHTML = users.map(user => `
<tr>
<td class="py-3 px-4 font-medium text-slate-900 dark:text-white">${user.name}</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400 hidden sm:table-cell text-sm">${user.email}</td>
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">
<span class="px-2 py-1 rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 text-xs font-medium">${this.getRoleName(user.role)}</span>
</td>
<td class="py-3 px-4">
<span class="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${user.active ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-400'}">
<i data-lucide="circle" class="w-2 h-2"></i>
${user.active ? 'Активен' : 'Неактивен'}
</span>
</td>
<td class="py-3 px-4 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="app.editUser(${user.id})" class="p-1 hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded text-blue-600 dark:text-blue-400" title="Редактировать">
<i data-lucide="edit" class="w-4 h-4"></i>
</button>
<button onclick="app.deleteUser(${user.id})" class="p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded text-red-600 dark:text-red-400" title="Удалить">
<i data-lucide="trash" class="w-4 h-4"></i>
</button>
</div>
</td>
</tr>
`).join('');
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
this.setupAdminPanelHandlers();
} catch (error) {
console.error('Failed to load users:', error);
}
}
setupAdminPanelHandlers() {
// Add user button
const addUserBtn = document.getElementById('addUserBtn');
if (addUserBtn) {
addUserBtn.onclick = () => this.showUserModal(null);
}
// User modal controls
const userModal = document.getElementById('userModal');
const closeBtn = document.getElementById('closeUserModal');
const cancelBtn = document.getElementById('cancelUserEdit');
const userForm = document.getElementById('userForm');
if (closeBtn) closeBtn.onclick = () => userModal.classList.add('hidden');
if (cancelBtn) cancelBtn.onclick = () => userModal.classList.add('hidden');
if (userForm) {
userForm.onsubmit = (e) => this.handleUserFormSubmit(e);
}
}
showUserModal(userId) {
const userModal = document.getElementById('userModal');
const userForm = document.getElementById('userForm');
const userModalTitle = document.getElementById('userModalTitle');
userForm.reset();
userForm.dataset.userId = userId || '';
if (userId) {
userModalTitle.textContent = 'Редактировать пользователя';
// Load user data
// TODO: implement
} else {
userModalTitle.textContent = 'Добавить пользователя';
}
userModal.classList.remove('hidden');
}
async handleUserFormSubmit(e) {
e.preventDefault();
const formData = {
name: document.getElementById('userNameInput').value,
email: document.getElementById('userEmailInput').value,
role: document.getElementById('userRoleSelect').value,
password: document.getElementById('userPasswordInput').value
};
const userId = e.target.dataset.userId;
try {
if (userId) {
await api.updateUser(userId, formData);
} else {
await api.createUser(formData);
}
document.getElementById('userModal').classList.add('hidden');
this.loadUsers();
} catch (error) {
alert('Ошибка: ' + error.message);
}
}
async editUser(userId) {
// TODO: implement
this.showUserModal(userId);
}
async deleteUser(userId) {
if (confirm('Вы уверены, что хотите удалить этого пользователя?')) {
try {
await api.deleteUser(userId);
this.loadUsers();
} catch (error) {
alert('Ошибка: ' + error.message);
}
}
}
getRoleName(role) {
const names = {
'superadmin': 'Суперадминистратор',
'admin': 'Администратор',
'moderator': 'Модератор',
'viewer': 'Только просмотр'
};
return names[role] || role;
}
}
// Initialize application
const app = new Application();
// Update content when route changes
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('hashchange', () => {
const currentRoute = window.location.hash.slice(1);
// Load dashboard data
if (currentRoute.includes('dashboard') || currentRoute === '') {
app.loadDashboardData();
}
// Load admin panel
if (currentRoute.includes('admin')) {
app.loadAdminPanel();
}
});
});

212
public/js/auth.js Normal file
View File

@@ -0,0 +1,212 @@
// Authentication Handler
class Auth {
constructor() {
this.user = null;
this.isAuthenticated = false;
this.checkAuth();
}
async checkAuth() {
try {
const response = await api.getCurrentUser();
if (response.success) {
this.user = response.user;
this.isAuthenticated = true;
return true;
}
} catch (error) {
this.logout();
}
return false;
}
async login(email, password) {
try {
const response = await api.login(email, password);
if (response.success) {
this.user = response.user;
this.isAuthenticated = true;
return { success: true, user: this.user };
}
return { success: false, error: response.message };
} catch (error) {
return { success: false, error: error.message };
}
}
async register(name, email, password) {
try {
const response = await api.register(name, email, password);
if (response.success) {
return { success: true, message: 'Регистрация успешна. Теперь вы можете войти.' };
}
return { success: false, error: response.message };
} catch (error) {
return { success: false, error: error.message };
}
}
async logout() {
try {
await api.logout();
} catch (error) {
console.error('Logout error:', error);
}
this.user = null;
this.isAuthenticated = false;
window.location.hash = '#login';
}
hasRole(role) {
return this.user && this.user.role === role;
}
hasPermission(permission) {
if (!this.user) return false;
return this.user.permissions && this.user.permissions.includes(permission);
}
getUser() {
return this.user;
}
isAdmin() {
return this.user && (this.user.role === 'superadmin' || this.user.role === 'admin');
}
isSuperAdmin() {
return this.user && this.user.role === 'superadmin';
}
}
// Global auth instance
const auth = new Auth();
// Login/Register form handler
document.addEventListener('DOMContentLoaded', () => {
// Tab switching
const authTabBtns = document.querySelectorAll('.auth-tab-btn');
const authForms = document.querySelectorAll('.auth-form');
authTabBtns.forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.dataset.tab;
// Update active tab
authTabBtns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
// Show/hide forms
authForms.forEach(form => form.classList.add('hidden'));
if (tab === 'login') {
document.getElementById('loginForm').classList.remove('hidden');
} else {
document.getElementById('registerForm').classList.remove('hidden');
}
});
});
// Login form
const loginForm = document.getElementById('loginForm');
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('loginEmail').value;
const password = document.getElementById('loginPassword').value;
const loginText = document.querySelector('.login-text');
const loginLoader = document.querySelector('.login-loader');
loginText.style.display = 'none';
loginLoader.classList.remove('hidden');
const result = await auth.login(email, password);
if (result.success) {
// Redirect to dashboard
setTimeout(() => {
window.location.hash = '#dashboard';
}, 500);
} else {
showAuthError(result.error);
loginText.style.display = 'block';
loginLoader.classList.add('hidden');
}
});
}
// Register form
const registerForm = document.getElementById('registerForm');
if (registerForm) {
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('registerName').value;
const email = document.getElementById('registerEmail').value;
const password = document.getElementById('registerPassword').value;
const confirmPassword = document.getElementById('registerPasswordConfirm').value;
if (password !== confirmPassword) {
showAuthError('Пароли не совпадают');
return;
}
const registerText = document.querySelector('.register-text');
const registerLoader = document.querySelector('.register-loader');
registerText.style.display = 'none';
registerLoader.classList.remove('hidden');
const result = await auth.register(name, email, password);
if (result.success) {
showAuthError(result.message, 'success');
// Clear form and switch to login
registerForm.reset();
document.querySelector('[data-tab="login"]').click();
} else {
showAuthError(result.error);
}
registerText.style.display = 'block';
registerLoader.classList.add('hidden');
});
}
});
function showAuthError(message, type = 'error') {
const errorDiv = document.getElementById('authError');
const errorText = document.getElementById('authErrorText');
if (type === 'success') {
errorDiv.classList.remove('bg-red-50', 'border-red-200', 'text-red-600', 'dark:bg-red-900/20', 'dark:border-red-800', 'dark:text-red-400');
errorDiv.classList.add('bg-green-50', 'border-green-200', 'text-green-600', 'dark:bg-green-900/20', 'dark:border-green-800', 'dark:text-green-400');
const icon = errorDiv.querySelector('i');
if (icon) {
icon.setAttribute('data-lucide', 'check-circle');
}
} else {
errorDiv.classList.remove('bg-green-50', 'border-green-200', 'text-green-600', 'dark:bg-green-900/20', 'dark:border-green-800', 'dark:text-green-400');
errorDiv.classList.add('bg-red-50', 'border-red-200', 'text-red-600', 'dark:bg-red-900/20', 'dark:border-red-800', 'dark:text-red-400');
const icon = errorDiv.querySelector('i');
if (icon) {
icon.setAttribute('data-lucide', 'alert-circle');
}
}
errorText.textContent = message;
errorDiv.classList.remove('hidden');
// Auto-hide success messages
if (type === 'success') {
setTimeout(() => {
errorDiv.classList.add('hidden');
}, 3000);
}
// Update Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}

205
public/js/router.js Normal file
View File

@@ -0,0 +1,205 @@
// Router - Single Page Application routing
class Router {
constructor() {
this.routes = {
'login': { module: 'authModule', handler: this.handleLogin.bind(this) },
'register': { module: 'authModule', handler: this.handleLogin.bind(this) },
'dashboard': { module: 'dashboardModule', requireAuth: true, handler: this.handleDashboard.bind(this) },
'databases': { module: 'dashboardModule', requireAuth: true, handler: this.handleDatabases.bind(this) },
'tables': { module: 'dashboardModule', requireAuth: true, handler: this.handleTables.bind(this) },
'queries': { module: 'dashboardModule', requireAuth: true, handler: this.handleQueries.bind(this) },
'admin-users': { module: 'adminModule', requireAuth: true, requireAdmin: true, handler: this.handleAdminUsers.bind(this) },
'admin-roles': { module: 'adminModule', requireAuth: true, requireAdmin: true, handler: this.handleAdminRoles.bind(this) },
'admin-settings': { module: 'adminModule', requireAuth: true, requireAdmin: true, handler: this.handleAdminSettings.bind(this) },
'admin-logs': { module: 'adminModule', requireAuth: true, requireAdmin: true, handler: this.handleAdminLogs.bind(this) },
'admin-database': { module: 'adminModule', requireAuth: true, requireAdmin: true, handler: this.handleAdminDatabase.bind(this) },
'admin-backups': { module: 'adminModule', requireAuth: true, requireAdmin: true, handler: this.handleAdminBackups.bind(this) },
};
this.currentRoute = null;
this.modules = {};
this.init();
}
async init() {
// Load modules
await this.loadModules();
// Setup hash change listener
window.addEventListener('hashchange', () => this.navigate());
// Initial navigation
this.navigate();
}
async loadModules() {
const modules = ['auth', 'dashboard', 'admin'];
for (const module of modules) {
try {
const response = await fetch(`/modules/${module}/${module === 'auth' ? 'login' : module === 'admin' ? 'admin-panel' : 'dashboard'}.html`);
if (response.ok) {
const html = await response.text();
const container = document.createElement('div');
container.innerHTML = html;
document.getElementById('app').appendChild(container);
}
} catch (error) {
console.error(`Failed to load ${module} module:`, error);
}
}
// Update Lucide icons after loading modules
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
navigate() {
const hash = window.location.hash.slice(1) || 'login';
const route = this.routes[hash];
if (!route) {
window.location.hash = '#login';
return;
}
// Check authentication
if (route.requireAuth && !auth.isAuthenticated) {
window.location.hash = '#login';
return;
}
// Check admin permission
if (route.requireAdmin && !auth.isAdmin()) {
window.location.hash = '#dashboard';
return;
}
// Hide all modules
document.querySelectorAll('[id$="Module"]').forEach(mod => {
mod.classList.add('hidden');
});
// Show target module
const module = document.getElementById(route.module);
if (module) {
module.classList.remove('hidden');
}
// Close sidebars on mobile when navigating
const sidebars = document.querySelectorAll('[id$="Sidebar"]');
sidebars.forEach(sidebar => {
sidebar.classList.add('hidden');
});
// Call route handler
if (route.handler) {
route.handler();
}
// Setup navigation event listeners for current route
this.setupNavigation();
// Update Lucide icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
this.currentRoute = hash;
}
setupNavigation() {
// Sidebar toggle
const toggleBtn = document.getElementById('toggleSidebar');
const sidebar = document.getElementById('sidebar');
if (toggleBtn && sidebar) {
toggleBtn.onclick = () => sidebar.classList.toggle('-translate-x-full');
}
// Admin sidebar toggle
const toggleAdminBtn = document.getElementById('toggleAdminSidebar');
const adminSidebar = document.getElementById('adminSidebar');
if (toggleAdminBtn && adminSidebar) {
toggleAdminBtn.onclick = () => adminSidebar.classList.toggle('-translate-x-full');
}
// Logout buttons
const logoutBtn = document.getElementById('logoutBtn');
if (logoutBtn) {
logoutBtn.onclick = () => {
if (confirm('Вы уверены, что хотите выйти?')) {
auth.logout();
}
};
}
const adminLogoutBtn = document.getElementById('adminLogoutBtn');
if (adminLogoutBtn) {
adminLogoutBtn.onclick = () => {
if (confirm('Вы уверены, что хотите выйти?')) {
auth.logout();
}
};
}
// Update user info
if (auth.isAuthenticated && auth.user) {
const userName = document.getElementById('userName');
const userRole = document.getElementById('userRole');
const avatarCircle = document.getElementById('avatarCircle');
if (userName) userName.textContent = auth.user.name || 'User';
if (userRole) userRole.textContent = this.getRoleName(auth.user.role);
if (avatarCircle) avatarCircle.textContent = (auth.user.name || 'A').charAt(0).toUpperCase();
}
// Navigation items
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.classList.remove('active');
if (item.getAttribute('href') === `#${this.currentRoute}`) {
item.classList.add('active');
}
item.addEventListener('click', () => {
navItems.forEach(n => n.classList.remove('active'));
item.classList.add('active');
// Close sidebar on mobile
const sidebar = document.getElementById('sidebar') || document.getElementById('adminSidebar');
if (sidebar) {
sidebar.classList.add('-translate-x-full');
}
});
});
}
getRoleName(role) {
const names = {
'superadmin': 'Суперадминистратор',
'admin': 'Администратор',
'moderator': 'Модератор',
'viewer': 'Только просмотр'
};
return names[role] || role;
}
// Route handlers
handleLogin() { }
handleDashboard() { }
handleDatabases() { }
handleTables() { }
handleQueries() { }
handleAdminUsers() { }
handleAdminRoles() { }
handleAdminSettings() { }
handleAdminLogs() { }
handleAdminDatabase() { }
handleAdminBackups() { }
}
// Initialize router
const router = new Router();

76
public/js/theme.js Normal file
View File

@@ -0,0 +1,76 @@
// Theme Management
class ThemeManager {
constructor() {
this.storageKey = 'pgadmin-theme';
this.darkClass = 'dark';
this.init();
}
init() {
const savedTheme = localStorage.getItem(this.storageKey);
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = savedTheme ? savedTheme === 'dark' : prefersDark;
if (isDark) {
this.setDark();
} else {
this.setLight();
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem(this.storageKey)) {
e.matches ? this.setDark() : this.setLight();
}
});
}
toggle() {
const html = document.documentElement;
if (html.classList.contains(this.darkClass)) {
this.setLight();
} else {
this.setDark();
}
}
setDark() {
document.documentElement.classList.add(this.darkClass);
localStorage.setItem(this.storageKey, 'dark');
this.updateIcons();
}
setLight() {
document.documentElement.classList.remove(this.darkClass);
localStorage.setItem(this.storageKey, 'light');
this.updateIcons();
}
isDark() {
return document.documentElement.classList.contains(this.darkClass);
}
updateIcons() {
// Update Lucide icons if needed
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
}
}
// Initialize theme manager
const themeManager = new ThemeManager();
// Theme toggle button handlers
document.addEventListener('DOMContentLoaded', () => {
const themeButtons = [
document.getElementById('toggleTheme'),
document.getElementById('toggleAdminTheme')
];
themeButtons.forEach(btn => {
if (btn) {
btn.addEventListener('click', () => themeManager.toggle());
}
});
});

View File

@@ -0,0 +1,164 @@
<!-- Admin Panel Module -->
<div id="adminModule" class="admin-module hidden h-screen flex flex-col">
<!-- Header (same as dashboard) -->
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 h-16 flex items-center justify-between px-4 sm:px-6 shadow-sm z-10">
<div class="flex items-center gap-4">
<button id="toggleAdminSidebar" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg md:hidden">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
<div class="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<i data-lucide="shield-admin" class="w-6 h-6"></i>
<span class="font-bold text-lg hidden sm:inline">Администрирование</span>
</div>
</div>
<div class="flex items-center gap-3">
<button id="toggleAdminTheme" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors" title="Переключить тему">
<i data-lucide="moon" class="w-5 h-5 dark:hidden"></i>
<i data-lucide="sun" class="w-5 h-5 hidden dark:block"></i>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block"></div>
<button id="adminLogoutBtn" class="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" title="Выход">
<i data-lucide="log-out" class="w-5 h-5 text-red-500"></i>
</button>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Admin Sidebar -->
<aside id="adminSidebar" class="fixed md:static inset-y-16 left-0 w-64 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 overflow-y-auto transform -translate-x-full md:translate-x-0 transition-transform z-40">
<nav class="p-4 space-y-2">
<h3 class="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Управление</h3>
<a href="#admin-overview" class="nav-item active flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="layout-dashboard" class="w-5 h-5"></i>
<span>Обзор</span>
</a>
<a href="#admin-users" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="users" class="w-5 h-5"></i>
<span>Пользователи</span>
</a>
<a href="#admin-roles" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="shield" class="w-5 h-5"></i>
<span>Роли и права</span>
</a>
<a href="#admin-logs" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="file-text" class="w-5 h-5"></i>
<span>Логи</span>
</a>
<div class="my-4 h-px bg-slate-200 dark:bg-slate-700"></div>
<h3 class="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Система</h3>
<a href="#admin-database" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="database" class="w-5 h-5"></i>
<span>База данных</span>
</a>
<a href="#admin-settings" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="settings" class="w-5 h-5"></i>
<span>Настройки</span>
</a>
<a href="#admin-backups" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="download" class="w-5 h-5"></i>
<span>Резервные копии</span>
</a>
<a href="#dashboard" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors text-blue-600 dark:text-blue-400">
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<span>К панели</span>
</a>
</nav>
</aside>
<!-- Admin Content -->
<main id="adminContent" class="flex-1 overflow-auto">
<div class="p-4 sm:p-6 space-y-6">
<!-- Users Section -->
<section id="usersSection" class="space-y-4">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 mb-4">
<div>
<h2 class="text-2xl font-bold text-slate-900 dark:text-white flex items-center gap-2">
<i data-lucide="users" class="w-6 h-6"></i>
Управление пользователями
</h2>
<p class="text-slate-500 dark:text-slate-400 text-sm mt-1">Добавляйте, редактируйте и удаляйте администраторов</p>
</div>
<button id="addUserBtn" class="btn btn-primary whitespace-nowrap flex items-center gap-2">
<i data-lucide="plus" class="w-5 h-5"></i>
<span class="hidden sm:inline">Добавить пользователя</span>
<span class="sm:hidden">Добавить</span>
</button>
</div>
<!-- Users Table -->
<div class="card overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 dark:border-slate-700">
<tr>
<th class="text-left py-3 px-4 font-semibold text-slate-600 dark:text-slate-300">Имя</th>
<th class="text-left py-3 px-4 font-semibold text-slate-600 dark:text-slate-300 hidden sm:table-cell">Email</th>
<th class="text-left py-3 px-4 font-semibold text-slate-600 dark:text-slate-300">Роль</th>
<th class="text-left py-3 px-4 font-semibold text-slate-600 dark:text-slate-300">Статус</th>
<th class="text-right py-3 px-4 font-semibold text-slate-600 dark:text-slate-300">Действия</th>
</tr>
</thead>
<tbody id="usersList" class="divide-y divide-slate-200 dark:divide-slate-700">
<tr>
<td colspan="5" class="py-8 px-4 text-center text-slate-500 dark:text-slate-400">
Загрузка пользователей...
</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- Add/Edit User Modal -->
<div id="userModal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-900 rounded-2xl shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-slate-900 p-6 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-900 dark:text-white" id="userModalTitle">Добавить пользователя</h3>
<button id="closeUserModal" class="p-1 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg">
<i data-lucide="x" class="w-6 h-6"></i>
</button>
</div>
<form id="userForm" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Полное имя</label>
<input type="text" id="userNameInput" class="input-field" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Email</label>
<input type="email" id="userEmailInput" class="input-field" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Роль</label>
<select id="userRoleSelect" class="input-field">
<option value="superadmin">Суперадминистратор</option>
<option value="admin">Администратор</option>
<option value="moderator">Модератор</option>
<option value="viewer">Только просмотр</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Пароль</label>
<input type="password" id="userPasswordInput" class="input-field" placeholder="Оставить пусто, чтобы оставить без изменений">
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 btn btn-primary">Сохранить</button>
<button type="button" id="cancelUserEdit" class="flex-1 btn btn-secondary">Отмена</button>
</div>
</form>
</div>
</div>
</div>
</main>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<!-- Auth Module - Login/Register Screen -->
<div id="authModule" class="auth-module hidden">
<!-- Login Screen -->
<div id="loginScreen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-gradient-to-br from-slate-900 via-blue-900 to-slate-900 dark:from-slate-950 dark:via-blue-950 dark:to-slate-950"></div>
<div class="relative w-full max-w-md px-4 sm:px-0">
<div class="glass-panel glass-panel-light dark:glass-panel-dark rounded-2xl shadow-2xl p-8">
<!-- Header -->
<div class="text-center mb-8">
<div class="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-br from-blue-600 to-blue-700 rounded-2xl mb-4 shadow-lg shadow-blue-600/30">
<i data-lucide="database" class="w-8 h-8 text-white"></i>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white">PostgreSQL SensoLab</h1>
<p class="text-slate-500 dark:text-slate-400 mt-2 text-sm sm:text-base">Управление базой данных</p>
</div>
<!-- Tab Navigation -->
<div class="flex gap-2 mb-8 bg-slate-100 dark:bg-slate-800 p-1 rounded-lg">
<button class="auth-tab-btn active flex-1 py-2 px-4 rounded-md font-medium transition-all text-sm sm:text-base" data-tab="login">
Вход
</button>
<button class="auth-tab-btn flex-1 py-2 px-4 rounded-md font-medium transition-all text-sm sm:text-base" data-tab="register">
Регистрация
</button>
</div>
<!-- Login Form -->
<form id="loginForm" class="auth-form space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Email или логин
</label>
<input type="text" id="loginEmail" class="input-field" placeholder="admin@example.com" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Пароль
</label>
<input type="password" id="loginPassword" class="input-field" placeholder="••••••••" required>
</div>
<button type="submit" class="w-full btn btn-primary mt-6">
<span class="login-text">Войти</span>
<div class="login-loader hidden" style="width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top: 2px solid white; border-radius: 50%; animation: spin 1s linear infinite;"></div>
</button>
</form>
<!-- Register Form -->
<form id="registerForm" class="auth-form hidden space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Полное имя
</label>
<input type="text" id="registerName" class="input-field" placeholder="Иван Петров" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Email
</label>
<input type="email" id="registerEmail" class="input-field" placeholder="admin@example.com" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Пароль
</label>
<input type="password" id="registerPassword" class="input-field" placeholder="••••••••" required>
</div>
<div>
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Подтверждение пароля
</label>
<input type="password" id="registerPasswordConfirm" class="input-field" placeholder="••••••••" required>
</div>
<button type="submit" class="w-full btn btn-primary mt-6">
<span class="register-text">Создать аккаунт</span>
<div class="register-loader hidden" style="width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top: 2px solid white; border-radius: 50%; animation: spin 1s linear infinite;"></div>
</button>
</form>
<!-- Error Message -->
<div id="authError" class="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-red-600 dark:text-red-400 text-sm hidden flex items-center gap-2">
<i data-lucide="alert-circle" class="w-4 h-4"></i>
<span id="authErrorText"></span>
</div>
<!-- Demo Info -->
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg text-blue-600 dark:text-blue-400 text-xs sm:text-sm">
<p class="font-semibold mb-2 flex items-center gap-2">
<i data-lucide="info" class="w-4 h-4"></i>
Демо учетные данные:
</p>
<p>Email: <code class="bg-white/50 dark:bg-slate-900 px-2 py-1 rounded text-xs">admin@example.com</code></p>
<p>Пароль: <code class="bg-white/50 dark:bg-slate-900 px-2 py-1 rounded text-xs">admin123</code></p>
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,146 @@
<!-- Dashboard Module -->
<div id="dashboardModule" class="dashboard-module hidden h-screen flex flex-col">
<!-- Header -->
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 h-16 flex items-center justify-between px-4 sm:px-6 shadow-sm z-10">
<div class="flex items-center gap-2 sm:gap-4">
<button id="toggleSidebar" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg md:hidden">
<i data-lucide="menu" class="w-6 h-6"></i>
</button>
<div class="flex items-center gap-2 text-blue-600 dark:text-blue-400">
<i data-lucide="database" class="w-6 h-6"></i>
<span class="font-bold text-lg hidden sm:inline">SensoLab</span>
</div>
</div>
<div class="flex items-center gap-3">
<button id="toggleTheme" class="p-2 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors" title="Переключить тему">
<i data-lucide="moon" class="w-5 h-5 dark:hidden"></i>
<i data-lucide="sun" class="w-5 h-5 hidden dark:block"></i>
</button>
<div class="h-6 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block"></div>
<div class="flex items-center gap-2 text-sm">
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold hidden sm:flex" id="avatarCircle">A</div>
<div class="hidden sm:block">
<p class="font-semibold text-slate-900 dark:text-white text-sm" id="userName">Admin</p>
<p class="text-xs text-slate-500 dark:text-slate-400" id="userRole">Администратор</p>
</div>
</div>
<button id="logoutBtn" class="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors" title="Выход">
<i data-lucide="log-out" class="w-5 h-5 text-red-500"></i>
</button>
</div>
</header>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<aside id="sidebar" class="fixed md:static inset-y-16 left-0 w-64 bg-white dark:bg-slate-900 border-r border-slate-200 dark:border-slate-800 overflow-y-auto transform -translate-x-full md:translate-x-0 transition-transform z-40">
<nav class="p-4 space-y-2">
<h3 class="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Меню</h3>
<a href="#dashboard" class="nav-item active flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="home" class="w-5 h-5"></i>
<span>Панель управления</span>
</a>
<a href="#databases" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="database" class="w-5 h-5"></i>
<span>Базы данных</span>
</a>
<a href="#tables" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="table" class="w-5 h-5"></i>
<span>Таблицы</span>
</a>
<a href="#queries" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="terminal" class="w-5 h-5"></i>
<span>SQL Запросы</span>
</a>
<div class="my-4 h-px bg-slate-200 dark:bg-slate-700"></div>
<h3 class="px-4 py-2 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">Администрирование</h3>
<a href="#admin-users" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="users" class="w-5 h-5"></i>
<span>Пользователи</span>
</a>
<a href="#admin-settings" class="nav-item flex items-center gap-3 px-4 py-3 rounded-lg font-medium transition-colors">
<i data-lucide="settings" class="w-5 h-5"></i>
<span>Настройки</span>
</a>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto">
<div id="dashboardContent" class="p-4 sm:p-6 space-y-6">
<!-- Stats Row -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">Таблиц</p>
<p class="text-2xl font-bold text-slate-900 dark:text-white" id="statsTableCount">0</p>
</div>
<div class="p-3 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<i data-lucide="table" class="w-6 h-6 text-blue-600 dark:text-blue-400"></i>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">Записей</p>
<p class="text-2xl font-bold text-slate-900 dark:text-white" id="statsRecordCount">0</p>
</div>
<div class="p-3 bg-green-100 dark:bg-green-900/30 rounded-lg">
<i data-lucide="database" class="w-6 h-6 text-green-600 dark:text-green-400"></i>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">Пользователей</p>
<p class="text-2xl font-bold text-slate-900 dark:text-white" id="statsUserCount">0</p>
</div>
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<i data-lucide="users" class="w-6 h-6 text-purple-600 dark:text-purple-400"></i>
</div>
</div>
</div>
<div class="card">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-500 dark:text-slate-400">Размер БД</p>
<p class="text-2xl font-bold text-slate-900 dark:text-white" id="statsDbSize">-</p>
</div>
<div class="p-3 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<i data-lucide="hard-drive" class="w-6 h-6 text-orange-600 dark:text-orange-400"></i>
</div>
</div>
</div>
</div>
<!-- Tables Preview -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-slate-900 dark:text-white flex items-center gap-2">
<i data-lucide="table" class="w-5 h-5"></i>
Таблицы
</h2>
<a href="#tables" class="text-sm text-blue-600 dark:text-blue-400 hover:underline">Все таблицы</a>
</div>
<div id="tablesList" class="space-y-2">
<p class="text-slate-500 dark:text-slate-400 text-sm">Загрузка...</p>
</div>
</div>
</div>
</main>
</div>
</div>

View File

@@ -0,0 +1,275 @@
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInDown {
from {
opacity: 0;
transform: translateY(-30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
/* Animation classes */
.animate-fade-in {
animation: fadeIn 0.3s ease-in;
}
.animate-fade-out {
animation: fadeOut 0.3s ease-out;
}
.animate-slide-in-left {
animation: slideInLeft 0.3s ease-out;
}
.animate-slide-in-right {
animation: slideInRight 0.3s ease-out;
}
.animate-slide-in-down {
animation: slideInDown 0.3s ease-out;
}
.animate-slide-in-up {
animation: slideInUp 0.3s ease-out;
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-bounce {
animation: bounce 1s infinite;
}
.animate-spin {
animation: spin 1s linear infinite;
}
.animate-shimmer {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 1000px 100%;
animation: shimmer 2s infinite;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Transition classes */
.transition-all {
transition: all 0.3s ease;
}
.transition-colors {
transition: color 0.3s ease, background-color 0.3s ease, border-color 0.3s ease;
}
.transition-transform {
transition: transform 0.3s ease;
}
.transition-opacity {
transition: opacity 0.3s ease;
}
.transition-fast {
transition: all 0.15s ease;
}
.transition-slow {
transition: all 0.5s ease;
}
/* Hover effects */
.hover-lift {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.hover-lift:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.hover-scale:hover {
transform: scale(1.05);
}
.hover-brighten:hover {
filter: brightness(1.1);
}
/* Transform utilities */
.scale-95 {
transform: scale(0.95);
}
.scale-100 {
transform: scale(1);
}
.scale-105 {
transform: scale(1.05);
}
.scale-110 {
transform: scale(1.1);
}
/* Opacity utilities */
.opacity-0 {
opacity: 0;
}
.opacity-50 {
opacity: 0.5;
}
.opacity-75 {
opacity: 0.75;
}
.opacity-100 {
opacity: 1;
}
/* Loading skeleton */
.skeleton {
background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
.dark .skeleton {
background: linear-gradient(90deg, #333 25%, #444 50%, #333 75%);
background-size: 200% 100%;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-text {
height: 1rem;
border-radius: 0.25rem;
}
.skeleton-circle {
border-radius: 50%;
height: 2.5rem;
width: 2.5rem;
}
.skeleton-card {
border-radius: 0.75rem;
height: 100%;
}

266
public/styles/main.css Normal file
View File

@@ -0,0 +1,266 @@
/* Main Styles */
:root {
--color-primary: #3b82f6;
--color-primary-dark: #1e40af;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-danger: #ef4444;
--transition-speed: 0.3s;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
/* Common Components */
.card {
@apply bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm hover:shadow-md transition-shadow;
}
.glass-panel {
@apply backdrop-filter backdrop-blur-md border;
}
.glass-panel-light {
@apply bg-white/95 border-slate-200/50;
}
.glass-panel-dark {
@apply bg-slate-900/95 dark:bg-slate-900/95 border-slate-700/50;
}
/* Buttons */
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center justify-center gap-2;
}
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-600/30 hover:shadow-blue-600/50 active:scale-95;
}
.btn-secondary {
@apply bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 active:scale-95;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-700 text-white shadow-lg shadow-red-600/30 active:scale-95;
}
/* Input Fields */
.input-field {
@apply w-full px-4 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent dark:focus:ring-blue-400 outline-none transition-all;
}
.input-field:disabled {
@apply bg-slate-100 dark:bg-slate-900 cursor-not-allowed opacity-50;
}
/* Navigation Items */
.nav-item {
@apply text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-white;
}
.nav-item.active {
@apply bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-semibold;
}
/* Auth Tab Buttons */
.auth-tab-btn {
@apply text-slate-700 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-all;
}
.auth-tab-btn.active {
@apply bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-sm;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.fade-in {
animation: fadeIn var(--transition-speed) ease-in;
}
.slide-in {
animation: slideIn var(--transition-speed) ease-in;
}
/* Scrollbar Styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark ::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Status Indicators */
.status-badge {
@apply inline-flex items-center gap-1 px-3 py-1 rounded-full text-xs font-semibold;
}
.status-online {
@apply bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400;
}
.status-offline {
@apply bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-400;
}
.status-warning {
@apply bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400;
}
.status-error {
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
}
/* Loading Spinner */
.loader {
display: inline-block;
border: 3px solid rgba(100, 116, 139, 0.2);
border-top-color: #3b82f6;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 0.8s linear infinite;
}
/* Tables */
table {
border-collapse: collapse;
}
thead {
background: #f8fafc;
}
.dark thead {
background: #1e293b;
}
tbody tr {
@apply hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors;
}
td, th {
padding: 0.75rem;
}
/* Modal Overlay */
.modal-overlay {
@apply fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center;
}
/* Utility Classes */
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.shadow-sm {
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
/* Focus visible for accessibility */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
input:focus-visible,
button:focus-visible,
select:focus-visible {
outline: none !important;
}

View File

@@ -0,0 +1,279 @@
/* Responsive Design - Mobile First */
/* Extra Small Devices (< 640px) */
@media (max-width: 639px) {
body {
font-size: 14px;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
}
h3 {
font-size: 1.1rem;
}
.card {
padding: 1rem;
}
.btn {
padding: 0.625rem 1rem;
font-size: 0.875rem;
}
.btn-icon {
width: 32px;
height: 32px;
padding: 0.5rem;
}
/* Hide elements on small screens */
.hidden-mobile {
display: none;
}
/* Stack grid on mobile */
.grid-responsive {
grid-template-columns: 1fr;
}
/* Full width modals */
.modal {
margin: 1rem;
}
/* Navigation sidebar to drawer on mobile */
#sidebar {
position: fixed;
left: 0;
top: 4rem;
height: calc(100vh - 4rem);
width: 100%;
max-width: 256px;
transition: transform 0.3s ease;
}
#sidebar.hidden {
transform: translateX(-100%);
}
/* Adjust table for small screens */
table {
font-size: 0.875rem;
}
thead th {
padding: 0.5rem;
}
tbody td {
padding: 0.5rem;
}
/* Stack table rows */
.table-responsive {
display: block;
overflow-x: auto;
white-space: nowrap;
}
/* Adjust forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
/* Responsive header */
header {
padding: 0.5rem 1rem;
}
.header-title {
font-size: 1rem;
}
}
/* Small Devices (640px - 768px) */
@media (min-width: 640px) and (max-width: 767px) {
.card {
padding: 1.5rem;
}
.grid-responsive {
grid-template-columns: repeat(2, 1fr);
}
table {
font-size: 0.9375rem;
}
}
/* Medium Devices (768px - 1024px) && ipads */
@media (min-width: 768px) {
.hidden-md-down {
display: none;
}
.hidden-md-up {
display: block;
}
#sidebar {
transform: translateX(0) !important;
position: static;
}
.grid-responsive {
grid-template-columns: repeat(2, 1fr);
}
}
/* Large Devices (1024px+) && desktops */
@media (min-width: 1024px) {
.grid-responsive {
grid-template-columns: repeat(3, 1fr);
}
.grid-responsive.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
#sidebar {
display: block !important;
}
.hidden-lg-down {
display: none;
}
.hidden-lg-up {
display: block;
}
/* Show more columns on desktop */
.table-hidden-mobile {
display: table-cell;
}
}
/* Extra Large Devices (1280px+) */
@media (min-width: 1280px) {
body {
font-size: 16px;
}
.container-xl {
max-width: 1280px;
margin: 0 auto;
}
.grid-responsive.grid-4 {
grid-template-columns: repeat(4, 1fr);
}
.grid-responsive.grid-5 {
grid-template-columns: repeat(5, 1fr);
}
}
/* Portrait vs Landscape */
@media (orientation: portrait) {
.landscape-hidden {
display: none;
}
}
@media (orientation: landscape) {
.portrait-hidden {
display: none;
}
.h-screen {
max-height: 100vh;
}
}
/* Touch devices optimization */
@media (hover: none) and (pointer: coarse) {
.btn,
.nav-item {
padding: 1rem;
min-height: 44px;
min-width: 44px;
}
/* Remove hover effects on touch devices */
.btn:hover {
transform: none;
}
button:active {
transform: scale(0.98);
}
}
/* Print styles */
@media print {
header,
nav,
.no-print {
display: none;
}
body {
background: white;
color: black;
}
.card {
page-break-inside: avoid;
border: 1px solid #ccc;
}
.btn {
display: none;
}
}
/* High DPI screens (Retina) */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
/* Optimize images and borders */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Dark mode media query */
@media (prefers-color-scheme: dark) {
:root {
color-scheme: dark;
}
}
/* Light mode media query */
@media (prefers-color-scheme: light) {
:root {
color-scheme: light;
}
}

192
public/styles/theme.css Normal file
View File

@@ -0,0 +1,192 @@
/* Theme Support - Light/Dark Mode */
:root {
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-tertiary: #94a3b8;
--border-color: #e2e8f0;
--shadow-color: rgba(0, 0, 0, 0.1);
}
:root.dark {
--bg-primary: #0f172a;
--bg-secondary: #1e293b;
--bg-tertiary: #334155;
--text-primary: #f1f5f9;
--text-secondary: #cbd5e1;
--text-tertiary: #94a3b8;
--border-color: #475569;
--shadow-color: rgba(0, 0, 0, 0.3);
}
/* Smooth transitions for theme switching */
html {
transition: background-color 0.3s ease, color 0.3s ease;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
.dark body {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
/* Background gradients for different themes */
.bg-gradient-light {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.bg-gradient-dark {
background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%);
}
/* Text colors that change with theme */
.text-muted {
color: var(--text-secondary);
}
.text-muted-dark {
color: var(--text-tertiary);
}
/* Shadow adjustments */
.shadow-light {
box-shadow: 0 4px 6px -1px var(--shadow-color);
}
.shadow-dark {
box-shadow: 0 10px 15px -3px var(--shadow-color);
}
/* Input field styling for dark mode */
.input-field {
background-color: var(--bg-primary);
border-color: var(--border-color);
color: var(--text-primary);
}
.input-field::placeholder {
color: var(--text-tertiary);
}
/* Card styling for dark mode */
.card {
background-color: var(--bg-primary);
border-color: var(--border-color);
}
.dark .card {
background-color: var(--bg-secondary);
border-color: var(--border-color);
}
/* Header and navigation */
header {
background-color: var(--bg-primary);
border-color: var(--border-color);
}
.sidebar {
background-color: var(--bg-primary);
border-color: var(--border-color);
}
/* Table styling */
thead {
background-color: var(--bg-secondary);
border-color: var(--border-color);
}
tbody {
color: var(--text-primary);
}
.glass-panel {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-color: rgba(255, 255, 255, 0.2);
}
.dark .glass-panel {
background: rgba(15, 23, 42, 0.95);
backdrop-filter: blur(10px);
border-color: rgba(148, 163, 184, 0.1);
}
/* Hover states */
.hover-light:hover {
background-color: var(--bg-tertiary);
}
.dark .hover-light:hover {
background-color: var(--bg-tertiary);
}
/* Color overlays */
.color-overlay-blue {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(59, 130, 246, 0.05));
}
.dark .color-overlay-blue {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(59, 130, 246, 0.1));
}
/* Auth screen theme */
#authModule {
background: linear-gradient(to bottom right, #1e3a8a, #1e40af);
}
.dark #authModule {
background: linear-gradient(to bottom right, #0f172a, #1e293b);
}
/* Status colors */
.status-success {
color: #10b981;
}
.dark .status-success {
color: #6ee7b7;
}
.status-warning {
color: #f59e0b;
}
.dark .status-warning {
color: #fbbf24;
}
.status-error {
color: #ef4444;
}
.dark .status-error {
color: #f87171;
}
.status-info {
color: #3b82f6;
}
.dark .status-info {
color: #60a5fa;
}

188
server.js
View File

@@ -5,108 +5,154 @@ const session = require('express-session');
const cors = require('cors'); const cors = require('cors');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const bcrypt = require('bcryptjs');
let usersConfig = { users: [] }; // Initialize Express app
try {
usersConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'users.json'), 'utf8'));
} catch (err) {
console.warn('⚠️ users.json not found or invalid JSON. Falling back to env-based admin only.');
}
const rolePermissions = {
superadmin: { folders: null, canCreate: true, canEdit: true, canDelete: true },
frontend_admin: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: true },
backend_admin: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: true },
frontend_moder: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: false },
backend_moder: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: false },
viewer: { folders: null, canCreate: false, canEdit: false, canDelete: false },
};
function getUser(username) {
return usersConfig.users.find(u => u.username === username);
}
function getTableFolder(tableName) {
if (!tableName) return 'default';
const parts = tableName.split('__');
return parts.length > 1 ? parts[0] : 'default';
}
function getRolePermissions(role) {
return rolePermissions[role] || rolePermissions.viewer;
}
function canAccessTable(role, tableName) {
const perms = getRolePermissions(role);
if (!perms.folders) return true;
const folder = getTableFolder(tableName);
return perms.folders.includes(folder);
}
const app = express(); const app = express();
const PORT = process.env.PORT || 3000;
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
app.use(express.static('.')); app.use(express.static(path.join(__dirname, 'public')));
// Session configuration // Session configuration
app.use(session({ app.use(session({
secret: process.env.SESSION_SECRET || 'default-secret-change-this', secret: process.env.SESSION_SECRET || 'default-secret-change-this',
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { secure: false } // Set to true if using HTTPS cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7 // 7 days
}
})); }));
// Database connection pool (uses .env configuration) // Database connection pool
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT, port: process.env.DB_PORT || 5432,
database: process.env.DB_NAME, database: process.env.DB_NAME || 'postgres',
user: process.env.DB_USER, user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD || 'postgres',
}); });
// Test database connection on startup // Test database connection
pool.connect((err, client, release) => { pool.connect((err, client, release) => {
if (err) { if (err) {
console.error('❌ Error connecting to PostgreSQL:', err.message); console.error('❌ Error connecting to PostgreSQL:', err.message);
console.log('Проверьте настройки в .env файле'); console.log('Проверьте настройки в .env файле');
} else { } else {
console.log('✅ Connected to PostgreSQL database'); console.log('✅ Connected to PostgreSQL database');
console.log(` Host: ${process.env.DB_HOST}:${process.env.DB_PORT}`); console.log(` Host: ${process.env.DB_HOST || 'localhost'}:${process.env.DB_PORT || 5432}`);
console.log(` Database: ${process.env.DB_NAME}`); console.log(` Database: ${process.env.DB_NAME || 'postgres'}`);
release(); release();
} }
}); });
// Helper: get primary key column for a table (returns null if none) // Initialize database schema
async function getPrimaryKeyColumn(tableName) { async function initializeDatabase() {
const result = await pool.query(` const client = await pool.connect();
SELECT kcu.column_name try {
FROM information_schema.table_constraints tc // Check if users table exists, if not create it
JOIN information_schema.key_column_usage kcu await client.query(`
ON tc.constraint_name = kcu.constraint_name CREATE TABLE IF NOT EXISTS users (
AND tc.table_schema = kcu.table_schema id SERIAL PRIMARY KEY,
WHERE tc.constraint_type = 'PRIMARY KEY' name VARCHAR(255) NOT NULL,
AND tc.table_name = $1 email VARCHAR(255) UNIQUE NOT NULL,
AND tc.table_schema = 'public' password VARCHAR(255) NOT NULL,
LIMIT 1 role VARCHAR(50) NOT NULL DEFAULT 'viewer',
`, [tableName]); active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
return result.rows[0]?.column_name || null; CREATE TABLE IF NOT EXISTS activity_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
action VARCHAR(255),
details JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at);
`);
// Create default admin user if doesn't exist
const adminExists = await client.query(
'SELECT id FROM users WHERE email = $1',
['admin@example.com']
);
if (adminExists.rows.length === 0) {
const hashedPassword = await bcrypt.hash('admin123', 10);
await client.query(
`INSERT INTO users (name, email, password, role, active)
VALUES ($1, $2, $3, $4, $5)`,
['Admin', 'admin@example.com', hashedPassword, 'superadmin', true]
);
console.log('✅ Created default admin user: admin@example.com / admin123');
}
console.log('✅ Database schema initialized');
} catch (error) {
console.error('Database initialization error:', error);
} finally {
client.release();
}
} }
// Middleware to check if user is authenticated // Initialize on startup
const requireAuth = (req, res, next) => { initializeDatabase();
if (req.session && req.session.authenticated) {
next();
} else {
res.status(401).json({ error: 'Unauthorized' });
}
};
// Login endpoint - checks users.json (fallback to .env admin) // Routes
const authRoutes = require('./src/routes/auth');
const userRoutes = require('./src/routes/users');
const dbRoutes = require('./src/routes/db-tables');
const adminRoutes = require('./src/routes/admin');
// API Routes
app.use('/api/auth', authRoutes(pool));
app.use('/api/users', userRoutes(pool));
app.use('/api/db', dbRoutes(pool));
app.use('/api/admin', adminRoutes(pool));
// SPA catch-all route
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
res.status(500).json({
success: false,
message: 'Internal server error',
error: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
// Start server
app.listen(PORT, () => {
console.log(`\n🚀 PostgreSQL Admin Panel running at http://localhost:${PORT}`);
console.log(`📦 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`\n💾 Database Connection:`);
console.log(` Host: ${process.env.DB_HOST || 'localhost'}`);
console.log(` Database: ${process.env.DB_NAME || 'postgres'}`);
console.log('\n');
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received. Shutting down gracefully...');
pool.end(() => {
console.log('Connection pool closed');
process.exit(0);
});
});
module.exports = app;
app.post('/api/login', async (req, res) => { app.post('/api/login', async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;

127
src/routes/admin.js Normal file
View File

@@ -0,0 +1,127 @@
const express = require('express');
module.exports = (pool) => {
const router = express.Router();
// Middleware
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ success: false, message: 'Unauthorized' });
}
};
const requireAdmin = (req, res, next) => {
if (req.session && (req.session.role === 'superadmin' || req.session.role === 'admin')) {
next();
} else {
res.status(403).json({ success: false, message: 'Forbidden' });
}
};
// Get system stats
router.get('/stats', requireAuth, requireAdmin, async (req, res) => {
try {
const users = await pool.query('SELECT COUNT(*) as count FROM users');
const logs = await pool.query('SELECT COUNT(*) as count FROM activity_logs');
res.json({
success: true,
stats: {
totalUsers: parseInt(users.rows[0].count),
totalLogs: parseInt(logs.rows[0].count),
activeUsers: parseInt(users.rows[0].count) // Can be improved
}
});
} catch (error) {
console.error('Get stats error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении статистики'
});
}
});
// Get activity logs
router.get('/logs', requireAuth, requireAdmin, async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 100;
const result = await pool.query(
`SELECT
l.id,
l.user_id,
u.name as user_name,
l.action,
l.details,
l.created_at
FROM activity_logs l
LEFT JOIN users u ON l.user_id = u.id
ORDER BY l.created_at DESC
LIMIT $1`,
[limit]
);
res.json({
success: true,
logs: result.rows
});
} catch (error) {
console.error('Get logs error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении логов'
});
}
});
// Get backups
router.get('/backups', requireAuth, requireAdmin, async (req, res) => {
try {
// TODO: Implement actual backup system
res.json({
success: true,
backups: [
{
id: 1,
name: 'Backup 2024-01-15',
date: '2024-01-15T12:00:00Z',
size: '450 MB'
}
]
});
} catch (error) {
console.error('Get backups error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении резервных копий'
});
}
});
// Create backup
router.post('/backups', requireAuth, requireAdmin, async (req, res) => {
try {
// TODO: Implement actual backup system
res.json({
success: true,
message: 'Резервная копия создается',
backup: {
id: 2,
name: 'Backup ' + new Date().toISOString().split('T')[0],
date: new Date().toISOString(),
status: 'creating'
}
});
} catch (error) {
console.error('Create backup error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при создании резервной копии'
});
}
});
return router;
};

197
src/routes/auth.js Normal file
View File

@@ -0,0 +1,197 @@
const express = require('express');
const bcrypt = require('bcryptjs');
module.exports = (pool) => {
const router = express.Router();
// Middleware to check authentication
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ success: false, message: 'Unauthorized' });
}
};
// Register
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
if (!email || !password || !name) {
return res.status(400).json({
success: false,
message: 'Email, name и password обязательны'
});
}
// Check if user already exists
const existing = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existing.rows.length > 0) {
return res.status(400).json({
success: false,
message: 'Пользователь с таким email уже существует'
});
}
// Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// Create user
const result = await pool.query(
'INSERT INTO users (name, email, password, role, active) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, email, role',
[name, email, hashedPassword, 'viewer', true]
);
res.json({
success: true,
message: 'Пользователь создан. Вы можете войти.',
user: result.rows[0]
});
} catch (error) {
console.error('Register error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при создании пользователя'
});
}
});
// Login
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
message: 'Email и password обязательны'
});
}
// Find user
const result = await pool.query(
'SELECT id, name, email, password, role, active FROM users WHERE email = $1',
[email]
);
if (result.rows.length === 0) {
return res.status(401).json({
success: false,
message: 'Неверный email или пароль'
});
}
const user = result.rows[0];
if (!user.active) {
return res.status(401).json({
success: false,
message: 'Пользователь неактивен'
});
}
// Check password
const passwordValid = await bcrypt.compare(password, user.password);
if (!passwordValid) {
return res.status(401).json({
success: false,
message: 'Неверный email или пароль'
});
}
// Set session
req.session.userId = user.id;
req.session.role = user.role;
// Log activity
await pool.query(
'INSERT INTO activity_logs (user_id, action) VALUES ($1, $2)',
[user.id, 'login']
);
res.json({
success: true,
message: 'Успешно вошли',
user: {
id: user.id,
name: user.name,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при входе'
});
}
});
// Get current user
router.get('/me', requireAuth, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, name, email, role FROM users WHERE id = $1',
[req.session.userId]
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
res.json({
success: true,
user: result.rows[0]
});
} catch (error) {
console.error('Get me error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении данных пользователя'
});
}
});
// Logout
router.post('/logout', requireAuth, async (req, res) => {
try {
// Log activity
await pool.query(
'INSERT INTO activity_logs (user_id, action) VALUES ($1, $2)',
[req.session.userId, 'logout']
);
req.session.destroy((err) => {
if (err) {
return res.status(500).json({
success: false,
message: 'Ошибка при выходе'
});
}
res.json({
success: true,
message: 'Успешно вышли'
});
});
} catch (error) {
console.error('Logout error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при выходе'
});
}
});
return router;
};

174
src/routes/db-tables.js Normal file
View File

@@ -0,0 +1,174 @@
const express = require('express');
module.exports = (pool) => {
const router = express.Router();
// Middleware
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ success: false, message: 'Unauthorized' });
}
};
// Get all tables
router.get('/tables', requireAuth, async (req, res) => {
try {
const result = await pool.query(`
SELECT
table_name as name,
(SELECT COUNT(*) FROM information_schema.columns WHERE table_name = t.table_name) as column_count
FROM information_schema.tables t
WHERE table_schema = 'public'
ORDER BY table_name
`);
// Get row counts
const tablesWithCount = await Promise.all(
result.rows.map(async (table) => {
try {
const countResult = await pool.query(
`SELECT COUNT(*) as count FROM "${table.name}"`
);
return {
...table,
recordCount: parseInt(countResult.rows[0].count)
};
} catch (e) {
return {
...table,
recordCount: 0
};
}
})
);
res.json({
success: true,
tables: tablesWithCount
});
} catch (error) {
console.error('Get tables error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении таблиц'
});
}
});
// Get database stats
router.get('/stats', requireAuth, async (req, res) => {
try {
const tables = await pool.query(`
SELECT COUNT(*) as count FROM information_schema.tables
WHERE table_schema = 'public'
`);
const users = await pool.query('SELECT COUNT(*) as count FROM users');
let totalRecords = 0;
const tableNames = await pool.query(`
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
`);
for (const table of tableNames.rows) {
try {
const result = await pool.query(
`SELECT COUNT(*) as count FROM "${table.table_name}"`
);
totalRecords += parseInt(result.rows[0].count);
} catch (e) {
// Skip tables that can't be counted
}
}
res.json({
success: true,
stats: {
tableCount: parseInt(tables.rows[0].count),
recordCount: totalRecords,
userCount: parseInt(users.rows[0].count),
dbSize: '-'
}
});
} catch (error) {
console.error('Get stats error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении статистики'
});
}
});
// Get table data
router.get('/tables/:tableName/data', requireAuth, async (req, res) => {
try {
const { tableName } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 100;
const offset = (page - 1) * limit;
const result = await pool.query(
`SELECT * FROM "${tableName}" LIMIT $1 OFFSET $2`,
[limit, offset]
);
const countResult = await pool.query(
`SELECT COUNT(*) as count FROM "${tableName}"`
);
res.json({
success: true,
data: result.rows,
total: parseInt(countResult.rows[0].count),
page,
limit
});
} catch (error) {
console.error('Get table data error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении данных таблицы'
});
}
});
// Execute query
router.post('/query', requireAuth, async (req, res) => {
try {
const { sql } = req.body;
if (!sql) {
return res.status(400).json({
success: false,
message: 'SQL запрос обязателен'
});
}
// Log query
await pool.query(
'INSERT INTO activity_logs (user_id, action, details) VALUES ($1, $2, $3)',
[req.session.userId, 'execute_query', JSON.stringify({ sql })]
);
const result = await pool.query(sql);
res.json({
success: true,
data: result.rows,
rowCount: result.rowCount
});
} catch (error) {
console.error('Execute query error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при выполнении запроса',
error: error.message
});
}
});
return router;
};

233
src/routes/users.js Normal file
View File

@@ -0,0 +1,233 @@
const express = require('express');
const bcrypt = require('bcryptjs');
module.exports = (pool) => {
const router = express.Router();
// Middleware to check authentication
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ success: false, message: 'Unauthorized' });
}
};
// Middleware to check admin role
const requireAdmin = (req, res, next) => {
if (req.session && req.session.role === 'superadmin' || req.session.role === 'admin') {
next();
} else {
res.status(403).json({ success: false, message: 'Forbidden' });
}
};
// Get all users (admin only)
router.get('/', requireAuth, requireAdmin, async (req, res) => {
try {
const result = await pool.query(
'SELECT id, name, email, role, active, created_at FROM users ORDER BY created_at DESC'
);
res.json({
success: true,
users: result.rows
});
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при получении пользователей'
});
}
});
// Create user (admin only)
router.post('/', requireAuth, requireAdmin, async (req, res) => {
try {
const { name, email, password, role } = req.body;
if (!email || !password || !name) {
return res.status(400).json({
success: false,
message: 'Name, email и password обязательны'
});
}
// Check if user exists
const existing = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email]
);
if (existing.rows.length > 0) {
return res.status(400).json({
success: false,
message: 'Пользователь с таким email уже существует'
});
}
const hashedPassword = await bcrypt.hash(password, 10);
const result = await pool.query(
'INSERT INTO users (name, email, password, role, active) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, email, role, active',
[name, email, hashedPassword, role || 'viewer', true]
);
// Log activity
await pool.query(
'INSERT INTO activity_logs (user_id, action, details) VALUES ($1, $2, $3)',
[req.session.userId, 'create_user', JSON.stringify({ userId: result.rows[0].id })]
);
res.json({
success: true,
message: 'Пользователь создан',
user: result.rows[0]
});
} catch (error) {
console.error('Create user error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при создании пользователя'
});
}
});
// Update user (admin only or self)
router.patch('/:userId', requireAuth, async (req, res) => {
try {
const { userId } = req.params;
const { name, email, password, role, active } = req.body;
// Check permission
if (req.session.userId !== parseInt(userId) && req.session.role !== 'superadmin' && req.session.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Вы не можете редактировать этого пользователя'
});
}
let query = 'UPDATE users SET ';
const updates = [];
const values = [];
let paramIndex = 1;
if (name) {
updates.push(`name = $${paramIndex}`);
values.push(name);
paramIndex++;
}
if (email) {
updates.push(`email = $${paramIndex}`);
values.push(email);
paramIndex++;
}
if (password) {
const hashedPassword = await bcrypt.hash(password, 10);
updates.push(`password = $${paramIndex}`);
values.push(hashedPassword);
paramIndex++;
}
if (role && (req.session.role === 'superadmin' || req.session.role === 'admin')) {
updates.push(`role = $${paramIndex}`);
values.push(role);
paramIndex++;
}
if (typeof active === 'boolean' && (req.session.role === 'superadmin' || req.session.role === 'admin')) {
updates.push(`active = $${paramIndex}`);
values.push(active);
paramIndex++;
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: 'Нет данных для обновления'
});
}
updates.push(`updated_at = NOW()`);
values.push(userId);
const result = await pool.query(
`${query}${updates.join(', ')} WHERE id = $${paramIndex} RETURNING id, name, email, role, active`,
values
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
// Log activity
await pool.query(
'INSERT INTO activity_logs (user_id, action, details) VALUES ($1, $2, $3)',
[req.session.userId, 'update_user', JSON.stringify({ userId, updatedFields: updates })]
);
res.json({
success: true,
message: 'Пользователь обновлен',
user: result.rows[0]
});
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при обновлении пользователя'
});
}
});
// Delete user (admin only)
router.delete('/:userId', requireAuth, requireAdmin, async (req, res) => {
try {
const { userId } = req.params;
if (parseInt(userId) === req.session.userId) {
return res.status(400).json({
success: false,
message: 'Вы не можете удалить самого себя'
});
}
const result = await pool.query(
'DELETE FROM users WHERE id = $1 RETURNING id',
[userId]
);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
// Log activity
await pool.query(
'INSERT INTO activity_logs (user_id, action, details) VALUES ($1, $2, $3)',
[req.session.userId, 'delete_user', JSON.stringify({ userId })]
);
res.json({
success: true,
message: 'Пользователь удален'
});
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({
success: false,
message: 'Ошибка при удалении пользователя'
});
}
});
return router;
};

View File

@@ -1,10 +1,15 @@
{ {
"users": [ "users": [
{ {
"username": "superadmin", "success": true,
"password": "superadmin", "role": "superadmin",
"role": "superadmin" "permissions": {
}, "folders": null,
"canCreate": true,
"canEdit": true,
"canDelete": true
}
},
{ {
"username": "frontend_admin", "username": "frontend_admin",
"password": "frontend", "password": "frontend",