fix(qfs): project ui, config naming, sync timestamps - v1.5.4

This commit is contained in:
Mikhail Chusavitin
2026-03-16 08:32:15 +03:00
parent c599897142
commit 35c5600b36
16 changed files with 815 additions and 93 deletions

View File

@@ -0,0 +1,173 @@
package main
import (
"flag"
"fmt"
"log"
"sort"
"time"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
type projectTimestampRow struct {
UUID string
UpdatedAt time.Time
}
type updatePlanRow struct {
UUID string
Code string
Variant string
LocalUpdatedAt time.Time
ServerUpdatedAt time.Time
}
func main() {
defaultLocalDBPath, err := appstate.ResolveDBPath("")
if err != nil {
log.Fatalf("failed to resolve default local SQLite path: %v", err)
}
localDBPath := flag.String("localdb", defaultLocalDBPath, "path to local SQLite database (default: user state dir or QFS_DB_PATH)")
apply := flag.Bool("apply", false, "apply updates to local SQLite (default is preview only)")
flag.Parse()
local, err := localdb.New(*localDBPath)
if err != nil {
log.Fatalf("failed to initialize local database: %v", err)
}
defer local.Close()
if !local.HasSettings() {
log.Fatalf("SQLite connection settings are not configured. Run qfs setup first.")
}
dsn, err := local.GetDSN()
if err != nil {
log.Fatalf("failed to build DSN from SQLite settings: %v", err)
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
log.Fatalf("failed to connect to MariaDB: %v", err)
}
serverRows, err := loadServerProjects(db)
if err != nil {
log.Fatalf("failed to load server projects: %v", err)
}
localProjects, err := local.GetAllProjects(true)
if err != nil {
log.Fatalf("failed to load local projects: %v", err)
}
plan := buildUpdatePlan(localProjects, serverRows)
printPlan(plan, *apply)
if !*apply || len(plan) == 0 {
return
}
updated := 0
for i := range plan {
project, err := local.GetProjectByUUID(plan[i].UUID)
if err != nil {
log.Printf("skip %s: load local project: %v", plan[i].UUID, err)
continue
}
project.UpdatedAt = plan[i].ServerUpdatedAt
if err := local.SaveProjectPreservingUpdatedAt(project); err != nil {
log.Printf("skip %s: save local project: %v", plan[i].UUID, err)
continue
}
updated++
}
log.Printf("updated %d local project timestamps", updated)
}
func loadServerProjects(db *gorm.DB) (map[string]time.Time, error) {
var rows []projectTimestampRow
if err := db.Model(&models.Project{}).
Select("uuid, updated_at").
Find(&rows).Error; err != nil {
return nil, err
}
out := make(map[string]time.Time, len(rows))
for _, row := range rows {
if row.UUID == "" {
continue
}
out[row.UUID] = row.UpdatedAt
}
return out, nil
}
func buildUpdatePlan(localProjects []localdb.LocalProject, serverRows map[string]time.Time) []updatePlanRow {
plan := make([]updatePlanRow, 0)
for i := range localProjects {
project := localProjects[i]
serverUpdatedAt, ok := serverRows[project.UUID]
if !ok {
continue
}
if project.UpdatedAt.Equal(serverUpdatedAt) {
continue
}
plan = append(plan, updatePlanRow{
UUID: project.UUID,
Code: project.Code,
Variant: project.Variant,
LocalUpdatedAt: project.UpdatedAt,
ServerUpdatedAt: serverUpdatedAt,
})
}
sort.Slice(plan, func(i, j int) bool {
if plan[i].Code != plan[j].Code {
return plan[i].Code < plan[j].Code
}
return plan[i].Variant < plan[j].Variant
})
return plan
}
func printPlan(plan []updatePlanRow, apply bool) {
mode := "preview"
if apply {
mode = "apply"
}
log.Printf("project updated_at resync mode=%s changes=%d", mode, len(plan))
if len(plan) == 0 {
log.Printf("no local project timestamps need resync")
return
}
for _, row := range plan {
variant := row.Variant
if variant == "" {
variant = "main"
}
log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt))
}
if !apply {
fmt.Println("Re-run with -apply to write server updated_at into local SQLite.")
}
}
func formatStamp(value time.Time) string {
if value.IsZero() {
return "zero"
}
return value.Format(time.RFC3339)
}

