diff --git a/admin.go b/admin.go
index e63c0ec..279bf81 100644
--- a/admin.go
+++ b/admin.go
@@ -179,6 +179,60 @@ func handleAdminRepeaters(db *sql.DB) http.HandlerFunc {
}
}
+func handleAdminSaveBMData(db *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut {
+ http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
+ return
+ }
+
+ var payload struct {
+ ID int `json:"id"`
+ LastSeen string `json:"last_seen"`
+ BmStatus *int `json:"bm_status"`
+ BmStatusText string `json:"bm_status_text"`
+ Hardware string `json:"hardware"`
+ Firmware string `json:"firmware"`
+ Pep int `json:"pep"`
+ Agl int `json:"agl"`
+ Website string `json:"website"`
+ Description string `json:"description"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
+ return
+ }
+ if payload.ID <= 0 {
+ http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest)
+ return
+ }
+
+ var lastSeen *time.Time
+ if payload.LastSeen != "" {
+ if t, err := time.Parse("2006-01-02 15:04:05", payload.LastSeen); err == nil {
+ lastSeen = &t
+ }
+ }
+
+ _, err := db.Exec(`UPDATE repeaters SET
+ last_seen=$1, bm_status=$2, bm_status_text=$3,
+ hardware=$4, firmware=$5, pep=$6, agl=$7,
+ website=$8, description=$9, last_polled=NOW()
+ WHERE id=$10`,
+ lastSeen, payload.BmStatus, payload.BmStatusText,
+ payload.Hardware, payload.Firmware, payload.Pep, payload.Agl,
+ payload.Website, payload.Description, payload.ID)
+ if err != nil {
+ log.Printf("Admin save BM data: update failed for %d: %v", payload.ID, err)
+ http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"ok":true}`))
+ }
+}
+
func parseAdminFilters(q url.Values) ([]Filter, string) {
mode := q.Get("filter_mode")
if mode != "or" {
diff --git a/main.go b/main.go
index 94f174f..eaf2b24 100644
--- a/main.go
+++ b/main.go
@@ -45,6 +45,7 @@ func main() {
adminAPI.HandleFunc("/admin/api/repeaters", handleAdminRepeaters(db))
adminAPI.HandleFunc("/admin/api/repeaters/update", handleAdminUpdateRepeater(db))
adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice())
+ adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(db))
mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI))
log.Println("Admin interface enabled at /admin/")
}
diff --git a/static/admin.html b/static/admin.html
index 9c0f71f..432ba30 100644
--- a/static/admin.html
+++ b/static/admin.html
@@ -535,6 +535,42 @@
cursor: pointer;
}
.filter-add-btn:hover { border-color: var(--text-muted); color: var(--text-muted); }
+
+ .bm-review-bar {
+ background: var(--tag-blue-bg);
+ border-bottom: 1px solid var(--border-light);
+ padding: 10px 24px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-size: 13px;
+ color: var(--tag-blue-text);
+ }
+ .bm-review-bar .bm-review-text { flex: 1; }
+ .bm-review-bar button {
+ padding: 6px 14px;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ cursor: pointer;
+ }
+ .btn-bm-save {
+ background: var(--accent);
+ color: white;
+ border: none;
+ }
+ .btn-bm-save:hover { background: var(--accent-hover); }
+ .btn-bm-save:disabled { opacity: 0.5; cursor: default; }
+ .btn-bm-discard {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--text-muted);
+ }
+ .btn-bm-discard:hover { background: var(--hover-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-error td { background: var(--tag-red-bg); }
@@ -553,6 +589,7 @@
DMRmap Admin
+
@@ -564,6 +601,11 @@
+
+
+
+
+
@@ -645,6 +687,13 @@
var filterBar = document.getElementById("filter-bar");
var filterList = document.getElementById("filter-list");
var addFilterBtn = document.getElementById("add-filter-btn");
+ var fetchAllBmBtn = document.getElementById("fetch-all-bm-btn");
+ var bmReviewBar = document.getElementById("bm-review-bar");
+ var bmReviewText = document.getElementById("bm-review-text");
+ var bmSaveBtn = document.getElementById("bm-save-btn");
+ var bmDiscardBtn = document.getElementById("bm-discard-btn");
+
+ var pendingBMData = {}; // keyed by repeater ID: { bmData, original }
var filterDefs = {
country: { label: "Country", ops: [{v:"eq",l:"is"},{v:"neq",l:"is not"},{v:"contains",l:"contains"}], hasValue: true },
@@ -807,7 +856,148 @@
});
});
+ // --- Bulk BM fetch ---
+
+ function fetchAllBMData() {
+ var bmRepeaters = currentRepeaters.filter(function (r) {
+ return r.networks && r.networks.indexOf("Brandmeister") !== -1;
+ });
+ if (!bmRepeaters.length) {
+ totalInfo.textContent = "No BrandMeister repeaters on this page";
+ return;
+ }
+
+ pendingBMData = {};
+ fetchAllBmBtn.disabled = true;
+ fetchAllBmBtn.textContent = "Fetching 0/" + bmRepeaters.length;
+
+ var idx = 0;
+ var changed = 0;
+ var errors = 0;
+ var fetched = 0;
+
+ function scheduleNext() {
+ // 5s pause after every 10 fetches, otherwise 500ms (2/sec max)
+ var delay = (fetched > 0 && fetched % 10 === 0) ? 5000 : 500;
+ if (fetched > 0 && fetched % 10 === 0) {
+ fetchAllBmBtn.textContent = "Pausing... " + idx + "/" + bmRepeaters.length;
+ }
+ setTimeout(next, delay);
+ }
+
+ function next() {
+ if (idx >= bmRepeaters.length) {
+ fetchAllBmBtn.disabled = false;
+ fetchAllBmBtn.textContent = "Fetch All BM";
+ if (changed > 0) {
+ bmReviewText.textContent = changed + " repeater" + (changed !== 1 ? "s" : "") + " with new BM data" + (errors > 0 ? " (" + errors + " failed)" : "");
+ bmReviewBar.style.display = "flex";
+ } else {
+ totalInfo.textContent = "No changes found" + (errors > 0 ? " (" + errors + " failed)" : "");
+ }
+ renderTable(currentRepeaters);
+ return;
+ }
+
+ var r = bmRepeaters[idx];
+ fetchAllBmBtn.textContent = "Fetching " + (idx + 1) + "/" + bmRepeaters.length;
+
+ apiFetch("/admin/api/repeaters/bm-device?id=" + r.id)
+ .then(function (bm) {
+ var hasChange =
+ (bm.status_text || "") !== (r.bm_status_text || "") ||
+ bm.status !== r.bm_status ||
+ (bm.hardware || "") !== (r.hardware || "") ||
+ (bm.firmware || "") !== (r.firmware || "") ||
+ bm.pep !== r.pep ||
+ bm.agl !== r.agl ||
+ (bm.website || "") !== (r.website || "") ||
+ (bm.description || "") !== (r.description || "");
+
+ if (hasChange) {
+ pendingBMData[r.id] = bm;
+ changed++;
+ }
+ idx++;
+ fetched++;
+ scheduleNext();
+ })
+ .catch(function (err) {
+ if (err.message === "unauthorized") return;
+ errors++;
+ idx++;
+ fetched++;
+ scheduleNext();
+ });
+ }
+
+ next();
+ }
+
+ function discardBMData() {
+ pendingBMData = {};
+ bmReviewBar.style.display = "none";
+ renderTable(currentRepeaters);
+ }
+
+ function saveAllBMData() {
+ var ids = Object.keys(pendingBMData);
+ if (!ids.length) return;
+
+ bmSaveBtn.disabled = true;
+ bmReviewText.textContent = "Saving 0/" + ids.length + "...";
+ var idx = 0;
+ var saved = 0;
+ var errors = 0;
+
+ function nextSave() {
+ if (idx >= ids.length) {
+ bmSaveBtn.disabled = false;
+ pendingBMData = {};
+ bmReviewBar.style.display = "none";
+ fetchRepeaters();
+ return;
+ }
+
+ var id = parseInt(ids[idx]);
+ var bm = pendingBMData[id];
+ bmReviewText.textContent = "Saving " + (idx + 1) + "/" + ids.length + "...";
+
+ apiFetch("/admin/api/repeaters/save-bm", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ id: id,
+ last_seen: bm.last_seen || "",
+ bm_status: bm.status,
+ bm_status_text: bm.status_text || "",
+ hardware: bm.hardware || "",
+ firmware: bm.firmware || "",
+ pep: bm.pep || 0,
+ agl: bm.agl || 0,
+ website: bm.website || "",
+ description: bm.description || ""
+ })
+ })
+ .then(function () { saved++; idx++; nextSave(); })
+ .catch(function (err) {
+ if (err.message === "unauthorized") return;
+ errors++;
+ idx++;
+ nextSave();
+ });
+ }
+
+ nextSave();
+ }
+
+ fetchAllBmBtn.addEventListener("click", fetchAllBMData);
+ bmSaveBtn.addEventListener("click", saveAllBMData);
+ bmDiscardBtn.addEventListener("click", discardBMData);
+
function fetchRepeaters() {
+ pendingBMData = {};
+ bmReviewBar.style.display = "none";
loadingEl.style.display = "";
emptyState.style.display = "none";
tableBody.innerHTML = "";
@@ -851,6 +1041,9 @@
repeaters.forEach(function (r, idx) {
var tr = document.createElement("tr");
+ var bm = pendingBMData[r.id];
+ if (bm) tr.className = "bm-changed";
+
var bandTag = r.band === "2m"
? '2m'
: '70cm';
@@ -859,7 +1052,9 @@
? 'Inactive'
: '' + escapeHtml(r.status) + '';
- var bmStatus = r.bm_status_text || (r.bm_status !== null ? "Code " + r.bm_status : "");
+ var bmStatusVal = bm ? bm.status_text || (bm.status !== null ? "Code " + bm.status : "") : r.bm_status_text || (r.bm_status !== null ? "Code " + r.bm_status : "");
+ var lastSeenVal = bm ? bm.last_seen : r.last_seen;
+ var hardwareVal = bm ? bm.hardware : r.hardware;
tr.innerHTML =
'| ' + r.id + ' | ' +
@@ -875,9 +1070,9 @@
'' + escapeHtml(r.country) + ' | ' +
'' + statusTag + ' | ' +
'' + escapeHtml(r.ts_linked) + ' | ' +
- '' + formatDate(r.last_seen) + ' | ' +
- '' + escapeHtml(bmStatus) + ' | ' +
- '' + escapeHtml(r.hardware) + ' | ' +
+ '' + formatDate(lastSeenVal) + ' | ' +
+ '' + escapeHtml(bmStatusVal) + ' | ' +
+ '' + escapeHtml(hardwareVal || '') + ' | ' +
'' + formatDate(r.last_polled) + ' | ';
tr.addEventListener("click", function () {