From 0e38ac89d95530c40a8cf54826d33b4097a2f28e Mon Sep 17 00:00:00 2001 From: Marcus Kida Date: Wed, 11 Feb 2026 08:55:14 +0100 Subject: [PATCH] Implement BM manual fetch from admin interface --- admin.go | 74 +++++++++++++++++++++++++++++++++++++ main.go | 1 + static/admin.html | 94 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+) diff --git a/admin.go b/admin.go index 0ae1ff4..979dad4 100644 --- a/admin.go +++ b/admin.go @@ -3,9 +3,12 @@ package main import ( "database/sql" "encoding/json" + "fmt" + "log" "net/http" "strconv" "strings" + "time" ) func adminAuth(token string, next http.Handler) http.Handler { @@ -60,6 +63,77 @@ func handleAdminUpdateRepeater(db *sql.DB) http.HandlerFunc { } } +func handleBMDevice() http.HandlerFunc { + client := &http.Client{Timeout: 15 * time.Second} + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + return + } + id := r.URL.Query().Get("id") + if id == "" { + http.Error(w, `{"error":"missing id"}`, http.StatusBadRequest) + return + } + if _, err := strconv.Atoi(id); err != nil { + http.Error(w, `{"error":"invalid id"}`, http.StatusBadRequest) + return + } + + url := fmt.Sprintf("https://api.brandmeister.network/v2/device/%s", id) + resp, err := client.Get(url) + if err != nil { + log.Printf("BM device proxy: HTTP error for %s: %v", id, err) + http.Error(w, `{"error":"upstream request failed"}`, http.StatusBadGateway) + return + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + http.Error(w, `{"error":"device not found on BrandMeister"}`, http.StatusNotFound) + return + } + if resp.StatusCode != http.StatusOK { + log.Printf("BM device proxy: HTTP %d for %s", resp.StatusCode, id) + http.Error(w, `{"error":"upstream error"}`, http.StatusBadGateway) + return + } + + var device bmDeviceResponse + if err := json.NewDecoder(resp.Body).Decode(&device); err != nil { + log.Printf("BM device proxy: decode error for %s: %v", id, err) + http.Error(w, `{"error":"decode error"}`, http.StatusBadGateway) + return + } + + desc := device.Description + if device.PriorityDescription != "" { + if desc != "" { + desc = device.PriorityDescription + "\n" + desc + } else { + desc = device.PriorityDescription + } + } + desc = stripHTML(desc) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "last_seen": device.LastSeen, + "status": device.Status, + "status_text": device.StatusText, + "hardware": device.Hardware, + "firmware": device.Firmware, + "pep": device.Pep, + "agl": device.Agl, + "website": device.Website, + "description": desc, + "tx": device.Tx, + "rx": device.Rx, + "last_known_master": device.LastKnownMaster, + }) + } +} + func handleAdminRepeaters(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { diff --git a/main.go b/main.go index 4c12b54..94f174f 100644 --- a/main.go +++ b/main.go @@ -44,6 +44,7 @@ func main() { adminAPI := http.NewServeMux() adminAPI.HandleFunc("/admin/api/repeaters", handleAdminRepeaters(db)) adminAPI.HandleFunc("/admin/api/repeaters/update", handleAdminUpdateRepeater(db)) + adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice()) 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 588eac0..a050997 100644 --- a/static/admin.html +++ b/static/admin.html @@ -423,6 +423,19 @@ .btn-cancel:hover { background: var(--hover-bg); } + .btn-fetch-bm { + padding: 8px 20px; + background: none; + border: 1px solid var(--tag-blue-text); + border-radius: 6px; + font-size: 13px; + color: var(--tag-blue-text); + cursor: pointer; + font-weight: 600; + } + .btn-fetch-bm:hover { background: var(--tag-blue-bg); } + .btn-fetch-bm:disabled { opacity: 0.5; cursor: default; } + .save-status { font-size: 12px; align-self: center; @@ -502,6 +515,7 @@
@@ -542,6 +556,7 @@ var detailCancel = document.getElementById("detail-cancel"); var detailSave = document.getElementById("detail-save"); var saveStatus = document.getElementById("save-status"); + var fetchBmBtn = document.getElementById("fetch-bm-btn"); function escapeHtml(s) { if (!s) return ""; @@ -861,10 +876,89 @@ }); } + function updateField(key, value) { + // Find the field definition + var field = null; + for (var j = 0; j < fields.length; j++) { + if (fields[j].key === key) { field = fields[j]; break; } + } + if (!field) return; + + // Only update editingRepeater for editable fields (readonly values like + // last_seen use non-RFC3339 formats that break Go's JSON decoder on save) + if (field.type !== "readonly" && editingRepeater) editingRepeater[key] = value; + + if (field.type === "readonly") { + // Find the label that matches and update the sibling readonly-value + var labels = detailBody.querySelectorAll("label"); + for (var i = 0; i < labels.length; i++) { + if (labels[i].textContent === field.label) { + var rdv = labels[i].parentNode.querySelector(".readonly-value"); + if (rdv) { + var display = value; + if (key === "last_seen" || key === "last_polled") display = formatDate(String(value)); + if (key === "import_freq_inconsistent") display = value ? "Yes" : "No"; + if (key === "bm_status") display = value !== "" && value !== null ? String(value) : "N/A"; + rdv.textContent = String(display || "N/A"); + } + break; + } + } + } else if (field.type === "multicheck") { + var group = detailBody.querySelector('[data-key="' + key + '"]'); + if (group) { + var cbs = group.querySelectorAll('input[type="checkbox"]'); + var vals = value || []; + for (var i = 0; i < cbs.length; i++) { + cbs[i].checked = vals.indexOf(cbs[i].value) !== -1; + } + } + } else { + var el = detailBody.querySelector('[data-key="' + key + '"]'); + if (el) el.value = value != null ? value : ""; + } + } + + function fetchBMData() { + if (!editingRepeater) return; + var id = editingRepeater.id; + + fetchBmBtn.disabled = true; + fetchBmBtn.textContent = "Fetching..."; + saveStatus.textContent = ""; + saveStatus.className = "save-status"; + + apiFetch("/admin/api/repeaters/bm-device?id=" + id) + .then(function (bm) { + updateField("last_seen", bm.last_seen); + updateField("bm_status", bm.status); + updateField("bm_status_text", bm.status_text); + updateField("hardware", bm.hardware); + updateField("firmware", bm.firmware); + updateField("pep", bm.pep); + updateField("agl", bm.agl); + updateField("website", bm.website); + updateField("description", bm.description); + + saveStatus.textContent = "BM data loaded — review and save"; + saveStatus.className = "save-status success"; + fetchBmBtn.disabled = false; + fetchBmBtn.textContent = "Fetch BM"; + }) + .catch(function (err) { + if (err.message === "unauthorized") return; + saveStatus.textContent = "Failed to fetch BM data"; + saveStatus.className = "save-status error"; + fetchBmBtn.disabled = false; + fetchBmBtn.textContent = "Fetch BM"; + }); + } + detailClose.addEventListener("click", closeDetail); detailCancel.addEventListener("click", closeDetail); detailOverlay.addEventListener("click", closeDetail); detailSave.addEventListener("click", saveDetail); + fetchBmBtn.addEventListener("click", fetchBMData); document.addEventListener("keydown", function (e) { if (e.key === "Escape" && detailPanel.classList.contains("open")) {