Implement manually updating BM status
This commit is contained in:
parent
0b85a68dae
commit
c1ce81456d
3 changed files with 254 additions and 4 deletions
54
admin.go
54
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) {
|
func parseAdminFilters(q url.Values) ([]Filter, string) {
|
||||||
mode := q.Get("filter_mode")
|
mode := q.Get("filter_mode")
|
||||||
if mode != "or" {
|
if mode != "or" {
|
||||||
|
|
|
||||||
1
main.go
1
main.go
|
|
@ -45,6 +45,7 @@ func main() {
|
||||||
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())
|
adminAPI.HandleFunc("/admin/api/repeaters/bm-device", handleBMDevice())
|
||||||
|
adminAPI.HandleFunc("/admin/api/repeaters/save-bm", handleAdminSaveBMData(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/")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -535,6 +535,42 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.filter-add-btn:hover { border-color: var(--text-muted); color: var(--text-muted); }
|
.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); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -553,6 +589,7 @@
|
||||||
<h1>DMRmap Admin</h1>
|
<h1>DMRmap Admin</h1>
|
||||||
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
|
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
|
||||||
<button class="topbar-btn" id="toggle-filters-btn">Filters</button>
|
<button class="topbar-btn" id="toggle-filters-btn">Filters</button>
|
||||||
|
<button class="topbar-btn" id="fetch-all-bm-btn">Fetch All BM</button>
|
||||||
<span class="topbar-info" id="total-info"></span>
|
<span class="topbar-info" id="total-info"></span>
|
||||||
<button class="logout-btn" id="logout-btn">Logout</button>
|
<button class="logout-btn" id="logout-btn">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -564,6 +601,11 @@
|
||||||
<div id="filter-list" class="filter-list"></div>
|
<div id="filter-list" class="filter-list"></div>
|
||||||
<button class="filter-add-btn" id="add-filter-btn">+ Add filter</button>
|
<button class="filter-add-btn" id="add-filter-btn">+ Add filter</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bm-review-bar" id="bm-review-bar" style="display:none">
|
||||||
|
<span class="bm-review-text" id="bm-review-text"></span>
|
||||||
|
<button class="btn-bm-save" id="bm-save-btn">Save All</button>
|
||||||
|
<button class="btn-bm-discard" id="bm-discard-btn">Discard</button>
|
||||||
|
</div>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -645,6 +687,13 @@
|
||||||
var filterBar = document.getElementById("filter-bar");
|
var filterBar = document.getElementById("filter-bar");
|
||||||
var filterList = document.getElementById("filter-list");
|
var filterList = document.getElementById("filter-list");
|
||||||
var addFilterBtn = document.getElementById("add-filter-btn");
|
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 = {
|
var filterDefs = {
|
||||||
country: { label: "Country", ops: [{v:"eq",l:"is"},{v:"neq",l:"is not"},{v:"contains",l:"contains"}], hasValue: true },
|
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() {
|
function fetchRepeaters() {
|
||||||
|
pendingBMData = {};
|
||||||
|
bmReviewBar.style.display = "none";
|
||||||
loadingEl.style.display = "";
|
loadingEl.style.display = "";
|
||||||
emptyState.style.display = "none";
|
emptyState.style.display = "none";
|
||||||
tableBody.innerHTML = "";
|
tableBody.innerHTML = "";
|
||||||
|
|
@ -851,6 +1041,9 @@
|
||||||
|
|
||||||
repeaters.forEach(function (r, idx) {
|
repeaters.forEach(function (r, idx) {
|
||||||
var tr = document.createElement("tr");
|
var tr = document.createElement("tr");
|
||||||
|
var bm = pendingBMData[r.id];
|
||||||
|
if (bm) tr.className = "bm-changed";
|
||||||
|
|
||||||
var bandTag = r.band === "2m"
|
var bandTag = r.band === "2m"
|
||||||
? '<span class="tag tag-2m">2m</span>'
|
? '<span class="tag tag-2m">2m</span>'
|
||||||
: '<span class="tag tag-70cm">70cm</span>';
|
: '<span class="tag tag-70cm">70cm</span>';
|
||||||
|
|
@ -859,7 +1052,9 @@
|
||||||
? '<span class="tag tag-inactive">Inactive</span>'
|
? '<span class="tag tag-inactive">Inactive</span>'
|
||||||
: '<span class="tag tag-active">' + escapeHtml(r.status) + '</span>';
|
: '<span class="tag tag-active">' + escapeHtml(r.status) + '</span>';
|
||||||
|
|
||||||
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 =
|
tr.innerHTML =
|
||||||
'<td>' + r.id + '</td>' +
|
'<td>' + r.id + '</td>' +
|
||||||
|
|
@ -875,9 +1070,9 @@
|
||||||
'<td>' + escapeHtml(r.country) + '</td>' +
|
'<td>' + escapeHtml(r.country) + '</td>' +
|
||||||
'<td>' + statusTag + '</td>' +
|
'<td>' + statusTag + '</td>' +
|
||||||
'<td>' + escapeHtml(r.ts_linked) + '</td>' +
|
'<td>' + escapeHtml(r.ts_linked) + '</td>' +
|
||||||
'<td>' + formatDate(r.last_seen) + '</td>' +
|
'<td>' + formatDate(lastSeenVal) + '</td>' +
|
||||||
'<td>' + escapeHtml(bmStatus) + '</td>' +
|
'<td>' + escapeHtml(bmStatusVal) + '</td>' +
|
||||||
'<td>' + escapeHtml(r.hardware) + '</td>' +
|
'<td>' + escapeHtml(hardwareVal || '') + '</td>' +
|
||||||
'<td>' + formatDate(r.last_polled) + '</td>';
|
'<td>' + formatDate(r.last_polled) + '</td>';
|
||||||
|
|
||||||
tr.addEventListener("click", function () {
|
tr.addEventListener("click", function () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue