This commit is contained in:
2026-06-14 00:55:27 +02:00
parent 08162fa126
commit 67203cd4a8
16 changed files with 897 additions and 212 deletions
+102 -60
View File
@@ -369,6 +369,9 @@ type App struct {
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
startupErr string // captured for surfacing to the frontend
dbPath string // active database file (may be a user-chosen location)
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
migratedFromAppData bool // true when we auto-copied AppData on first portable launch
@@ -540,6 +543,11 @@ func (a *App) startup(ctx context.Context) {
// to a separate cat.log in the old HamLog folder, which users couldn't find).
cat.LogSink = applog.Printf
applog.Printf("startup: data dir = %s", dataDir)
// The local SQLite file ALWAYS holds per-operator configuration — settings,
// station profiles, rigs/antennas, cluster nodes, UDP, QSL templates, award
// lists, the lookup cache. Only the QSO logbook itself may live on a shared
// MySQL server (the multi-operator feature). Keeping config local means it
// stays instant even when the shared logbook is on a far-away MySQL.
conn, err := db.Open(a.dbPath)
if err != nil {
a.startupErr = "cannot open db: " + err.Error()
@@ -547,7 +555,32 @@ func (a *App) startup(ctx context.Context) {
return
}
a.db = conn
a.qso = qso.NewRepo(conn)
// Choose where the QSO logbook lives. On a MySQL failure we fall back to the
// local SQLite logbook so the operator can still log (and fix the config).
logbookConn := conn
if mb := readBootstrap(dataDir).MySQL; mb != nil && mb.Enabled {
applog.Printf("startup: logbook backend = MySQL (%s:%d/%s)", mb.Host, mb.Port, mb.Database)
mysqlConn, mErr := db.OpenMySQL(db.MySQLConfig{
Host: mb.Host, Port: mb.Port, User: mb.User, Password: mb.Password, Database: mb.Database,
})
if mErr != nil {
applog.Printf("startup: MySQL open failed (%v) — falling back to SQLite logbook", mErr)
a.dbBackendErr = "MySQL: " + mErr.Error()
} else {
logbookConn = mysqlConn
a.dbBackend = "mysql"
}
}
if a.dbBackend == "" {
a.dbBackend = "sqlite"
}
// db.Dialect describes the LOGBOOK backend — the only place SQL actually
// varies (qso JSON extraction). Config repos always run on SQLite.
db.SetDialect(a.dbBackend)
applog.Printf("startup: logbook backend = %s", a.dbBackend)
a.logDb = logbookConn
a.qso = qso.NewRepo(logbookConn)
a.settings = settings.NewStore(conn)
a.settings.SetSensitivePredicate(isSensitiveSetting) // encrypt passwords at rest when a passphrase is set
a.profiles = profile.NewRepo(conn)
@@ -882,6 +915,9 @@ func (a *App) shutdown(ctx context.Context) {
if a.qsoRec != nil {
a.qsoRec.Stop()
}
if a.logDb != nil && a.logDb != a.db {
_ = a.logDb.Close() // shared MySQL logbook (separate from the local config DB)
}
if a.db != nil {
_ = a.db.Close()
}
@@ -981,32 +1017,49 @@ func copyFileData(src, dst string) error {
// ── Database location (config.json pointer) ────────────────────────────
// dbPointer is the tiny bootstrap config stored in the data dir. It must
// live outside the database because we read it to decide which DB to open.
// live outside the database because we read it BEFORE opening any DB to decide
// which backend to use: a local SQLite file (DBPath) or a shared MySQL server
// (MySQL). The MySQL connection lives here — not in the settings table — for
// the same reason: we need it to choose and open the backend at startup.
type dbPointer struct {
DBPath string `json:"db_path"`
DBPath string `json:"db_path"`
MySQL *MySQLSettings `json:"mysql,omitempty"`
}
func dbPointerPath(dataDir string) string { return filepath.Join(dataDir, "config.json") }
// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
// readBootstrap returns the full bootstrap config (DB path + MySQL), or a zero
// value if the file is missing/unreadable.
func readBootstrap(dataDir string) dbPointer {
var c dbPointer
b, err := os.ReadFile(dbPointerPath(dataDir))
if err != nil {
return ""
return c
}
var c dbPointer
if json.Unmarshal(b, &c) != nil {
return ""
}
return strings.TrimSpace(c.DBPath)
_ = json.Unmarshal(b, &c)
c.DBPath = strings.TrimSpace(c.DBPath)
return c
}
// writeDBPointer persists the chosen DB path ("" resets to default).
func writeDBPointer(dataDir, path string) error {
b, _ := json.MarshalIndent(dbPointer{DBPath: strings.TrimSpace(path)}, "", " ")
func writeBootstrap(dataDir string, c dbPointer) error {
c.DBPath = strings.TrimSpace(c.DBPath)
b, _ := json.MarshalIndent(c, "", " ")
return os.WriteFile(dbPointerPath(dataDir), b, 0o644)
}
// readDBPointer returns the user-chosen DB path, or "" for the default.
func readDBPointer(dataDir string) string {
return readBootstrap(dataDir).DBPath
}
// writeDBPointer persists the chosen DB path ("" resets to default), keeping
// any saved MySQL config intact.
func writeDBPointer(dataDir, path string) error {
c := readBootstrap(dataDir)
c.DBPath = strings.TrimSpace(path)
return writeBootstrap(dataDir, c)
}
// DatabaseSettings describes the active database file for the Settings UI.
type DatabaseSettings struct {
Path string `json:"path"`
@@ -1032,62 +1085,51 @@ type MySQLSettings struct {
Database string `json:"database"`
}
const (
keyMySQLEnabled = "mysql.enabled"
keyMySQLHost = "mysql.host"
keyMySQLPort = "mysql.port"
keyMySQLUser = "mysql.user"
keyMySQLPassword = "mysql.password"
keyMySQLDatabase = "mysql.database"
)
// DBBackendStatus reports which backend OpsLog actually opened at startup so
// the Settings UI can confirm the shared MySQL connection (or explain a
// fallback to SQLite when the configured server was unreachable).
type DBBackendStatus struct {
Active string `json:"active"` // "sqlite" | "mysql"
Fallback bool `json:"fallback"` // MySQL was enabled but failed, so we used SQLite
Error string `json:"error"` // the MySQL open error, when Fallback is true
}
// GetMySQLSettings returns the stored shared-database config (defaults applied).
// GetDBBackendStatus returns the active backend and any MySQL fallback error.
func (a *App) GetDBBackendStatus() DBBackendStatus {
return DBBackendStatus{
Active: a.dbBackend,
Fallback: a.dbBackendErr != "",
Error: a.dbBackendErr,
}
}
// GetMySQLSettings returns the stored shared-database config from the bootstrap
// file (config.json), with defaults applied. Read before the DB is open, so it
// must not depend on the settings table.
func (a *App) GetMySQLSettings() (MySQLSettings, error) {
out := MySQLSettings{Port: 3306}
if a.settings == nil {
return out, nil
if mb := readBootstrap(a.dataDir).MySQL; mb != nil {
out = *mb
if out.Port <= 0 {
out.Port = 3306
}
}
m, err := a.settings.GetMany(a.ctx, keyMySQLEnabled, keyMySQLHost, keyMySQLPort, keyMySQLUser, keyMySQLPassword, keyMySQLDatabase)
if err != nil {
return out, err
}
out.Enabled = m[keyMySQLEnabled] == "1"
out.Host = m[keyMySQLHost]
if p, _ := strconv.Atoi(m[keyMySQLPort]); p > 0 {
out.Port = p
}
out.User = m[keyMySQLUser]
out.Password = m[keyMySQLPassword]
out.Database = m[keyMySQLDatabase]
return out, nil
}
// SaveMySQLSettings persists the shared-database config. (Switching the active
// backend takes effect on restart — wired in a later phase.)
// SaveMySQLSettings persists the shared-database config to the bootstrap file.
// Switching the active backend takes effect on the next launch (we read this
// file before opening any database).
func (a *App) SaveMySQLSettings(s MySQLSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
if s.Port <= 0 {
s.Port = 3306
}
enabled := "0"
if s.Enabled {
enabled = "1"
}
for k, v := range map[string]string{
keyMySQLEnabled: enabled,
keyMySQLHost: strings.TrimSpace(s.Host),
keyMySQLPort: strconv.Itoa(s.Port),
keyMySQLUser: strings.TrimSpace(s.User),
keyMySQLPassword: s.Password,
keyMySQLDatabase: strings.TrimSpace(s.Database),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
}
}
return nil
s.Host = strings.TrimSpace(s.Host)
s.User = strings.TrimSpace(s.User)
s.Database = strings.TrimSpace(s.Database)
c := readBootstrap(a.dataDir)
c.MySQL = &s
return writeBootstrap(a.dataDir, c)
}
// TestMySQLConnection pings the shared MySQL database with the given settings
@@ -4878,7 +4920,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// Ids already QRZ-confirmed locally → "ALREADY CONFIRMED" vs "UPDATED",
// without a per-record DB read.
alreadyQrz := map[int64]bool{}
if rs, e := a.db.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
if rs, e := a.logDb.QueryContext(ctx, `SELECT id FROM qso WHERE qrzcom_qso_download_status = 'Y'`); e == nil {
for rs.Next() {
var id int64
if rs.Scan(&id) == nil {