Implement changelog

This commit is contained in:
Marcus Kida 2026-02-19 10:00:17 +01:00
parent aa0b4275e2
commit e3e5d74338
6 changed files with 523 additions and 4 deletions

123
admin.go
View file

@ -54,11 +54,32 @@ func handleAdminUpdateRepeater(db *sql.DB) http.HandlerFunc {
return 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 { if err := updateRepeater(db, rpt); err != nil {
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError) http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return 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.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`)) w.Write([]byte(`{"ok":true}`))
} }
@ -229,10 +250,35 @@ func handleAdminSaveBMData(db *sql.DB) http.HandlerFunc {
return 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 // Add Brandmeister tag if valid master server
if payload.LastKnownMaster != 0 && payload.LastKnownMaster != 9999 { if payload.LastKnownMaster != 0 && payload.LastKnownMaster != 9999 {
db.Exec(`UPDATE repeaters SET networks = array_append(networks, 'Brandmeister') if _, tagErr := db.Exec(`UPDATE repeaters SET networks = array_append(networks, 'Brandmeister')
WHERE id = $1 AND NOT networks @> ARRAY['Brandmeister']`, payload.ID) 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") w.Header().Set("Content-Type", "application/json")
@ -265,11 +311,84 @@ func handleAdminRemoveBMTag(db *sql.DB) http.HandlerFunc {
return 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.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`)) 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) { func parseAdminFilters(q url.Values) ([]Filter, string) {
mode := q.Get("filter_mode") mode := q.Get("filter_mode")
if mode != "or" { if mode != "or" {

View file

@ -119,6 +119,13 @@ func runBMDeviceSync(db *sql.DB) {
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
log.Printf("BM device sync: 404 for %d (%s) — removing Brandmeister tag", rf.ID, rf.Callsign) 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) 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() mu.Lock()
removedBM++ removedBM++
mu.Unlock() mu.Unlock()
@ -194,6 +201,25 @@ func runBMDeviceSync(db *sql.DB) {
errors++ errors++
} else { } else {
updated++ 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() mu.Unlock()
@ -201,6 +227,13 @@ func runBMDeviceSync(db *sql.DB) {
if device.LastKnownMaster == 0 || device.LastKnownMaster == 9999 { 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 { 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) 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() mu.Lock()
removedBM++ removedBM++
mu.Unlock() mu.Unlock()

141
db.go
View file

@ -3,6 +3,7 @@ package main
import ( import (
"database/sql" "database/sql"
"database/sql/driver" "database/sql/driver"
"encoding/json"
"fmt" "fmt"
"log" "log"
"math" "math"
@ -583,6 +584,146 @@ func updateRepeater(db *sql.DB, r Repeater) error {
return err 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 { func haversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const r = 6371.0 const r = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180 dLat := (lat2 - lat1) * math.Pi / 180

View file

@ -47,6 +47,7 @@ func main() {
adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice()) adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice())
adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db)) adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db))
adminAPI.HandleFunc("/admin/api/repeaters/remove-bm-tag", handleAdminRemoveBMTag(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)) mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI))
log.Println("Admin interface enabled at /admin/") log.Println("Admin interface enabled at /admin/")
} }

View file

@ -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;

View file

@ -571,6 +571,118 @@
tr.bm-changed td { background: var(--tag-blue-bg); } 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-changed:hover td { background: var(--tag-blue-bg); filter: brightness(0.95); }
tr.bm-error td { background: var(--tag-red-bg); } 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; }
</style> </style>
</head> </head>
<body> <body>
@ -650,12 +762,22 @@
<div class="detail-body" id="detail-body"></div> <div class="detail-body" id="detail-body"></div>
<div class="detail-footer"> <div class="detail-footer">
<span class="save-status" id="save-status"></span> <span class="save-status" id="save-status"></span>
<button class="btn-history" id="history-btn">History</button>
<button class="btn-fetch-bm" id="fetch-bm-btn">Fetch BM</button> <button class="btn-fetch-bm" id="fetch-bm-btn">Fetch BM</button>
<button class="btn-cancel" id="detail-cancel">Cancel</button> <button class="btn-cancel" id="detail-cancel">Cancel</button>
<button class="btn-save" id="detail-save">Save</button> <button class="btn-save" id="detail-save">Save</button>
</div> </div>
</div> </div>
<div class="history-overlay" id="history-overlay"></div>
<div class="history-modal" id="history-modal">
<div class="history-modal-header">
<h3 id="history-title">Change History</h3>
<button class="history-modal-close" id="history-close">&times;</button>
</div>
<div class="history-modal-body" id="history-body"></div>
</div>
<script> <script>
(function () { (function () {
"use strict"; "use strict";
@ -709,6 +831,12 @@
var detailSave = document.getElementById("detail-save"); var detailSave = document.getElementById("detail-save");
var saveStatus = document.getElementById("save-status"); var saveStatus = document.getElementById("save-status");
var fetchBmBtn = document.getElementById("fetch-bm-btn"); var fetchBmBtn = document.getElementById("fetch-bm-btn");
var historyBtn = document.getElementById("history-btn");
var historyOverlay = document.getElementById("history-overlay");
var historyModal = document.getElementById("history-modal");
var historyTitle = document.getElementById("history-title");
var historyBody = document.getElementById("history-body");
var historyClose = document.getElementById("history-close");
function escapeHtml(s) { function escapeHtml(s) {
if (!s) return ""; if (!s) return "";
@ -1417,6 +1545,80 @@
}); });
} }
function formatValue(v) {
if (v === null || v === undefined) return "N/A";
if (Array.isArray(v)) return v.join(", ") || "(empty)";
if (typeof v === "boolean") return v ? "Yes" : "No";
return String(v);
}
function showHistory() {
if (!editingRepeater) return;
var id = editingRepeater.id;
var callsign = editingRepeater.callsign || "";
historyTitle.textContent = "History — " + callsign + " (#" + id + ")";
historyBody.innerHTML = '<div class="history-empty">Loading...</div>';
historyOverlay.style.display = "block";
historyModal.classList.add("open");
apiFetch("/admin/api/repeaters/changelog?id=" + id)
.then(function (entries) {
if (!entries.length) {
historyBody.innerHTML = '<div class="history-empty">No changes recorded yet</div>';
return;
}
var html = "";
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var sourceClass = "history-source-" + e.source;
html += '<div class="history-entry">';
html += '<div class="history-meta">';
html += '<span class="history-date">' + escapeHtml(e.created_at) + '</span>';
html += '<span class="history-source ' + sourceClass + '">' + escapeHtml(e.source) + '</span>';
html += '<span class="history-action">' + escapeHtml(e.action) + '</span>';
html += '</div>';
if (e.description) {
html += '<div class="history-desc">' + escapeHtml(e.description) + '</div>';
}
if (e.old_values || e.new_values) {
html += '<div class="history-changes"><table><tr><th>Field</th>';
if (e.old_values) html += '<th>Old</th>';
html += '<th>New</th></tr>';
var keys = Object.keys(e.new_values || e.old_values || {});
if (e.old_values && e.new_values) {
var oldKeys = Object.keys(e.old_values);
for (var k = 0; k < oldKeys.length; k++) {
if (keys.indexOf(oldKeys[k]) === -1) keys.push(oldKeys[k]);
}
}
for (var k = 0; k < keys.length; k++) {
html += '<tr><td>' + escapeHtml(keys[k]) + '</td>';
if (e.old_values) html += '<td class="old-val">' + escapeHtml(formatValue(e.old_values[keys[k]])) + '</td>';
html += '<td class="new-val">' + escapeHtml(formatValue((e.new_values || {})[keys[k]])) + '</td>';
html += '</tr>';
}
html += '</table></div>';
}
html += '</div>';
}
historyBody.innerHTML = html;
})
.catch(function (err) {
if (err.message === "unauthorized") return;
historyBody.innerHTML = '<div class="history-empty">Failed to load history</div>';
});
}
function closeHistory() {
historyOverlay.style.display = "none";
historyModal.classList.remove("open");
}
historyBtn.addEventListener("click", showHistory);
historyClose.addEventListener("click", closeHistory);
historyOverlay.addEventListener("click", closeHistory);
detailClose.addEventListener("click", closeDetail); detailClose.addEventListener("click", closeDetail);
detailCancel.addEventListener("click", closeDetail); detailCancel.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", closeDetail); detailOverlay.addEventListener("click", closeDetail);
@ -1424,9 +1626,13 @@
fetchBmBtn.addEventListener("click", fetchBMData); fetchBmBtn.addEventListener("click", fetchBMData);
document.addEventListener("keydown", function (e) { document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && detailPanel.classList.contains("open")) { if (e.key === "Escape") {
if (historyModal.classList.contains("open")) {
closeHistory();
} else if (detailPanel.classList.contains("open")) {
closeDetail(); closeDetail();
} }
}
}); });
// Login // Login