up
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user