Implement changelog
This commit is contained in:
parent
aa0b4275e2
commit
e3e5d74338
6 changed files with 523 additions and 4 deletions
123
admin.go
123
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" {
|
||||
|
|
|
|||
33
bmdevice.go
33
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()
|
||||
|
|
|
|||
141
db.go
141
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
|
||||
|
|
|
|||
1
main.go
1
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/")
|
||||
}
|
||||
|
|
|
|||
19
migrations/005_changelog.sql
Normal file
19
migrations/005_changelog.sql
Normal 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;
|
||||
|
|
@ -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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -650,12 +762,22 @@
|
|||
<div class="detail-body" id="detail-body"></div>
|
||||
<div class="detail-footer">
|
||||
<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-cancel" id="detail-cancel">Cancel</button>
|
||||
<button class="btn-save" id="detail-save">Save</button>
|
||||
</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">×</button>
|
||||
</div>
|
||||
<div class="history-modal-body" id="history-body"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
"use strict";
|
||||
|
|
@ -709,6 +831,12 @@
|
|||
var detailSave = document.getElementById("detail-save");
|
||||
var saveStatus = document.getElementById("save-status");
|
||||
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) {
|
||||
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);
|
||||
detailCancel.addEventListener("click", closeDetail);
|
||||
detailOverlay.addEventListener("click", closeDetail);
|
||||
|
|
@ -1424,9 +1626,13 @@
|
|||
fetchBmBtn.addEventListener("click", fetchBMData);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
|
|
|
|||
Loading…
Reference in a new issue