From e3e5d743387c319fded149b002bd7a35ebcb42ff Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Thu, 19 Feb 2026 10:00:17 +0100 Subject: [PATCH] Implement changelog --- admin.go | 123 +++++++++++++++++++- bmdevice.go | 33 ++++++ db.go | 141 +++++++++++++++++++++++ main.go | 1 + migrations/005_changelog.sql | 19 ++++ static/admin.html | 210 ++++++++++++++++++++++++++++++++++- 6 files changed, 523 insertions(+), 4 deletions(-) create mode 100644 migrations/005_changelog.sql diff --git a/admin.go b/admin.go index 8a4ccc4..e951a66 100644 --- a/admin.go +++ b/admin.go @@ -54,11 +54,32 @@ func handleAdminUpdateRepeater(db *sql.DB) http.HandlerFunc { return } + oldRpt, fetchErr := queryRepeaterByID(db, rpt.ID) + if fetchErr != nil { + log.Printf("changelog: could not fetch old repeater %d for diff: %v", rpt.ID, fetchErr) + } + if err := updateRepeater(db, rpt); err != nil { http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) return } + var oldVals, newVals map[string]interface{} + if oldRpt != nil { + oldVals, newVals = diffRepeater(*oldRpt, rpt) + } + if oldVals != nil || oldRpt == nil { + insertChangelog(db, ChangelogEntry{ + RepeaterID: rpt.ID, + Callsign: rpt.Callsign, + Source: "admin", + Action: "update", + Description: "Admin updated repeater", + OldValues: oldVals, + NewValues: newVals, + }) + } + w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true}`)) } @@ -229,10 +250,35 @@ func handleAdminSaveBMData(db *sql.DB) http.HandlerFunc { return } + insertChangelog(db, ChangelogEntry{ + RepeaterID: payload.ID, + Source: "admin", + Action: "update_bm_data", + Description: "Admin saved BrandMeister device data", + NewValues: map[string]interface{}{ + "last_seen": payload.LastSeen, + "bm_status": payload.BmStatus, + "bm_status_text": payload.BmStatusText, + "hardware": payload.Hardware, + "firmware": payload.Firmware, + "pep": payload.Pep, + "agl": payload.Agl, + "website": payload.Website, + "description": payload.Description, + }, + }) + // Add Brandmeister tag if valid master server if payload.LastKnownMaster != 0 && payload.LastKnownMaster != 9999 { - db.Exec(`UPDATE repeaters SET networks = array_append(networks, 'Brandmeister') - WHERE id = $1 AND NOT networks @> ARRAY['Brandmeister']`, payload.ID) + if _, tagErr := db.Exec(`UPDATE repeaters SET networks = array_append(networks, 'Brandmeister') + WHERE id = $1 AND NOT networks @> ARRAY['Brandmeister']`, payload.ID); tagErr == nil { + insertChangelog(db, ChangelogEntry{ + RepeaterID: payload.ID, + Source: "admin", + Action: "add_bm_tag", + Description: fmt.Sprintf("Added Brandmeister network tag (master=%d)", payload.LastKnownMaster), + }) + } } w.Header().Set("Content-Type", "application/json") @@ -265,11 +311,84 @@ func handleAdminRemoveBMTag(db *sql.DB) http.HandlerFunc { return } + insertChangelog(db, ChangelogEntry{ + RepeaterID: payload.ID, + Source: "admin", + Action: "remove_bm_tag", + Description: "Admin removed Brandmeister network tag", + }) + w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"ok":true}`)) } } +func handleAdminChangelog(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + + idStr := r.URL.Query().Get("id") + if idStr == "" { + http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) + return + } + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + + rows, err := db.Query( + `SELECT created_at, source, action, description, old_values, new_values + FROM changelog WHERE repeater_id = $1 + ORDER BY created_at DESC LIMIT 100`, id) + if err != nil { + log.Printf("Admin changelog: query failed for %d: %v", id, err) + http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError) + return + } + defer rows.Close() + + type entry struct { + CreatedAt string `json:"created_at"` + Source string `json:"source"` + Action string `json:"action"` + Description string `json:"description"` + OldValues *json.RawMessage `json:"old_values"` + NewValues *json.RawMessage `json:"new_values"` + } + + var entries []entry + for rows.Next() { + var e entry + var createdAt time.Time + var oldJSON, newJSON *string + if err := rows.Scan(&createdAt, &e.Source, &e.Action, &e.Description, &oldJSON, &newJSON); err != nil { + continue + } + e.CreatedAt = createdAt.Format("2006-01-02 15:04:05") + if oldJSON != nil { + raw := json.RawMessage(*oldJSON) + e.OldValues = &raw + } + if newJSON != nil { + raw := json.RawMessage(*newJSON) + e.NewValues = &raw + } + entries = append(entries, e) + } + if entries == nil { + entries = []entry{} + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) + } +} + func parseAdminFilters(q url.Values) ([]Filter, string) { mode := q.Get("filter_mode") if mode != "or" { diff --git a/bmdevice.go b/bmdevice.go index 9306757..37f484f 100644 --- a/bmdevice.go +++ b/bmdevice.go @@ -119,6 +119,13 @@ func runBMDeviceSync(db *sql.DB) { if resp.StatusCode == http.StatusNotFound { log.Printf("BM device sync: 404 for %d (%s) — removing Brandmeister tag", rf.ID, rf.Callsign) db.Exec(`UPDATE repeaters SET networks = array_remove(networks, 'Brandmeister'), last_polled=NOW() WHERE id = $1`, rf.ID) + insertChangelog(db, ChangelogEntry{ + RepeaterID: rf.ID, + Callsign: rf.Callsign, + Source: "bm_sync", + Action: "remove_bm_tag", + Description: "BM API returned 404 — removed Brandmeister tag", + }) mu.Lock() removedBM++ mu.Unlock() @@ -194,6 +201,25 @@ func runBMDeviceSync(db *sql.DB) { errors++ } else { updated++ + insertChangelog(db, ChangelogEntry{ + RepeaterID: rf.ID, + Callsign: rf.Callsign, + Source: "bm_sync", + Action: "update_bm_data", + Description: "BM device sync updated device data", + NewValues: map[string]interface{}{ + "last_seen": lastSeen, + "bm_status": device.Status, + "bm_status_text": device.StatusText, + "hardware": device.Hardware, + "firmware": device.Firmware, + "pep": device.Pep, + "agl": device.Agl, + "website": device.Website, + "description": desc, + "import_freq_inconsistent": freqInconsistent, + }, + }) } mu.Unlock() @@ -201,6 +227,13 @@ func runBMDeviceSync(db *sql.DB) { if device.LastKnownMaster == 0 || device.LastKnownMaster == 9999 { if _, err := db.Exec(`UPDATE repeaters SET networks = array_remove(networks, 'Brandmeister') WHERE id = $1`, rf.ID); err == nil { log.Printf("BM device sync: removed Brandmeister tag from %d (%s) — no valid master", rf.ID, rf.Callsign) + insertChangelog(db, ChangelogEntry{ + RepeaterID: rf.ID, + Callsign: rf.Callsign, + Source: "bm_sync", + Action: "remove_bm_tag", + Description: fmt.Sprintf("Removed Brandmeister tag — no valid master (lastKnownMaster=%d)", device.LastKnownMaster), + }) mu.Lock() removedBM++ mu.Unlock() diff --git a/db.go b/db.go index ea030e2..e59e34e 100644 --- a/db.go +++ b/db.go @@ -3,6 +3,7 @@ package main import ( "database/sql" "database/sql/driver" + "encoding/json" "fmt" "log" "math" @@ -583,6 +584,146 @@ func updateRepeater(db *sql.DB, r Repeater) error { return err } +type ChangelogEntry struct { + RepeaterID int + Callsign string + Source string + Action string + Description string + OldValues map[string]interface{} + NewValues map[string]interface{} +} + +func insertChangelog(db *sql.DB, entry ChangelogEntry) { + var oldJSON, newJSON interface{} + if entry.OldValues != nil { + if b, err := json.Marshal(entry.OldValues); err == nil { + oldJSON = string(b) + } + } + if entry.NewValues != nil { + if b, err := json.Marshal(entry.NewValues); err == nil { + newJSON = string(b) + } + } + _, err := db.Exec( + `INSERT INTO changelog (repeater_id, callsign, source, action, description, old_values, new_values) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + entry.RepeaterID, entry.Callsign, entry.Source, entry.Action, entry.Description, + oldJSON, newJSON, + ) + if err != nil { + log.Printf("changelog: failed to insert entry for repeater %d: %v", entry.RepeaterID, err) + } +} + +func diffRepeater(old, new Repeater) (oldVals, newVals map[string]interface{}) { + oldVals = make(map[string]interface{}) + newVals = make(map[string]interface{}) + + if old.Callsign != new.Callsign { + oldVals["callsign"] = old.Callsign + newVals["callsign"] = new.Callsign + } + if old.FreqTx != new.FreqTx { + oldVals["freq_tx"] = old.FreqTx + newVals["freq_tx"] = new.FreqTx + } + if old.FreqRx != new.FreqRx { + oldVals["freq_rx"] = old.FreqRx + newVals["freq_rx"] = new.FreqRx + } + if old.FreqOffset != new.FreqOffset { + oldVals["freq_offset"] = old.FreqOffset + newVals["freq_offset"] = new.FreqOffset + } + if old.Band != new.Band { + oldVals["band"] = old.Band + newVals["band"] = new.Band + } + if old.Lat != new.Lat { + oldVals["lat"] = old.Lat + newVals["lat"] = new.Lat + } + if old.Lng != new.Lng { + oldVals["lng"] = old.Lng + newVals["lng"] = new.Lng + } + if old.City != new.City { + oldVals["city"] = old.City + newVals["city"] = new.City + } + if old.State != new.State { + oldVals["state"] = old.State + newVals["state"] = new.State + } + if old.Country != new.Country { + oldVals["country"] = old.Country + newVals["country"] = new.Country + } + if old.ColorCode != new.ColorCode { + oldVals["color_code"] = old.ColorCode + newVals["color_code"] = new.ColorCode + } + if old.TsLinked != new.TsLinked { + oldVals["ts_linked"] = old.TsLinked + newVals["ts_linked"] = new.TsLinked + } + if old.Trustee != new.Trustee { + oldVals["trustee"] = old.Trustee + newVals["trustee"] = new.Trustee + } + if old.IpscNetwork != new.IpscNetwork { + oldVals["ipsc_network"] = old.IpscNetwork + newVals["ipsc_network"] = new.IpscNetwork + } + if strings.Join([]string(old.Networks), ",") != strings.Join([]string(new.Networks), ",") { + oldVals["networks"] = old.Networks + newVals["networks"] = new.Networks + } + if old.Hotspot != new.Hotspot { + oldVals["hotspot"] = old.Hotspot + newVals["hotspot"] = new.Hotspot + } + if old.Status != new.Status { + oldVals["status"] = old.Status + newVals["status"] = new.Status + } + if old.Hardware != new.Hardware { + oldVals["hardware"] = old.Hardware + newVals["hardware"] = new.Hardware + } + if old.Firmware != new.Firmware { + oldVals["firmware"] = old.Firmware + newVals["firmware"] = new.Firmware + } + if old.Pep != new.Pep { + oldVals["pep"] = old.Pep + newVals["pep"] = new.Pep + } + if old.Agl != new.Agl { + oldVals["agl"] = old.Agl + newVals["agl"] = new.Agl + } + if old.Website != new.Website { + oldVals["website"] = old.Website + newVals["website"] = new.Website + } + if old.Description != new.Description { + oldVals["description"] = old.Description + newVals["description"] = new.Description + } + if old.BmStatusText != new.BmStatusText { + oldVals["bm_status_text"] = old.BmStatusText + newVals["bm_status_text"] = new.BmStatusText + } + + if len(oldVals) == 0 { + return nil, nil + } + return oldVals, newVals +} + func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { const r = 6371.0 dLat := (lat2 - lat1) * math.Pi / 180 diff --git a/main.go b/main.go index aef3faa..da53ec7 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,7 @@ func main() { adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice()) adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db)) adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(db)) + adminAPI.HandleFunc("/admin/api/repeaters/changelog", handleAdminChangelog(db)) mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI)) log.Println("Admin interface enabled at /admin/") } diff --git a/migrations/005_changelog.sql b/migrations/005_changelog.sql new file mode 100644 index 0000000..352c0fe --- /dev/null +++ b/migrations/005_changelog.sql @@ -0,0 +1,19 @@ +-- +goose Up +CREATE TABLE changelog ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + repeater_id INTEGER NOT NULL REFERENCES repeaters(id), + callsign TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL, + action TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + old_values JSONB, + new_values JSONB +); + +CREATE INDEX idx_changelog_repeater_id ON changelog (repeater_id); +CREATE INDEX idx_changelog_created_at ON changelog (created_at); +CREATE INDEX idx_changelog_source ON changelog (source); + +-- +goose Down +DROP TABLE IF EXISTS changelog; diff --git a/static/admin.html b/static/admin.html index b37e7c0..be35a0a 100644 --- a/static/admin.html +++ b/static/admin.html @@ -571,6 +571,118 @@ tr.bm-changed td { background: var(--tag-blue-bg); } tr.bm-changed:hover td { background: var(--tag-blue-bg); filter: brightness(0.95); } tr.bm-error td { background: var(--tag-red-bg); } + + .btn-history { + padding: 8px 20px; + background: none; + border: 1px solid var(--text-faint); + border-radius: 6px; + font-size: 13px; + color: var(--text-muted); + cursor: pointer; + font-weight: 600; + } + .btn-history:hover { background: var(--hover-bg); } + + .history-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 700px; + max-width: 90vw; + max-height: 80vh; + background: var(--bg); + border-radius: 12px; + box-shadow: 0 8px 32px var(--shadow); + z-index: 200; + display: none; + flex-direction: column; + overflow: hidden; + } + .history-modal.open { display: flex; } + .history-modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-light); + display: flex; + justify-content: space-between; + align-items: center; + } + .history-modal-header h3 { margin: 0; font-size: 16px; color: var(--text); } + .history-modal-close { + background: none; + border: none; + font-size: 22px; + color: var(--text-muted); + cursor: pointer; + padding: 0 4px; + } + .history-modal-close:hover { color: var(--text); } + .history-modal-body { + padding: 16px 20px; + overflow-y: auto; + flex: 1; + } + .history-overlay { + display: none; + position: fixed; + inset: 0; + background: var(--overlay-bg); + z-index: 199; + } + .history-entry { + border-bottom: 1px solid var(--border-light); + padding: 12px 0; + } + .history-entry:last-child { border-bottom: none; } + .history-meta { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 6px; + flex-wrap: wrap; + } + .history-date { font-size: 13px; color: var(--text-muted); font-weight: 600; } + .history-source { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + font-weight: 600; + text-transform: uppercase; + } + .history-source-admin { background: var(--tag-blue-bg); color: var(--tag-blue-text); } + .history-source-bm_sync { background: var(--tag-green-bg); color: var(--tag-green-text); } + .history-action { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + background: var(--tag-gray-bg); + color: var(--tag-gray-text); + font-weight: 600; + } + .history-desc { font-size: 13px; color: var(--text); margin-bottom: 6px; } + .history-changes { font-size: 12px; color: var(--text-muted); } + .history-changes table { + width: 100%; + border-collapse: collapse; + margin-top: 4px; + } + .history-changes th { + text-align: left; + font-size: 11px; + color: var(--text-faint); + padding: 2px 8px 2px 0; + font-weight: 600; + } + .history-changes td { + padding: 2px 8px 2px 0; + font-size: 12px; + font-family: monospace; + word-break: break-all; + } + .history-changes .old-val { color: var(--tag-red-text); } + .history-changes .new-val { color: var(--tag-green-text); } + .history-empty { color: var(--text-faint); font-size: 13px; text-align: center; padding: 32px 0; } @@ -650,12 +762,22 @@
+
+
+
+

Change History

+ +
+
+
+