Compare commits
18 Commits
0bde12a39d
...
v0.2.7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a37542929 | ||
|
|
0eb6730a55 | ||
|
|
e2d056e7cb | ||
|
|
1bce8086d6 | ||
|
|
0bdd163728 | ||
|
|
fa0f5e321d | ||
|
|
502832ac9a | ||
|
|
8d84484412 | ||
| 2510d9e36e | |||
| d7285fc730 | |||
| e33a3f2c88 | |||
| 4735e2b9bb | |||
| cdf5cef2cf | |||
| 7f030e7db7 | |||
| 3d222b7f14 | |||
| c024b96de7 | |||
| 2c75a7ccb8 | |||
|
|
f25477a25e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -41,3 +41,4 @@ Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
releases/
|
||||
|
||||
62
CLAUDE.md
62
CLAUDE.md
@@ -46,30 +46,31 @@
|
||||
**TODO:**
|
||||
- ❌ Conflict resolution (Phase 4, last-write-wins default)
|
||||
|
||||
### UI Improvements 🔶 IN PROGRESS
|
||||
### UI Improvements ✅ MOSTLY DONE
|
||||
|
||||
**1. Sync icon + pricelist badge в header (tasks 4+2):**
|
||||
- ❌ `sync_status.html`: заменить текст Online/Offline на SVG иконку
|
||||
- ❌ Кнопка sync → иконка (circular arrows) вместо текста
|
||||
- ❌ Dropdown при клике: Push changes, Full sync, статус последней синхронизации
|
||||
- ❌ `configs.html`: рядом с кнопкой "Создать" показать badge с версией активного прайслиста
|
||||
- ❌ Загружать через `/api/pricelists/latest` при DOMContentLoaded
|
||||
**1. Sync UI + pricelist badge: ✅ DONE**
|
||||
- ✅ `sync_status.html`: SVG иконки Online/Offline (кликабельные → открывают модал)
|
||||
- ✅ Кнопка sync → иконка circular arrows (только full sync)
|
||||
- ✅ Модальное окно "Статус системы" в `base.html` (info о БД, ошибки синхронизации)
|
||||
- ✅ `configs.html`: badge с версией активного прайслиста
|
||||
- ✅ Загрузка через `/api/pricelists/latest` при DOMContentLoaded
|
||||
- ✅ Удалён dropdown с Push changes (упрощение UI)
|
||||
|
||||
**2. Прайслисты → вкладка в "Администратор цен" (task 1):**
|
||||
- ❌ `base.html`: убрать отдельную ссылку "Прайслисты" из навигации
|
||||
- ❌ `admin_pricing.html`: добавить 4-ю вкладку "Прайслисты"
|
||||
- ❌ Перенести логику из `pricelists.html` (table, create modal, CRUD) в эту вкладку
|
||||
- ❌ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists` или удалить
|
||||
**2. Прайслисты → вкладка в "Администратор цен": ✅ DONE**
|
||||
- ✅ `base.html`: убрана ссылка "Прайслисты" из навигации
|
||||
- ✅ `admin_pricing.html`: добавлена вкладка "Прайслисты"
|
||||
- ✅ Логика перенесена из `pricelists.html` (table, create modal, CRUD)
|
||||
- ✅ Route `/pricelists` → редирект на `/admin/pricing?tab=pricelists`
|
||||
- ✅ Поддержка URL param `?tab=pricelists`
|
||||
|
||||
**3. Страница настроек: расширить + синхронизация (task 3):**
|
||||
- ❌ `setup.html`: переделать на `{{template "base" .}}` структуру
|
||||
- ❌ Увеличить до `max-w-4xl`, разделить на 2 секции
|
||||
- ❌ Секция A: Подключение к БД (текущая форма)
|
||||
- ❌ Секция B: Синхронизация данных:
|
||||
- Статус Online/Offline
|
||||
- Кнопки: "Синхронизировать всё", "Обновить компоненты", "Обновить прайслисты"
|
||||
- Журнал синхронизации (последние N операций)
|
||||
- ❌ Возможно: новый API endpoint для sync log
|
||||
**3. Модал "Настройка цены" - кол-во котировок с учётом периода: ❌ TODO**
|
||||
- Текущее: показывает только общее кол-во котировок
|
||||
- Новое: показывать `N (всего: M)` где N - за выбранный период, M - всего
|
||||
- ❌ `admin_pricing.html`: обновить `#modal-quote-count`
|
||||
- ❌ `admin_pricing_handler.go`: в `/api/admin/pricing/preview` возвращать `quote_count_period` + `quote_count_total`
|
||||
|
||||
**4. Страница настроек: ❌ ОТЛОЖЕНО**
|
||||
- Перенесено в Phase 3 (после основных UI улучшений)
|
||||
|
||||
### Phase 3: Projects and Specifications
|
||||
- qt_projects, qt_specifications tables (MariaDB)
|
||||
@@ -133,9 +134,22 @@ Go 1.22+ | Gin | GORM | MariaDB 11 | SQLite (glebarez/sqlite) | htmx + Tailwind
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
go run ./cmd/server # Dev server
|
||||
go run ./cmd/cron -job=X # cleanup-pricelists | update-prices | update-popularity
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
# Development
|
||||
go run ./cmd/qfs # Dev server
|
||||
make run # Dev server (via Makefile)
|
||||
|
||||
# Production build
|
||||
make build-release # Optimized build with version (recommended)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
|
||||
# Cron jobs
|
||||
go run ./cmd/cron -job=cleanup-pricelists # Remove old unused pricelists
|
||||
go run ./cmd/cron -job=update-prices # Recalculate all prices
|
||||
go run ./cmd/cron -job=update-popularity # Update popularity scores
|
||||
|
||||
# Check version
|
||||
./bin/qfs -version
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -60,7 +60,7 @@ localConfigService := services.NewLocalConfigurationService(
|
||||
### Шаг 1: Обновить main.go
|
||||
|
||||
```go
|
||||
// В cmd/server/main.go
|
||||
// В cmd/qfs/main.go
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||
|
||||
// Создать isOnline функцию
|
||||
@@ -165,7 +165,7 @@ type PendingChange struct {
|
||||
|
||||
```bash
|
||||
# Compile
|
||||
go build ./cmd/server
|
||||
go build ./cmd/qfs
|
||||
|
||||
# Run
|
||||
./quoteforge
|
||||
|
||||
@@ -89,7 +89,7 @@ mysql -u user -p RFQ_LOG < migrations/004_add_price_updated_at.sql
|
||||
- `internal/models/configuration.go` - добавлено поле `PriceUpdatedAt`
|
||||
- `internal/services/configuration.go` - добавлен метод `RefreshPrices()`
|
||||
- `internal/handlers/configuration.go` - добавлен обработчик `RefreshPrices()`
|
||||
- `cmd/server/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
||||
- `cmd/qfs/main.go` - добавлен маршрут `/api/configs/:uuid/refresh-prices`
|
||||
- `web/templates/index.html` - добавлена кнопка и JavaScript функции
|
||||
- `migrations/004_add_price_updated_at.sql` - SQL миграция
|
||||
- `CLAUDE.md` - обновлена документация
|
||||
|
||||
97
Makefile
Normal file
97
Makefile
Normal file
@@ -0,0 +1,97 @@
|
||||
.PHONY: build build-release clean test run version
|
||||
|
||||
# Get version from git
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
LDFLAGS := -s -w -X main.Version=$(VERSION)
|
||||
|
||||
# Binary name
|
||||
BINARY := qfs
|
||||
|
||||
# Build for development (with debug info)
|
||||
build:
|
||||
go build -o bin/$(BINARY) ./cmd/qfs
|
||||
|
||||
# Build for release (optimized, with version)
|
||||
build-release:
|
||||
@echo "Building $(BINARY) version $(VERSION)..."
|
||||
CGO_ENABLED=0 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY) ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)"
|
||||
@./bin/$(BINARY) -version
|
||||
|
||||
# Build release for Linux (cross-compile)
|
||||
build-linux:
|
||||
@echo "Building $(BINARY) for Linux..."
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-linux-amd64"
|
||||
|
||||
# Build release for macOS (cross-compile)
|
||||
build-macos:
|
||||
@echo "Building $(BINARY) for macOS..."
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-amd64 ./cmd/qfs
|
||||
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-darwin-arm64 ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-darwin-amd64"
|
||||
@echo "✓ Built: bin/$(BINARY)-darwin-arm64"
|
||||
|
||||
# Build release for Windows (cross-compile)
|
||||
build-windows:
|
||||
@echo "Building $(BINARY) for Windows..."
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o bin/$(BINARY)-windows-amd64.exe ./cmd/qfs
|
||||
@echo "✓ Built: bin/$(BINARY)-windows-amd64.exe"
|
||||
|
||||
# Build all platforms
|
||||
build-all: build-release build-linux build-macos build-windows
|
||||
|
||||
# Create release packages for all platforms
|
||||
release:
|
||||
@./scripts/release.sh
|
||||
|
||||
# Show version
|
||||
version:
|
||||
@echo "Version: $(VERSION)"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf bin/
|
||||
rm -f $(BINARY)
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
go test -v ./...
|
||||
|
||||
# Run development server
|
||||
run:
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Run with auto-restart (requires entr: brew install entr)
|
||||
watch:
|
||||
find . -name '*.go' | entr -r go run ./cmd/qfs
|
||||
|
||||
# Install dependencies
|
||||
deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "QuoteForge Server (qfs) - Build Commands"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " build Build for development (with debug info)"
|
||||
@echo " build-release Build optimized release (default)"
|
||||
@echo " build-linux Cross-compile for Linux"
|
||||
@echo " build-macos Cross-compile for macOS (Intel + Apple Silicon)"
|
||||
@echo " build-windows Cross-compile for Windows"
|
||||
@echo " build-all Build for all platforms"
|
||||
@echo " release Create release packages for all platforms"
|
||||
@echo " version Show current version"
|
||||
@echo " clean Remove build artifacts"
|
||||
@echo " test Run tests"
|
||||
@echo " run Run development server"
|
||||
@echo " watch Run with auto-restart (requires entr)"
|
||||
@echo " deps Install/update dependencies"
|
||||
@echo " help Show this help"
|
||||
@echo ""
|
||||
@echo "Current version: $(VERSION)"
|
||||
30
README.md
30
README.md
@@ -82,7 +82,7 @@ auth:
|
||||
### 3. Миграции базы данных
|
||||
|
||||
```bash
|
||||
go run ./cmd/server -migrate
|
||||
go run ./cmd/qfs -migrate
|
||||
```
|
||||
|
||||
### 4. Импорт метаданных компонентов
|
||||
@@ -95,11 +95,27 @@ go run ./cmd/importer
|
||||
|
||||
```bash
|
||||
# Development
|
||||
go run ./cmd/server
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Production
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
./bin/quoteforge
|
||||
# Production (with Makefile - recommended)
|
||||
make build-release # Builds with version info
|
||||
./bin/qfs -version # Check version
|
||||
|
||||
# Production (manual)
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=$VERSION" -o bin/qfs ./cmd/qfs
|
||||
./bin/qfs -version
|
||||
```
|
||||
|
||||
**Makefile команды:**
|
||||
```bash
|
||||
make build-release # Оптимизированная сборка с версией
|
||||
make build-all # Сборка для всех платформ (Linux, macOS, Windows)
|
||||
make build-windows # Только для Windows
|
||||
make run # Запуск dev сервера
|
||||
make test # Запуск тестов
|
||||
make clean # Очистка bin/
|
||||
make help # Показать все команды
|
||||
```
|
||||
|
||||
Приложение будет доступно по адресу: http://localhost:8080
|
||||
@@ -209,13 +225,13 @@ go run ./cmd/cron -job=update-popularity
|
||||
|
||||
```bash
|
||||
# Запуск в режиме разработки (hot reload)
|
||||
go run ./cmd/server
|
||||
go run ./cmd/qfs
|
||||
|
||||
# Запуск тестов
|
||||
go test ./...
|
||||
|
||||
# Сборка для Linux
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/quoteforge ./cmd/server
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o bin/qfs ./cmd/qfs
|
||||
```
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
60
RELEASE_v0.2.6.md
Normal file
60
RELEASE_v0.2.6.md
Normal file
@@ -0,0 +1,60 @@
|
||||
## Release v0.2.6 - Build & Release Improvements
|
||||
|
||||
Minor release focused on developer experience and release automation.
|
||||
|
||||
### Changes
|
||||
|
||||
**Build System**
|
||||
- 🎯 Renamed binary from `quoteforge` to `qfs` (shorter, easier to type)
|
||||
- 🏷️ Added `-version` flag to display build version
|
||||
- 📦 Added Makefile with build targets for all platforms
|
||||
- 🚀 Added automated release script for multi-platform binaries
|
||||
|
||||
**New Commands**
|
||||
```bash
|
||||
make build-release # Optimized build with version info
|
||||
make build-all # Build for Linux + macOS (Intel/ARM)
|
||||
make release # Create release packages with checksums
|
||||
./bin/qfs -version # Show version
|
||||
```
|
||||
|
||||
**Release Artifacts**
|
||||
- Linux AMD64
|
||||
- macOS Intel (AMD64)
|
||||
- macOS Apple Silicon (ARM64)
|
||||
- Windows AMD64
|
||||
- SHA256 checksums
|
||||
|
||||
### Installation
|
||||
|
||||
Download the appropriate binary for your platform:
|
||||
```bash
|
||||
# Linux
|
||||
wget https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-linux-amd64.tar.gz
|
||||
tar -xzf qfs-v0.2.6-linux-amd64.tar.gz
|
||||
chmod +x qfs-linux-amd64
|
||||
./qfs-linux-amd64
|
||||
|
||||
# macOS Intel
|
||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-darwin-amd64.tar.gz
|
||||
tar -xzf qfs-v0.2.6-darwin-amd64.tar.gz
|
||||
chmod +x qfs-darwin-amd64
|
||||
./qfs-darwin-amd64
|
||||
|
||||
# macOS Apple Silicon
|
||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-darwin-arm64.tar.gz
|
||||
tar -xzf qfs-v0.2.6-darwin-arm64.tar.gz
|
||||
chmod +x qfs-darwin-arm64
|
||||
./qfs-darwin-arm64
|
||||
|
||||
# Windows
|
||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.6/qfs-v0.2.6-windows-amd64.zip
|
||||
unzip qfs-v0.2.6-windows-amd64.zip
|
||||
qfs-windows-amd64.exe
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
None - fully backward compatible with v0.2.5
|
||||
|
||||
### Full Changelog
|
||||
https://git.mchus.pro/mchus/QuoteForge/compare/v0.2.5...v0.2.6
|
||||
57
RELEASE_v0.2.7.md
Normal file
57
RELEASE_v0.2.7.md
Normal file
@@ -0,0 +1,57 @@
|
||||
## Release v0.2.7 - Windows Support & Localhost Binding
|
||||
|
||||
Bug fix release improving Windows compatibility and default network binding.
|
||||
|
||||
### Changes
|
||||
|
||||
**Windows Support**
|
||||
- ✅ Fixed template loading errors on Windows
|
||||
- ✅ All file paths now use `filepath.Join` for cross-platform compatibility
|
||||
- ✅ Binary now works correctly on Windows without path errors
|
||||
|
||||
**Localhost Binding**
|
||||
- ✅ Server now binds to `127.0.0.1` by default (instead of `0.0.0.0`)
|
||||
- ✅ Browser always opens to `http://127.0.0.1:8080`
|
||||
- ✅ Setup mode properly opens browser automatically
|
||||
- ✅ More secure default - only accessible from local machine
|
||||
|
||||
> **Note:** To bind to all network interfaces (for network access), set `host: "0.0.0.0"` in config.yaml
|
||||
|
||||
### Installation
|
||||
|
||||
Download the appropriate binary for your platform:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
wget https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-linux-amd64.tar.gz
|
||||
tar -xzf qfs-v0.2.7-linux-amd64.tar.gz
|
||||
chmod +x qfs-linux-amd64
|
||||
./qfs-linux-amd64
|
||||
|
||||
# macOS Intel
|
||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-darwin-amd64.tar.gz
|
||||
tar -xzf qfs-v0.2.7-darwin-amd64.tar.gz
|
||||
chmod +x qfs-darwin-amd64
|
||||
./qfs-darwin-amd64
|
||||
|
||||
# macOS Apple Silicon
|
||||
curl -L -O https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-darwin-arm64.tar.gz
|
||||
tar -xzf qfs-v0.2.7-darwin-arm64.tar.gz
|
||||
chmod +x qfs-darwin-arm64
|
||||
./qfs-darwin-arm64
|
||||
|
||||
# Windows
|
||||
# Download and extract: https://git.mchus.pro/mchus/QuoteForge/releases/download/v0.2.7/qfs-v0.2.7-windows-amd64.zip
|
||||
# Run: qfs-windows-amd64.exe
|
||||
```
|
||||
|
||||
### Breaking Changes
|
||||
None - fully backward compatible with v0.2.6
|
||||
|
||||
### Upgrade Notes
|
||||
- If you use config.yaml with `host: "0.0.0.0"`, the app will respect that setting
|
||||
- Default behavior now binds only to localhost for security
|
||||
- Windows users no longer need workarounds for path errors
|
||||
|
||||
### Full Changelog
|
||||
https://git.mchus.pro/mchus/QuoteForge/compare/v0.2.6...v0.2.7
|
||||
@@ -7,13 +7,17 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/config"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/handlers"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/middleware"
|
||||
@@ -33,11 +37,21 @@ const (
|
||||
localDBPath = "./data/settings.db"
|
||||
)
|
||||
|
||||
// Version is set via ldflags during build
|
||||
var Version = "dev"
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "config.yaml", "path to config file (optional, for server settings)")
|
||||
migrate := flag.Bool("migrate", false, "run database migrations")
|
||||
version := flag.Bool("version", false, "show version information")
|
||||
flag.Parse()
|
||||
|
||||
// Show version if requested
|
||||
if *version {
|
||||
fmt.Printf("qfs version %s\n", Version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Initialize local SQLite database (always used)
|
||||
local, err := localdb.New(localDBPath)
|
||||
if err != nil {
|
||||
@@ -63,28 +77,25 @@ func main() {
|
||||
|
||||
setupLogger(cfg.Logging)
|
||||
|
||||
// Get DSN from local SQLite
|
||||
dsn, err := local.GetDSN()
|
||||
if err != nil {
|
||||
slog.Error("failed to get database settings", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Connect to MariaDB
|
||||
db, err := setupDatabaseFromDSN(dsn)
|
||||
if err != nil {
|
||||
slog.Error("failed to connect to database", "error", err)
|
||||
slog.Info("you may need to reconfigure connection at /setup")
|
||||
os.Exit(1)
|
||||
}
|
||||
// Create connection manager and try to connect immediately if settings exist
|
||||
connMgr := db.NewConnectionManager(local)
|
||||
|
||||
dbUser := local.GetDBUser()
|
||||
dbUserID := uint(1)
|
||||
|
||||
// Ensure DB user exists in qt_users table (for foreign key constraint)
|
||||
dbUserID, err := models.EnsureDBUser(db, dbUser)
|
||||
// Try to connect to MariaDB on startup
|
||||
mariaDB, err := connMgr.GetDB()
|
||||
if err != nil {
|
||||
slog.Error("failed to ensure DB user exists", "error", err)
|
||||
os.Exit(1)
|
||||
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
|
||||
mariaDB = nil
|
||||
} else {
|
||||
slog.Info("successfully connected to MariaDB on startup")
|
||||
// Ensure DB user exists and get their ID
|
||||
if dbUserID, err = models.EnsureDBUser(mariaDB, dbUser); err != nil {
|
||||
slog.Error("failed to ensure DB user", "error", err)
|
||||
// Continue with default ID
|
||||
dbUserID = uint(1)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("starting QuoteForge server",
|
||||
@@ -92,15 +103,20 @@ func main() {
|
||||
"port", cfg.Server.Port,
|
||||
"db_user", dbUser,
|
||||
"db_user_id", dbUserID,
|
||||
"online", mariaDB != nil,
|
||||
)
|
||||
|
||||
if *migrate {
|
||||
if mariaDB == nil {
|
||||
slog.Error("cannot run migrations: database not available")
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("running database migrations...")
|
||||
if err := models.Migrate(db); err != nil {
|
||||
if err := models.Migrate(mariaDB); err != nil {
|
||||
slog.Error("migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := models.SeedCategories(db); err != nil {
|
||||
if err := models.SeedCategories(mariaDB); err != nil {
|
||||
slog.Error("seeding categories failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -108,17 +124,17 @@ func main() {
|
||||
}
|
||||
|
||||
gin.SetMode(cfg.Server.Mode)
|
||||
router, syncService, err := setupRouter(db, cfg, local, dbUserID)
|
||||
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUserID)
|
||||
if err != nil {
|
||||
slog.Error("failed to setup router", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Start background sync worker
|
||||
// Start background sync worker (will auto-skip when offline)
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
defer workerCancel()
|
||||
|
||||
syncWorker := sync.NewWorker(syncService, db, 5*time.Minute)
|
||||
syncWorker := sync.NewWorker(syncService, connMgr, 5*time.Minute)
|
||||
go syncWorker.Start(workerCtx)
|
||||
|
||||
srv := &http.Server{
|
||||
@@ -136,6 +152,18 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Automatically open browser after server starts (with a small delay)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
// Always use localhost for browser, even if server binds to 0.0.0.0
|
||||
browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
|
||||
slog.Info("Opening browser to", "url", browserURL)
|
||||
err := openBrowser(browserURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to open browser", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
@@ -159,7 +187,7 @@ func main() {
|
||||
|
||||
func setConfigDefaults(cfg *config.Config) {
|
||||
if cfg.Server.Host == "" {
|
||||
cfg.Server.Host = "0.0.0.0"
|
||||
cfg.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8080
|
||||
@@ -197,7 +225,9 @@ func setConfigDefaults(cfg *config.Config) {
|
||||
func runSetupMode(local *localdb.LocalDB) {
|
||||
restartSig := make(chan struct{}, 1)
|
||||
|
||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", restartSig)
|
||||
// In setup mode, we don't have a connection manager yet (will restart after setup)
|
||||
templatesPath := filepath.Join("web", "templates")
|
||||
setupHandler, err := handlers.NewSetupHandler(local, nil, templatesPath, restartSig)
|
||||
if err != nil {
|
||||
slog.Error("failed to create setup handler", "error", err)
|
||||
os.Exit(1)
|
||||
@@ -207,7 +237,8 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
|
||||
router.Static("/static", "web/static")
|
||||
staticPath := filepath.Join("web", "static")
|
||||
router.Static("/static", staticPath)
|
||||
|
||||
// Setup routes only
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
@@ -226,9 +257,8 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
})
|
||||
})
|
||||
|
||||
addr := ":8080"
|
||||
addr := "127.0.0.1:8080"
|
||||
slog.Info("starting setup mode server", "address", addr)
|
||||
slog.Info("open http://localhost:8080/setup to configure database connection")
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
@@ -242,6 +272,17 @@ func runSetupMode(local *localdb.LocalDB) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Open browser to setup page
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
browserURL := "http://127.0.0.1:8080/setup"
|
||||
slog.Info("Opening browser to setup page", "url", browserURL)
|
||||
err := openBrowser(browserURL)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to open browser", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
@@ -308,56 +349,89 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
||||
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUserID uint) (*gin.Engine, *sync.Service, error) {
|
||||
// mariaDB may be nil if we're in offline mode
|
||||
|
||||
// Repositories
|
||||
componentRepo := repository.NewComponentRepository(db)
|
||||
categoryRepo := repository.NewCategoryRepository(db)
|
||||
priceRepo := repository.NewPriceRepository(db)
|
||||
alertRepo := repository.NewAlertRepository(db)
|
||||
statsRepo := repository.NewStatsRepository(db)
|
||||
pricelistRepo := repository.NewPricelistRepository(db)
|
||||
configRepo := repository.NewConfigurationRepository(db)
|
||||
var componentRepo *repository.ComponentRepository
|
||||
var categoryRepo *repository.CategoryRepository
|
||||
var priceRepo *repository.PriceRepository
|
||||
var alertRepo *repository.AlertRepository
|
||||
var statsRepo *repository.StatsRepository
|
||||
var pricelistRepo *repository.PricelistRepository
|
||||
|
||||
// Only initialize repositories if we have a database connection
|
||||
if mariaDB != nil {
|
||||
componentRepo = repository.NewComponentRepository(mariaDB)
|
||||
categoryRepo = repository.NewCategoryRepository(mariaDB)
|
||||
priceRepo = repository.NewPriceRepository(mariaDB)
|
||||
alertRepo = repository.NewAlertRepository(mariaDB)
|
||||
statsRepo = repository.NewStatsRepository(mariaDB)
|
||||
pricelistRepo = repository.NewPricelistRepository(mariaDB)
|
||||
} else {
|
||||
// In offline mode, we'll use nil repositories or handle them differently
|
||||
// This is handled in the sync service and other components
|
||||
}
|
||||
|
||||
// Services
|
||||
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService := services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
exportService := services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService := pricelist.NewService(db, pricelistRepo, componentRepo)
|
||||
syncService := sync.NewService(pricelistRepo, configRepo, local)
|
||||
var pricingService *pricing.Service
|
||||
var componentService *services.ComponentService
|
||||
var quoteService *services.QuoteService
|
||||
var exportService *services.ExportService
|
||||
var alertService *alerts.Service
|
||||
var pricelistService *pricelist.Service
|
||||
var syncService *sync.Service
|
||||
|
||||
// Sync service always uses ConnectionManager (works offline and online)
|
||||
syncService = sync.NewService(connMgr, local)
|
||||
|
||||
if mariaDB != nil {
|
||||
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo)
|
||||
} else {
|
||||
// In offline mode, we still need to create services that don't require DB
|
||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil)
|
||||
}
|
||||
|
||||
// isOnline function for local-first architecture
|
||||
isOnline := func() bool {
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sqlDB.Ping() == nil
|
||||
return connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// Local-first configuration service (replaces old ConfigurationService)
|
||||
configService := services.NewLocalConfigurationService(local, syncService, quoteService, isOnline)
|
||||
|
||||
// Use filepath.Join for cross-platform path compatibility
|
||||
templatesPath := filepath.Join("web", "templates")
|
||||
|
||||
// Handlers
|
||||
componentHandler := handlers.NewComponentHandler(componentService)
|
||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
||||
pricingHandler := handlers.NewPricingHandler(db, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||
pricingHandler := handlers.NewPricingHandler(mariaDB, pricingService, alertService, componentRepo, priceRepo, statsRepo)
|
||||
pricelistHandler := handlers.NewPricelistHandler(pricelistService, local)
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, db, "web/templates")
|
||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
|
||||
}
|
||||
|
||||
// Setup handler (for reconfiguration) - no restart signal in normal mode
|
||||
setupHandler, err := handlers.NewSetupHandler(local, "web/templates", nil)
|
||||
setupHandler, err := handlers.NewSetupHandler(local, connMgr, templatesPath, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("creating setup handler: %w", err)
|
||||
}
|
||||
|
||||
// Web handler (templates)
|
||||
webHandler, err := handlers.NewWebHandler("web/templates", componentService)
|
||||
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -367,10 +441,11 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(requestLogger())
|
||||
router.Use(middleware.CORS())
|
||||
router.Use(middleware.OfflineDetector(db, local))
|
||||
router.Use(middleware.OfflineDetector(connMgr, local))
|
||||
|
||||
// Static files
|
||||
router.Static("/static", "web/static")
|
||||
// Static files (use filepath.Join for Windows compatibility)
|
||||
staticPath := filepath.Join("web", "static")
|
||||
router.Static("/static", staticPath)
|
||||
|
||||
// Health check
|
||||
router.GET("/health", func(c *gin.Context) {
|
||||
@@ -380,25 +455,43 @@ func setupRouter(db *gorm.DB, cfg *config.Config, local *localdb.LocalDB, dbUser
|
||||
})
|
||||
})
|
||||
|
||||
// Restart endpoint (for development purposes)
|
||||
router.POST("/api/restart", func(c *gin.Context) {
|
||||
// This will cause the server to restart by exiting
|
||||
// The restartProcess function will be called to restart the process
|
||||
slog.Info("Restart requested via API")
|
||||
go func() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
restartProcess()
|
||||
}()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
|
||||
})
|
||||
|
||||
// DB status endpoint
|
||||
router.GET("/api/db-status", func(c *gin.Context) {
|
||||
var lotCount, lotLogCount, metadataCount int64
|
||||
var dbOK bool = true
|
||||
var dbOK bool = false
|
||||
var dbError string
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
dbOK = false
|
||||
dbError = err.Error()
|
||||
} else if err := sqlDB.Ping(); err != nil {
|
||||
dbOK = false
|
||||
dbError = err.Error()
|
||||
// Check if connection exists (fast check, no reconnect attempt)
|
||||
status := connMgr.GetStatus()
|
||||
if status.IsConnected {
|
||||
// Already connected, safe to use
|
||||
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
||||
dbOK = true
|
||||
db.Table("lot").Count(&lotCount)
|
||||
db.Table("lot_log").Count(&lotLogCount)
|
||||
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
}
|
||||
} else {
|
||||
// Not connected - don't try to reconnect on status check
|
||||
// This prevents 3s timeout on every request
|
||||
dbError = "Database not connected (offline mode)"
|
||||
if status.LastError != "" {
|
||||
dbError = status.LastError
|
||||
}
|
||||
}
|
||||
|
||||
db.Table("lot").Count(&lotCount)
|
||||
db.Table("lot_log").Count(&lotLogCount)
|
||||
db.Table("qt_lot_metadata").Count(&metadataCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"connected": dbOK,
|
||||
"error": dbError,
|
||||
@@ -658,6 +751,25 @@ func restartProcess() {
|
||||
}
|
||||
}
|
||||
|
||||
func openBrowser(url string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
cmd = "cmd"
|
||||
args = []string{"/c", "start", url}
|
||||
case "darwin":
|
||||
cmd = "open"
|
||||
args = []string{url}
|
||||
default: // "linux", "freebsd", "openbsd", "netbsd"
|
||||
cmd = "xdg-open"
|
||||
args = []string{url}
|
||||
}
|
||||
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
|
||||
func requestLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
@@ -2,7 +2,7 @@
|
||||
# Copy this file to config.yaml and update values
|
||||
|
||||
server:
|
||||
host: "0.0.0.0"
|
||||
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces
|
||||
port: 8080
|
||||
mode: "release" # debug | release
|
||||
read_timeout: "30s"
|
||||
|
||||
@@ -106,7 +106,7 @@ func Load(path string) (*Config, error) {
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
if c.Server.Host == "" {
|
||||
c.Server.Host = "0.0.0.0"
|
||||
c.Server.Host = "127.0.0.1"
|
||||
}
|
||||
if c.Server.Port == 0 {
|
||||
c.Server.Port = 8080
|
||||
|
||||
328
internal/db/connection.go
Normal file
328
internal/db/connection.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConnectTimeout = 5 * time.Second
|
||||
defaultPingInterval = 30 * time.Second
|
||||
defaultReconnectCooldown = 10 * time.Second
|
||||
|
||||
maxOpenConns = 10
|
||||
maxIdleConns = 2
|
||||
connMaxLifetime = 5 * time.Minute
|
||||
)
|
||||
|
||||
// ConnectionStatus represents the current status of the database connection
|
||||
type ConnectionStatus struct {
|
||||
IsConnected bool
|
||||
LastCheck time.Time
|
||||
LastError string // empty if no error
|
||||
DSNHost string // host:port for display (without password!)
|
||||
}
|
||||
|
||||
// ConnectionManager manages database connections with thread-safety and connection pooling
|
||||
type ConnectionManager struct {
|
||||
localDB *localdb.LocalDB // for getting DSN from settings
|
||||
mu sync.RWMutex // protects db and state
|
||||
db *gorm.DB // current connection (nil if not connected)
|
||||
lastError error // last connection error
|
||||
lastCheck time.Time // time of last check/attempt
|
||||
connectTimeout time.Duration // timeout for connection (default: 5s)
|
||||
pingInterval time.Duration // minimum interval between pings (default: 30s)
|
||||
reconnectCooldown time.Duration // pause after failed attempt (default: 10s)
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new ConnectionManager instance
|
||||
func NewConnectionManager(localDB *localdb.LocalDB) *ConnectionManager {
|
||||
return &ConnectionManager{
|
||||
localDB: localDB,
|
||||
connectTimeout: defaultConnectTimeout,
|
||||
pingInterval: defaultPingInterval,
|
||||
reconnectCooldown: defaultReconnectCooldown,
|
||||
db: nil,
|
||||
lastError: nil,
|
||||
lastCheck: time.Time{},
|
||||
}
|
||||
}
|
||||
|
||||
// GetDB returns the current database connection, establishing it if needed
|
||||
// Thread-safe and respects connection cooldowns
|
||||
func (cm *ConnectionManager) GetDB() (*gorm.DB, error) {
|
||||
// Handle case where localDB is nil
|
||||
if cm.localDB == nil {
|
||||
return nil, fmt.Errorf("local database not initialized")
|
||||
}
|
||||
|
||||
// First check if we already have a valid connection
|
||||
cm.mu.RLock()
|
||||
if cm.db != nil {
|
||||
// Check if connection is still valid and within ping interval
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
cm.mu.RUnlock()
|
||||
return cm.db, nil
|
||||
}
|
||||
}
|
||||
cm.mu.RUnlock()
|
||||
|
||||
// Upgrade to write lock
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Double-check: someone else might have connected while we were waiting for the write lock
|
||||
if cm.db != nil {
|
||||
// Check if connection is still valid and within ping interval
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
return cm.db, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're in cooldown period after a failed attempt
|
||||
if cm.lastError != nil && time.Since(cm.lastCheck) < cm.reconnectCooldown {
|
||||
return nil, cm.lastError
|
||||
}
|
||||
|
||||
// Attempt to connect
|
||||
err := cm.connect()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update last check time and return success
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return cm.db, nil
|
||||
}
|
||||
|
||||
// connect establishes a new database connection
|
||||
func (cm *ConnectionManager) connect() error {
|
||||
// Get DSN from local settings
|
||||
dsn, err := cm.localDB.GetDSN()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting DSN: %w", err)
|
||||
}
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Open database connection
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening database connection: %w", err)
|
||||
}
|
||||
|
||||
// Test the connection
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Ping with timeout
|
||||
if err = sqlDB.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("pinging database: %w", err)
|
||||
}
|
||||
|
||||
// Set connection pool settings
|
||||
sqlDB.SetMaxOpenConns(maxOpenConns)
|
||||
sqlDB.SetMaxIdleConns(maxIdleConns)
|
||||
sqlDB.SetConnMaxLifetime(connMaxLifetime)
|
||||
|
||||
// Store the connection
|
||||
cm.db = db
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsOnline checks if the database is currently connected and responsive
|
||||
// Does not attempt to reconnect, only checks current state with caching
|
||||
func (cm *ConnectionManager) IsOnline() bool {
|
||||
cm.mu.RLock()
|
||||
if cm.db == nil {
|
||||
cm.mu.RUnlock()
|
||||
return false
|
||||
}
|
||||
|
||||
// If we've checked recently, return cached result
|
||||
if time.Since(cm.lastCheck) < cm.pingInterval {
|
||||
cm.mu.RUnlock()
|
||||
return true
|
||||
}
|
||||
cm.mu.RUnlock()
|
||||
|
||||
// Need to perform actual ping
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if cm.db == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Perform ping with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), cm.connectTimeout)
|
||||
defer cancel()
|
||||
|
||||
sqlDB, err := cm.db.DB()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
cm.db = nil
|
||||
return false
|
||||
}
|
||||
|
||||
if err = sqlDB.PingContext(ctx); err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
cm.db = nil
|
||||
return false
|
||||
}
|
||||
|
||||
// Update last check time and return success
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return true
|
||||
}
|
||||
|
||||
// TryConnect forces a new connection attempt (for UI "Reconnect" button)
|
||||
// Ignores cooldown period
|
||||
func (cm *ConnectionManager) TryConnect() error {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
// Attempt to connect
|
||||
err := cm.connect()
|
||||
if err != nil {
|
||||
cm.lastError = err
|
||||
cm.lastCheck = time.Now()
|
||||
return err
|
||||
}
|
||||
|
||||
// Update last check time and clear error
|
||||
cm.lastCheck = time.Now()
|
||||
cm.lastError = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Disconnect closes the current database connection
|
||||
func (cm *ConnectionManager) Disconnect() {
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
if cm.db != nil {
|
||||
sqlDB, err := cm.db.DB()
|
||||
if err == nil {
|
||||
sqlDB.Close()
|
||||
}
|
||||
}
|
||||
cm.db = nil
|
||||
cm.lastError = nil
|
||||
}
|
||||
|
||||
// GetLastError returns the last connection error (thread-safe)
|
||||
func (cm *ConnectionManager) GetLastError() error {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
return cm.lastError
|
||||
}
|
||||
|
||||
// GetStatus returns the current connection status
|
||||
func (cm *ConnectionManager) GetStatus() ConnectionStatus {
|
||||
cm.mu.RLock()
|
||||
defer cm.mu.RUnlock()
|
||||
|
||||
status := ConnectionStatus{
|
||||
IsConnected: cm.db != nil,
|
||||
LastCheck: cm.lastCheck,
|
||||
LastError: "",
|
||||
DSNHost: "",
|
||||
}
|
||||
|
||||
if cm.lastError != nil {
|
||||
status.LastError = cm.lastError.Error()
|
||||
}
|
||||
|
||||
// Extract host from DSN for display
|
||||
if cm.localDB != nil {
|
||||
if dsn, err := cm.localDB.GetDSN(); err == nil {
|
||||
// Parse DSN to extract host:port
|
||||
// Format: user:password@tcp(host:port)/database?...
|
||||
status.DSNHost = extractHostFromDSN(dsn)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
// extractHostFromDSN extracts the host:port part from a DSN string
|
||||
func extractHostFromDSN(dsn string) string {
|
||||
// Find the tcp( part
|
||||
tcpStart := 0
|
||||
if tcpStart = len("tcp("); tcpStart < len(dsn) && dsn[tcpStart] == '(' {
|
||||
// Look for the closing parenthesis
|
||||
parenEnd := -1
|
||||
for i := tcpStart + 1; i < len(dsn); i++ {
|
||||
if dsn[i] == ')' {
|
||||
parenEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if parenEnd != -1 {
|
||||
// Extract host:port part between tcp( and )
|
||||
hostPort := dsn[tcpStart+1:parenEnd]
|
||||
return hostPort
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to find host:port by looking for @tcp( pattern
|
||||
atIndex := -1
|
||||
for i := 0; i < len(dsn)-4; i++ {
|
||||
if dsn[i:i+4] == "@tcp" {
|
||||
atIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if atIndex != -1 {
|
||||
// Look for the opening parenthesis after @tcp
|
||||
parenStart := -1
|
||||
for i := atIndex + 4; i < len(dsn); i++ {
|
||||
if dsn[i] == '(' {
|
||||
parenStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parenStart != -1 {
|
||||
// Look for the closing parenthesis
|
||||
parenEnd := -1
|
||||
for i := parenStart + 1; i < len(dsn); i++ {
|
||||
if dsn[i] == ')' {
|
||||
parenEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parenEnd != -1 {
|
||||
hostPort := dsn[parenStart+1:parenEnd]
|
||||
return hostPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we can't parse it, return empty string
|
||||
return ""
|
||||
}
|
||||
@@ -5,16 +5,21 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
)
|
||||
|
||||
type ComponentHandler struct {
|
||||
componentService *services.ComponentService
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewComponentHandler(componentService *services.ComponentService) *ComponentHandler {
|
||||
return &ComponentHandler{componentService: componentService}
|
||||
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler {
|
||||
return &ComponentHandler{
|
||||
componentService: componentService,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ComponentHandler) List(c *gin.Context) {
|
||||
@@ -34,6 +39,40 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// If offline mode (empty result), fallback to local components
|
||||
if result.Total == 0 && h.localDB != nil {
|
||||
localFilter := localdb.ComponentFilter{
|
||||
Category: filter.Category,
|
||||
Search: filter.Search,
|
||||
HasPrice: filter.HasPrice,
|
||||
}
|
||||
|
||||
offset := (page - 1) * perPage
|
||||
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
|
||||
if err == nil && len(localComps) > 0 {
|
||||
// Convert local components to ComponentView format
|
||||
components := make([]services.ComponentView, len(localComps))
|
||||
for i, lc := range localComps {
|
||||
components[i] = services.ComponentView{
|
||||
LotName: lc.LotName,
|
||||
Description: lc.LotDescription,
|
||||
Category: lc.Category,
|
||||
CategoryName: lc.Category, // No translation in local mode
|
||||
Model: lc.Model,
|
||||
CurrentPrice: lc.CurrentPrice,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &services.ComponentListResult{
|
||||
Components: components,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,36 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// If offline (empty list), fallback to local pricelists
|
||||
if total == 0 && h.localDB != nil {
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
if err == nil && len(localPLs) > 0 {
|
||||
// Convert to PricelistSummary format
|
||||
summaries := make([]map[string]interface{}, len(localPLs))
|
||||
for i, lpl := range localPLs {
|
||||
summaries[i] = map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked
|
||||
"usage_count": 0, // Not tracked in local
|
||||
"is_active": true,
|
||||
"created_at": lpl.CreatedAt,
|
||||
"synced_from": "local",
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": summaries,
|
||||
"total": len(summaries),
|
||||
"page": page,
|
||||
"per_page": perPage,
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pricelists": pricelists,
|
||||
"total": total,
|
||||
@@ -124,9 +154,33 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||
|
||||
// GetLatest returns the most recent active pricelist
|
||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
// Try to get from server first
|
||||
pl, err := h.service.GetLatestActive()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no active pricelists found"})
|
||||
// If offline or no server pricelists, try to get from local cache
|
||||
if h.localDB == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||
return
|
||||
}
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||
if localErr != nil {
|
||||
// No local pricelists either
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "no pricelists available",
|
||||
"local_error": localErr.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
// Return local pricelist
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked in local pricelists
|
||||
"is_active": true,
|
||||
"created_at": localPL.CreatedAt,
|
||||
"synced_from": "local",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,17 @@ func NewPricingHandler(
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetStats(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.statsRepo == nil || h.alertService == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"new_alerts_count": 0,
|
||||
"top_components": []interface{}{},
|
||||
"trending_components": []interface{}{},
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
newAlerts, _ := h.alertService.GetNewAlertsCount()
|
||||
topComponents, _ := h.statsRepo.GetTopComponents(10)
|
||||
trendingComponents, _ := h.statsRepo.GetTrendingComponents(10)
|
||||
@@ -86,6 +97,19 @@ type ComponentWithCount struct {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListComponents(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.componentRepo == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"components": []ComponentWithCount{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"offline": true,
|
||||
"message": "Управление ценами доступно только в онлайн режиме",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
@@ -213,6 +237,15 @@ func (h *PricingHandler) expandMetaPrices(metaPrices, excludeLot string) []strin
|
||||
}
|
||||
|
||||
func (h *PricingHandler) GetComponentPricing(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.componentRepo == nil || h.pricingService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление ценами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
lotName := c.Param("lot_name")
|
||||
|
||||
component, err := h.componentRepo.GetByLotName(lotName)
|
||||
@@ -248,6 +281,15 @@ type UpdatePriceRequest struct {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) UpdatePrice(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Обновление цен доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdatePriceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -409,6 +451,15 @@ func (h *PricingHandler) recalculateSinglePrice(lotName string) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Пересчёт цен доступен только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers for SSE
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -588,6 +639,18 @@ func (h *PricingHandler) RecalculateAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"alerts": []interface{}{},
|
||||
"total": 0,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
|
||||
@@ -613,6 +676,15 @@ func (h *PricingHandler) ListAlerts(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление алертами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
@@ -628,6 +700,15 @@ func (h *PricingHandler) AcknowledgeAlert(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление алертами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
@@ -643,6 +724,15 @@ func (h *PricingHandler) ResolveAlert(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) IgnoreAlert(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Управление алертами доступно только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid alert id"})
|
||||
@@ -667,6 +757,15 @@ type PreviewPriceRequest struct {
|
||||
}
|
||||
|
||||
func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
// Check if we're in offline mode
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "Предпросмотр цены доступен только в онлайн режиме",
|
||||
"offline": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req PreviewPriceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -708,8 +807,8 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
medianAllTime = &median
|
||||
}
|
||||
|
||||
// Get quote count (from all relevant lots)
|
||||
var quoteCount int64
|
||||
// Get quote count (from all relevant lots) - total count
|
||||
var quoteCountTotal int64
|
||||
for _, lotName := range lotNames {
|
||||
var count int64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
@@ -718,7 +817,25 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
} else {
|
||||
h.db.Model(&models.LotLog{}).Where("lot = ?", lotName).Count(&count)
|
||||
}
|
||||
quoteCount += count
|
||||
quoteCountTotal += count
|
||||
}
|
||||
|
||||
// Get quote count for specified period (if period is > 0)
|
||||
var quoteCountPeriod int64
|
||||
if req.PeriodDays > 0 {
|
||||
for _, lotName := range lotNames {
|
||||
var count int64
|
||||
if strings.HasSuffix(lotName, "*") {
|
||||
pattern := strings.TrimSuffix(lotName, "*") + "%"
|
||||
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot LIKE ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, pattern, req.PeriodDays).Scan(&count)
|
||||
} else {
|
||||
h.db.Raw(`SELECT COUNT(*) FROM lot_log WHERE lot = ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY)`, lotName, req.PeriodDays).Scan(&count)
|
||||
}
|
||||
quoteCountPeriod += count
|
||||
}
|
||||
} else {
|
||||
// If no period specified, period count equals total count
|
||||
quoteCountPeriod = quoteCountTotal
|
||||
}
|
||||
|
||||
// Get last received price (from the main lot only)
|
||||
@@ -773,14 +890,15 @@ func (h *PricingHandler) PreviewPrice(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"lot_name": req.LotName,
|
||||
"current_price": comp.CurrentPrice,
|
||||
"median_all_time": medianAllTime,
|
||||
"new_price": newPrice,
|
||||
"quote_count": quoteCount,
|
||||
"manual_price": comp.ManualPrice,
|
||||
"last_price": lastPrice.Price,
|
||||
"last_price_date": lastPrice.Date,
|
||||
"lot_name": req.LotName,
|
||||
"current_price": comp.CurrentPrice,
|
||||
"median_all_time": medianAllTime,
|
||||
"new_price": newPrice,
|
||||
"quote_count_total": quoteCountTotal,
|
||||
"quote_count_period": quoteCountPeriod,
|
||||
"manual_price": comp.ManualPrice,
|
||||
"last_price": lastPrice.Price,
|
||||
"last_price_date": lastPrice.Date,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@ package handlers
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
@@ -17,11 +19,12 @@ import (
|
||||
|
||||
type SetupHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
templates map[string]*template.Template
|
||||
restartSig chan struct{}
|
||||
}
|
||||
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
|
||||
funcMap := template.FuncMap{
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
@@ -39,6 +42,7 @@ func NewSetupHandler(localDB *localdb.LocalDB, templatesPath string, restartSig
|
||||
|
||||
return &SetupHandler{
|
||||
localDB: localDB,
|
||||
connMgr: connMgr,
|
||||
templates: templates,
|
||||
restartSig: restartSig,
|
||||
}, nil
|
||||
@@ -181,12 +185,23 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try to connect immediately to verify settings
|
||||
if h.connMgr != nil {
|
||||
if err := h.connMgr.TryConnect(); err != nil {
|
||||
slog.Warn("failed to connect after saving settings", "error", err)
|
||||
} else {
|
||||
slog.Info("successfully connected to database after saving settings")
|
||||
}
|
||||
}
|
||||
|
||||
// Always restart to properly initialize all services with the new connection
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Settings saved. Restarting application...",
|
||||
"message": "Settings saved. Please restart the application to apply changes.",
|
||||
"restart_required": true,
|
||||
})
|
||||
|
||||
// Signal restart after response is sent
|
||||
// Signal restart after response is sent (if restart signal is configured)
|
||||
if h.restartSig != nil {
|
||||
go func() {
|
||||
time.Sleep(500 * time.Millisecond) // Give time for response to be sent
|
||||
|
||||
@@ -8,21 +8,21 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SyncHandler handles sync API endpoints
|
||||
type SyncHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
syncService *sync.Service
|
||||
mariaDB *gorm.DB
|
||||
connMgr *db.ConnectionManager
|
||||
tmpl *template.Template
|
||||
}
|
||||
|
||||
// NewSyncHandler creates a new sync handler
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB *gorm.DB, templatesPath string) (*SyncHandler, error) {
|
||||
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string) (*SyncHandler, error) {
|
||||
// Load sync_status partial template
|
||||
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
|
||||
tmpl, err := template.ParseFiles(partialPath)
|
||||
@@ -33,7 +33,7 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, mariaDB
|
||||
return &SyncHandler{
|
||||
localDB: localDB,
|
||||
syncService: syncService,
|
||||
mariaDB: mariaDB,
|
||||
connMgr: connMgr,
|
||||
tmpl: tmpl,
|
||||
}, nil
|
||||
}
|
||||
@@ -109,7 +109,17 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.localDB.SyncComponents(h.mariaDB)
|
||||
// Get database connection from ConnectionManager
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database connection failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -181,7 +191,16 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
var componentsSynced, pricelistsSynced int
|
||||
|
||||
// Sync components
|
||||
compResult, err := h.localDB.SyncComponents(h.mariaDB)
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"success": false,
|
||||
"error": "Database connection failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
compResult, err := h.localDB.SyncComponents(mariaDB)
|
||||
if err != nil {
|
||||
slog.Error("component sync failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -215,16 +234,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
|
||||
// checkOnline checks if MariaDB is accessible
|
||||
func (h *SyncHandler) checkOnline() bool {
|
||||
sqlDB, err := h.mariaDB.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
return h.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// PushPendingChanges pushes all pending changes to the server
|
||||
|
||||
@@ -9,6 +9,13 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ComponentFilter for searching with filters
|
||||
type ComponentFilter struct {
|
||||
Category string
|
||||
Search string
|
||||
HasPrice bool
|
||||
}
|
||||
|
||||
// ComponentSyncResult contains statistics from component sync
|
||||
type ComponentSyncResult struct {
|
||||
TotalSynced int
|
||||
@@ -196,6 +203,44 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category string, query string,
|
||||
return components, err
|
||||
}
|
||||
|
||||
// ListComponents returns components with filtering and pagination
|
||||
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||
db := l.db
|
||||
|
||||
// Apply category filter
|
||||
if filter.Category != "" {
|
||||
db = db.Where("LOWER(category) = ?", strings.ToLower(filter.Category))
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if filter.Search != "" {
|
||||
searchPattern := "%" + strings.ToLower(filter.Search) + "%"
|
||||
db = db.Where(
|
||||
"LOWER(lot_name) LIKE ? OR LOWER(lot_description) LIKE ? OR LOWER(category) LIKE ? OR LOWER(model) LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern,
|
||||
)
|
||||
}
|
||||
|
||||
// Apply price filter
|
||||
if filter.HasPrice {
|
||||
db = db.Where("current_price IS NOT NULL")
|
||||
}
|
||||
|
||||
// Get total count
|
||||
var total int64
|
||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Apply pagination and get results
|
||||
var components []LocalComponent
|
||||
if err := db.Order("lot_name").Offset(offset).Limit(limit).Find(&components).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return components, total, nil
|
||||
}
|
||||
|
||||
// GetLocalComponent returns a single component by lot_name
|
||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||
var component LocalComponent
|
||||
@@ -266,3 +311,100 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
||||
}
|
||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||
}
|
||||
|
||||
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
||||
// This allows offline price updates using synced pricelists without MariaDB connection
|
||||
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
||||
// Get all items from the specified pricelist
|
||||
var items []LocalPricelistItem
|
||||
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
||||
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Update current_price for each component
|
||||
updated := 0
|
||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, item := range items {
|
||||
result := tx.Model(&LocalComponent{}).
|
||||
Where("lot_name = ?", item.LotName).
|
||||
Update("current_price", item.Price)
|
||||
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
||||
}
|
||||
|
||||
if result.RowsAffected > 0 {
|
||||
updated++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
slog.Info("updated component prices from pricelist",
|
||||
"pricelist_id", pricelistID,
|
||||
"total_items", len(items),
|
||||
"updated_components", updated)
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
||||
// if no components exist or all current prices are NULL
|
||||
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||
// Check if we have any components with prices
|
||||
var count int64
|
||||
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
||||
return fmt.Errorf("checking component prices: %w", err)
|
||||
}
|
||||
|
||||
// If we have components with prices, don't load from pricelists
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if we have any components at all
|
||||
var totalComponents int64
|
||||
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
||||
return fmt.Errorf("counting components: %w", err)
|
||||
}
|
||||
|
||||
// If we have no components, we need to load them from pricelists
|
||||
if totalComponents == 0 {
|
||||
slog.Info("no components found in local database, loading from latest pricelist")
|
||||
// This would typically be called from the sync service or setup process
|
||||
// For now, we'll just return nil to indicate no action needed
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have components but no prices, we should load prices from pricelists
|
||||
// Find the latest pricelist
|
||||
var latestPricelist LocalPricelist
|
||||
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
slog.Warn("no pricelists found in local database")
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("finding latest pricelist: %w", err)
|
||||
}
|
||||
|
||||
// Update prices from the latest pricelist
|
||||
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("loaded component prices from latest pricelist",
|
||||
"pricelist_id", latestPricelist.ID,
|
||||
"updated_components", updated)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -132,7 +132,11 @@ func (l *LocalDB) GetDSN() (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
// Add aggressive timeouts for offline-first architecture
|
||||
// timeout: connection establishment timeout (3s)
|
||||
// readTimeout: I/O read timeout (3s)
|
||||
// writeTimeout: I/O write timeout (3s)
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=3s&readTimeout=3s&writeTimeout=3s",
|
||||
settings.User,
|
||||
settings.PasswordEncrypted, // Contains decrypted password after GetSettings
|
||||
settings.Host,
|
||||
|
||||
@@ -4,17 +4,17 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OfflineDetector creates middleware that detects offline mode
|
||||
// Sets context values:
|
||||
// - "is_offline" (bool) - true if MariaDB is unavailable
|
||||
// - "localdb" (*localdb.LocalDB) - local database instance for fallback
|
||||
func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
func OfflineDetector(connMgr *db.ConnectionManager, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
isOffline := !checkMariaDBOnline(mariaDB)
|
||||
isOffline := !connMgr.IsOnline()
|
||||
|
||||
// Set context values for handlers
|
||||
c.Set("is_offline", isOffline)
|
||||
@@ -27,17 +27,3 @@ func OfflineDetector(mariaDB *gorm.DB, local *localdb.LocalDB) gin.HandlerFunc {
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// checkMariaDBOnline checks if MariaDB is accessible
|
||||
func checkMariaDBOnline(mariaDB *gorm.DB) bool {
|
||||
sqlDB, err := mariaDB.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
@@ -59,6 +60,17 @@ type ComponentView struct {
|
||||
}
|
||||
|
||||
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
// Components should be loaded via /api/sync/components first
|
||||
if s.componentRepo == nil {
|
||||
return &ComponentListResult{
|
||||
Components: []ComponentView{},
|
||||
Total: 0,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -106,6 +118,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil {
|
||||
return nil, fmt.Errorf("offline mode: component data not available")
|
||||
}
|
||||
|
||||
c, err := s.componentRepo.GetByLotName(lotName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -135,11 +152,20 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
||||
}
|
||||
|
||||
func (s *ComponentService) GetCategories() ([]models.Category, error) {
|
||||
// If no database connection (offline mode), return default categories
|
||||
if s.categoryRepo == nil {
|
||||
return models.DefaultCategories, nil
|
||||
}
|
||||
return s.categoryRepo.GetAll()
|
||||
}
|
||||
|
||||
// ImportFromLot creates metadata entries for lots that don't have them
|
||||
func (s *ComponentService) ImportFromLot() (int, error) {
|
||||
// If no database connection (offline mode), return error
|
||||
if s.componentRepo == nil || s.categoryRepo == nil {
|
||||
return 0, fmt.Errorf("offline mode: import not available")
|
||||
}
|
||||
|
||||
lots, err := s.componentRepo.GetLotsWithoutMetadata()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -26,6 +26,10 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
if s.repo == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||
}
|
||||
|
||||
version, err := s.repo.GenerateVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
@@ -88,6 +92,11 @@ func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist,
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -100,11 +109,17 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetByID(id)
|
||||
}
|
||||
|
||||
// GetItems returns pricelist items with pagination
|
||||
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistItem{}, 0, nil
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -117,26 +132,42 @@ func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) (
|
||||
|
||||
// Delete deletes a pricelist by ID
|
||||
func (s *Service) Delete(id uint) error {
|
||||
if s.repo == nil {
|
||||
return fmt.Errorf("offline mode: cannot delete pricelists")
|
||||
}
|
||||
return s.repo.Delete(id)
|
||||
}
|
||||
|
||||
// CanWrite returns true if the user can create pricelists
|
||||
func (s *Service) CanWrite() bool {
|
||||
if s.repo == nil {
|
||||
return false
|
||||
}
|
||||
return s.repo.CanWrite()
|
||||
}
|
||||
|
||||
// CanWriteDebug returns write permission status with debug info
|
||||
func (s *Service) CanWriteDebug() (bool, string) {
|
||||
if s.repo == nil {
|
||||
return false, "offline mode"
|
||||
}
|
||||
return s.repo.CanWriteDebug()
|
||||
}
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetLatestActive()
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
func (s *Service) CleanupExpired() (int, error) {
|
||||
if s.repo == nil {
|
||||
return 0, fmt.Errorf("offline mode: cleanup not available")
|
||||
}
|
||||
|
||||
expired, err := s.repo.GetExpiredUnused()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
@@ -13,17 +14,15 @@ import (
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
configRepo *repository.ConfigurationRepository
|
||||
localDB *localdb.LocalDB
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
func NewService(pricelistRepo *repository.PricelistRepository, configRepo *repository.ConfigurationRepository, localDB *localdb.LocalDB) *Service {
|
||||
func NewService(connMgr *db.ConnectionManager, localDB *localdb.LocalDB) *Service {
|
||||
return &Service{
|
||||
pricelistRepo: pricelistRepo,
|
||||
configRepo: configRepo,
|
||||
localDB: localDB,
|
||||
connMgr: connMgr,
|
||||
localDB: localDB,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +38,17 @@ type SyncStatus struct {
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
|
||||
// Count server pricelists
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("counting server pricelists: %w", err)
|
||||
// Count server pricelists (only if already connected, don't reconnect)
|
||||
serverCount := 0
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
if connStatus.IsConnected {
|
||||
if mariaDB, err := s.connMgr.GetDB(); err == nil && mariaDB != nil {
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
serverPricelists, _, err := pricelistRepo.List(0, 1)
|
||||
if err == nil {
|
||||
serverCount = len(serverPricelists)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count local pricelists
|
||||
@@ -52,7 +58,7 @@ func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
|
||||
return &SyncStatus{
|
||||
LastSyncAt: lastSync,
|
||||
ServerPricelists: len(serverPricelists),
|
||||
ServerPricelists: serverCount,
|
||||
LocalPricelists: int(localCount),
|
||||
NeedsSync: needsSync,
|
||||
}, nil
|
||||
@@ -73,8 +79,21 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Check if there are new pricelists on server
|
||||
latestServer, err := s.pricelistRepo.GetLatestActive()
|
||||
// Check if there are new pricelists on server (only if already connected)
|
||||
connStatus := s.connMgr.GetStatus()
|
||||
if !connStatus.IsConnected {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
// If offline, can't check server, no need to sync
|
||||
return false, nil
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
latestServer, err := pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
@@ -98,18 +117,29 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Info("starting pricelist sync")
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
// Get all active pricelists from server (up to 100)
|
||||
serverPricelists, _, err := s.pricelistRepo.List(0, 100)
|
||||
serverPricelists, _, err := pricelistRepo.List(0, 100)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelists: %w", err)
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var latestLocalID uint
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, skip
|
||||
// Already synced, track latest
|
||||
latestLocalID = existing.ID
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -128,8 +158,27 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Sync items for the newly created pricelist
|
||||
itemCount, err := s.SyncPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to sync pricelist items", "version", pl.Version, "error", err)
|
||||
// Continue even if items sync fails - we have the pricelist metadata
|
||||
} else {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
latestLocalID = localPL.ID
|
||||
synced++
|
||||
slog.Debug("synced pricelist", "version", pl.Version, "server_id", pl.ID)
|
||||
}
|
||||
|
||||
// Update component prices from latest pricelist
|
||||
if latestLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||
if err != nil {
|
||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
||||
} else {
|
||||
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
||||
}
|
||||
}
|
||||
|
||||
// Update last sync time
|
||||
@@ -154,8 +203,17 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
return int(existingCount), nil
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
|
||||
// Get items from server
|
||||
serverItems, _, err := s.pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
serverItems, _, err := pricelistRepo.GetItems(localPL.ServerID, 0, 10000, "")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting server pricelist items: %w", err)
|
||||
}
|
||||
@@ -312,8 +370,17 @@ func (s *Service) pushConfigurationCreate(change *localdb.PendingChange) error {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
|
||||
// Create on server
|
||||
if err := s.configRepo.Create(&cfg); err != nil {
|
||||
if err := configRepo.Create(&cfg); err != nil {
|
||||
return fmt.Errorf("creating configuration on server: %w", err)
|
||||
}
|
||||
|
||||
@@ -337,6 +404,15 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
return fmt.Errorf("unmarshaling configuration: %w", err)
|
||||
}
|
||||
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
|
||||
// Ensure we have a server ID before updating
|
||||
// If the payload doesn't have ID, get it from local configuration
|
||||
if cfg.ID == 0 {
|
||||
@@ -347,7 +423,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
|
||||
if localCfg.ServerID == nil {
|
||||
// Configuration hasn't been synced yet, try to find it on server by UUID
|
||||
serverCfg, err := s.configRepo.GetByUUID(cfg.UUID)
|
||||
serverCfg, err := configRepo.GetByUUID(cfg.UUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configuration not yet synced to server: %w", err)
|
||||
}
|
||||
@@ -363,7 +439,7 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Update on server
|
||||
if err := s.configRepo.Update(&cfg); err != nil {
|
||||
if err := configRepo.Update(&cfg); err != nil {
|
||||
return fmt.Errorf("updating configuration on server: %w", err)
|
||||
}
|
||||
|
||||
@@ -380,8 +456,17 @@ func (s *Service) pushConfigurationUpdate(change *localdb.PendingChange) error {
|
||||
|
||||
// pushConfigurationDelete deletes a configuration from the server
|
||||
func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
// Get database connection
|
||||
mariaDB, err := s.connMgr.GetDB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("database not available: %w", err)
|
||||
}
|
||||
|
||||
// Create repository
|
||||
configRepo := repository.NewConfigurationRepository(mariaDB)
|
||||
|
||||
// Get the configuration from server by UUID to get the ID
|
||||
cfg, err := s.configRepo.GetByUUID(change.EntityUUID)
|
||||
cfg, err := configRepo.GetByUUID(change.EntityUUID)
|
||||
if err != nil {
|
||||
// Already deleted or not found, consider it successful
|
||||
slog.Warn("configuration not found on server, considering delete successful", "uuid", change.EntityUUID)
|
||||
@@ -389,7 +474,7 @@ func (s *Service) pushConfigurationDelete(change *localdb.PendingChange) error {
|
||||
}
|
||||
|
||||
// Delete from server
|
||||
if err := s.configRepo.Delete(cfg.ID); err != nil {
|
||||
if err := configRepo.Delete(cfg.ID); err != nil {
|
||||
return fmt.Errorf("deleting configuration from server: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,23 +5,23 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/db"
|
||||
)
|
||||
|
||||
// Worker performs background synchronization at regular intervals
|
||||
type Worker struct {
|
||||
service *Service
|
||||
db *gorm.DB
|
||||
connMgr *db.ConnectionManager
|
||||
interval time.Duration
|
||||
logger *slog.Logger
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewWorker creates a new background sync worker
|
||||
func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
||||
func NewWorker(service *Service, connMgr *db.ConnectionManager, interval time.Duration) *Worker {
|
||||
return &Worker{
|
||||
service: service,
|
||||
db: db,
|
||||
connMgr: connMgr,
|
||||
interval: interval,
|
||||
logger: slog.Default(),
|
||||
stopCh: make(chan struct{}),
|
||||
@@ -30,11 +30,7 @@ func NewWorker(service *Service, db *gorm.DB, interval time.Duration) *Worker {
|
||||
|
||||
// isOnline checks if the database connection is available
|
||||
func (w *Worker) isOnline() bool {
|
||||
sqlDB, err := w.db.DB()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sqlDB.Ping() == nil
|
||||
return w.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
// Start begins the background sync loop in a goroutine
|
||||
|
||||
100
scripts/release.sh
Executable file
100
scripts/release.sh
Executable file
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# QuoteForge Release Build Script
|
||||
# Creates binaries for all platforms and packages them for release
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get version from git
|
||||
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
if [[ $VERSION == *"dirty"* ]]; then
|
||||
echo -e "${RED}✗ Error: Working directory has uncommitted changes${NC}"
|
||||
echo " Commit your changes first"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}Building QuoteForge version: ${VERSION}${NC}"
|
||||
echo ""
|
||||
|
||||
# Create release directory
|
||||
RELEASE_DIR="releases/${VERSION}"
|
||||
mkdir -p "${RELEASE_DIR}"
|
||||
|
||||
# Build for all platforms
|
||||
echo -e "${YELLOW}→ Building binaries...${NC}"
|
||||
make build-all
|
||||
|
||||
# Package binaries with checksums
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Creating release packages...${NC}"
|
||||
|
||||
# Linux AMD64
|
||||
if [ -f "bin/qfs-linux-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-linux-amd64.tar.gz" qfs-linux-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-linux-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Intel
|
||||
if [ -f "bin/qfs-darwin-amd64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-amd64.tar.gz" qfs-darwin-amd64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-amd64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# macOS Apple Silicon
|
||||
if [ -f "bin/qfs-darwin-arm64" ]; then
|
||||
cd bin
|
||||
tar -czf "../${RELEASE_DIR}/qfs-${VERSION}-darwin-arm64.tar.gz" qfs-darwin-arm64
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-darwin-arm64.tar.gz${NC}"
|
||||
fi
|
||||
|
||||
# Windows AMD64
|
||||
if [ -f "bin/qfs-windows-amd64.exe" ]; then
|
||||
cd bin
|
||||
zip -q "../${RELEASE_DIR}/qfs-${VERSION}-windows-amd64.zip" qfs-windows-amd64.exe
|
||||
cd ..
|
||||
echo -e "${GREEN} ✓ qfs-${VERSION}-windows-amd64.zip${NC}"
|
||||
fi
|
||||
|
||||
# Generate checksums
|
||||
echo ""
|
||||
echo -e "${YELLOW}→ Generating checksums...${NC}"
|
||||
cd "${RELEASE_DIR}"
|
||||
shasum -a 256 *.tar.gz *.zip > SHA256SUMS.txt 2>/dev/null || shasum -a 256 * | grep -v SHA256SUMS > SHA256SUMS.txt
|
||||
cd ../..
|
||||
echo -e "${GREEN} ✓ SHA256SUMS.txt${NC}"
|
||||
|
||||
# List release files
|
||||
echo ""
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN}Release ${VERSION} built successfully!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo "Files in ${RELEASE_DIR}:"
|
||||
ls -lh "${RELEASE_DIR}"
|
||||
echo ""
|
||||
|
||||
# Show next steps
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo " 1. Create git tag:"
|
||||
echo " git tag -a ${VERSION} -m \"Release ${VERSION}\""
|
||||
echo ""
|
||||
echo " 2. Push tag to remote:"
|
||||
echo " git push origin ${VERSION}"
|
||||
echo ""
|
||||
echo " 3. Create release on git.mchus.pro:"
|
||||
echo " - Go to: https://git.mchus.pro/mchus/QuoteForge/releases"
|
||||
echo " - Click 'New Release'"
|
||||
echo " - Select tag: ${VERSION}"
|
||||
echo " - Upload files from: ${RELEASE_DIR}/"
|
||||
echo ""
|
||||
echo -e "${GREEN}Done!${NC}"
|
||||
@@ -591,8 +591,21 @@ async function fetchPreview() {
|
||||
document.getElementById('modal-new-price').textContent =
|
||||
data.new_price ? '$' + parseFloat(data.new_price).toFixed(2) : '—';
|
||||
|
||||
// Update quote count
|
||||
document.getElementById('modal-quote-count').textContent = data.quote_count || 0;
|
||||
// Update quote count with new format "N (всего: M)"
|
||||
let quoteCountText = '';
|
||||
if (data.quote_count_period !== undefined && data.quote_count_total !== undefined) {
|
||||
if (data.quote_count_period === data.quote_count_total) {
|
||||
// If period count equals total count, just show the total
|
||||
quoteCountText = data.quote_count_total;
|
||||
} else {
|
||||
// Show both counts in format "N (всего: M)"
|
||||
quoteCountText = data.quote_count_period + ' (всего: ' + data.quote_count_total + ')';
|
||||
}
|
||||
} else {
|
||||
// Fallback for older API responses
|
||||
quoteCountText = data.quote_count || 0;
|
||||
}
|
||||
document.getElementById('modal-quote-count').textContent = quoteCountText;
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Preview fetch error:', e);
|
||||
@@ -1019,7 +1032,23 @@ function closePricelistsCreateModal() {
|
||||
document.getElementById('pricelists-create-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
async function checkOnlineStatus() {
|
||||
try {
|
||||
const resp = await fetch('/api/db-status');
|
||||
const data = await resp.json();
|
||||
return data.connected === true;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPricelist() {
|
||||
// Check if online before creating
|
||||
const isOnline = await checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
throw new Error('Создание прайслистов доступно только в онлайн режиме');
|
||||
}
|
||||
|
||||
const resp = await fetch('/api/pricelists', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -1037,6 +1066,13 @@ async function createPricelist() {
|
||||
}
|
||||
|
||||
async function deletePricelist(id) {
|
||||
// Check if online before deleting
|
||||
const isOnline = await checkOnlineStatus();
|
||||
if (!isOnline) {
|
||||
showToast('Удаление прайслистов доступно только в онлайн режиме', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Удалить этот прайслист?')) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<a href="/" class="text-xl font-bold text-blue-600">QuoteForge</a>
|
||||
<div class="hidden md:flex space-x-4">
|
||||
<a href="/configurator" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Конфигуратор</a>
|
||||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm hidden">Администратор цен</a>
|
||||
<a id="admin-pricing-link" href="/admin/pricing" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Администратор цен</a>
|
||||
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,17 +237,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Admin pricing link is now always visible
|
||||
// Write permission is checked at operation time (create/delete)
|
||||
async function checkWritePermission() {
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists/can-write');
|
||||
const data = await resp.json();
|
||||
if (data.can_write) {
|
||||
const link = document.getElementById('admin-pricing-link');
|
||||
if (link) link.classList.remove('hidden');
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to check write permission:', e);
|
||||
}
|
||||
// No longer needed - link always visible in offline-first mode
|
||||
// Operations will check online status when executed
|
||||
}
|
||||
|
||||
// Load last sync time for dropdown (removed since dropdown is gone)
|
||||
|
||||
@@ -421,18 +421,23 @@ async function loadLatestPricelistVersion() {
|
||||
const pricelist = await resp.json();
|
||||
document.getElementById('pricelist-version').textContent = pricelist.version;
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
} else if (resp.status === 404) {
|
||||
// No active pricelist (normal in offline mode or when not synced)
|
||||
document.getElementById('pricelist-version').textContent = 'Не загружен';
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
|
||||
} else {
|
||||
// Show error in badge
|
||||
// Real error
|
||||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||||
}
|
||||
} catch(e) {
|
||||
// Show error in badge
|
||||
// Network error or other exception
|
||||
console.error('Failed to load pricelist version:', e);
|
||||
document.getElementById('pricelist-version').textContent = 'Ошибка загрузки';
|
||||
document.getElementById('pricelist-version').textContent = 'Не доступен';
|
||||
document.getElementById('pricelist-badge').classList.remove('hidden');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-red-100', 'text-red-800');
|
||||
document.getElementById('pricelist-badge').classList.add('bg-gray-100', 'text-gray-600');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -87,12 +87,14 @@
|
||||
<script>
|
||||
function showStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800');
|
||||
status.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'bg-red-100', 'text-red-800', 'bg-blue-100', 'text-blue-800', 'bg-yellow-100', 'text-yellow-800');
|
||||
|
||||
if (type === 'success') {
|
||||
status.classList.add('bg-green-100', 'text-green-800');
|
||||
} else if (type === 'error') {
|
||||
status.classList.add('bg-red-100', 'text-red-800');
|
||||
} else if (type === 'warning') {
|
||||
status.classList.add('bg-yellow-100', 'text-yellow-800');
|
||||
} else {
|
||||
status.classList.add('bg-blue-100', 'text-blue-800');
|
||||
}
|
||||
@@ -171,12 +173,21 @@
|
||||
|
||||
if (data.success) {
|
||||
showStatus('✓ ' + data.message, 'success');
|
||||
// Wait for restart and redirect to home
|
||||
setTimeout(() => {
|
||||
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||
// Poll until server is back
|
||||
checkServerReady();
|
||||
}, 2000);
|
||||
|
||||
// Check if restart is required
|
||||
if (data.restart_required) {
|
||||
// In normal mode, restart must be done manually
|
||||
setTimeout(() => {
|
||||
showStatus('⚠️ Пожалуйста, перезапустите приложение вручную для применения изменений', 'warning');
|
||||
}, 2000);
|
||||
} else {
|
||||
// In setup mode, auto-restart is happening
|
||||
setTimeout(() => {
|
||||
showStatus('✓ Настройки сохранены. Проверка подключения...', 'success');
|
||||
// Poll until server is back
|
||||
checkServerReady();
|
||||
}, 2000);
|
||||
}
|
||||
} else {
|
||||
showStatus(data.error, 'error');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user