Implement BM manual fetch from admin interface

This commit is contained in:
Marcus Kida 2026-02-11 08:55:14 +01:00
parent a44add1b4e
commit 0e38ac89d9
3 changed files with 169 additions and 0 deletions

View file

@ -3,9 +3,12 @@ package main
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"time"
) )
func adminAuth(token string, next http.Handler) http.Handler { 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 { func handleAdminRepeaters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {

View file

@ -44,6 +44,7 @@ func main() {
adminAPI := http.NewServeMux() adminAPI := http.NewServeMux()
adminAPI.HandleFunc("/admin/api/repeaters", handleAdminRepeaters(db)) adminAPI.HandleFunc("/admin/api/repeaters", handleAdminRepeaters(db))
adminAPI.HandleFunc("/admin/api/repeaters/update", handleAdminUpdateRepeater(db)) adminAPI.HandleFunc("/admin/api/repeaters/update", handleAdminUpdateRepeater(db))
adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice())
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

@ -423,6 +423,19 @@
.btn-cancel:hover { background: var(--hover-bg); } .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 { .save-status {
font-size: 12px; font-size: 12px;
align-self: center; align-self: center;
@ -502,6 +515,7 @@
<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-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>
@ -542,6 +556,7 @@
var detailCancel = document.getElementById("detail-cancel"); var detailCancel = document.getElementById("detail-cancel");
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");
function escapeHtml(s) { function escapeHtml(s) {
if (!s) return ""; 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); detailClose.addEventListener("click", closeDetail);
detailCancel.addEventListener("click", closeDetail); detailCancel.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", closeDetail); detailOverlay.addEventListener("click", closeDetail);
detailSave.addEventListener("click", saveDetail); detailSave.addEventListener("click", saveDetail);
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" && detailPanel.classList.contains("open")) {