View File

@@ -39,6 +39,10 @@ logging:
t.Fatalf("load legacy config: %v", err)
}
setConfigDefaults(cfg)
cfg.Server.Host, _, err = normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
t.Fatalf("normalize server host: %v", err)
}
if err := migrateConfigFileToRuntimeShape(path, cfg); err != nil {
t.Fatalf("migrate config: %v", err)
}
@@ -60,32 +64,43 @@ logging:
if !strings.Contains(text, "port: 9191") {
t.Fatalf("migrated config did not preserve server port:\n%s", text)
}
if !strings.Contains(text, "host: 127.0.0.1") {
t.Fatalf("migrated config did not normalize server host:\n%s", text)
}
if !strings.Contains(text, "level: debug") {
t.Fatalf("migrated config did not preserve logging level:\n%s", text)
}
}
func TestEnsureLoopbackServerHost(t *testing.T) {
func TestNormalizeLoopbackServerHost(t *testing.T) {
t.Parallel()
cases := []struct {
host string
wantErr bool
host string
want string
wantChanged bool
wantErr bool
}{
{host: "127.0.0.1", wantErr: false},
{host: "localhost", wantErr: false},
{host: "::1", wantErr: false},
{host: "0.0.0.0", wantErr: true},
{host: "192.168.1.10", wantErr: true},
{host: "127.0.0.1", want: "127.0.0.1", wantChanged: false, wantErr: false},
{host: "localhost", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "::1", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "0.0.0.0", want: "127.0.0.1", wantChanged: true, wantErr: false},
{host: "192.168.1.10", want: "127.0.0.1", wantChanged: true, wantErr: false},
}
for _, tc := range cases {
err := ensureLoopbackServerHost(tc.host)
got, changed, err := normalizeLoopbackServerHost(tc.host)
if tc.wantErr && err == nil {
t.Fatalf("expected error for host %q", tc.host)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
}
if got != tc.want {
t.Fatalf("unexpected normalized host for %q: got %q want %q", tc.host, got, tc.want)
}
if changed != tc.wantChanged {
t.Fatalf("unexpected changed flag for %q: got %t want %t", tc.host, changed, tc.wantChanged)
}
}
}

View File

@@ -148,10 +148,15 @@ func main() {
}
}
setConfigDefaults(cfg)
if err := ensureLoopbackServerHost(cfg.Server.Host); err != nil {
normalizedHost, changed, err := normalizeLoopbackServerHost(cfg.Server.Host)
if err != nil {
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
os.Exit(1)
}
if changed {
slog.Warn("corrected server host to loopback", "from", cfg.Server.Host, "to", normalizedHost)
}
cfg.Server.Host = normalizedHost
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1)
@@ -334,21 +339,28 @@ func setConfigDefaults(cfg *config.Config) {
}
}
func ensureLoopbackServerHost(host string) error {
func normalizeLoopbackServerHost(host string) (string, bool, error) {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return fmt.Errorf("server.host must not be empty")
return "", false, fmt.Errorf("server.host must not be empty")
}
const loopbackHost = "127.0.0.1"
if trimmed == loopbackHost {
return loopbackHost, false, nil
}
if strings.EqualFold(trimmed, "localhost") {
return nil
return loopbackHost, true, nil
}
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
if ip != nil && ip.IsLoopback() {
return nil
if ip != nil {
if ip.IsLoopback() || ip.IsUnspecified() {
return loopbackHost, trimmed != loopbackHost, nil
}
return loopbackHost, true, nil
}
return fmt.Errorf("QuoteForge local client must bind to localhost only")
return loopbackHost, true, nil
}
func vendorImportBodyLimit() int64 {
@@ -1490,6 +1502,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Create(dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err)
default:
@@ -1525,6 +1539,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil {
switch {
case errors.Is(err, services.ErrReservedMainVariant):
respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectCodeExists):
respondError(c, http.StatusConflict, "conflict detected", err)
case errors.Is(err, services.ErrProjectNotFound):