Add test push button in admin area

This commit is contained in:
Marcus Kida 2026-08-04 14:43:08 +02:00
parent 14550b3797
commit a7ab445281
5 changed files with 85 additions and 4 deletions

View file

@ -83,7 +83,7 @@ docker compose -f compose.test.yml run --rm test
| `NTFY_TOKEN` | *(none)* | ntfy access token. Sent as `Authorization: Bearer` when no username is set | | `NTFY_TOKEN` | *(none)* | ntfy access token. Sent as `Authorization: Bearer` when no username is set |
| `PUBLIC_BASE_URL` | *(none)* | Public base URL of the site (e.g. `https://dmrmap.de`). Used to add a click-through link to the admin view in ntfy notifications | | `PUBLIC_BASE_URL` | *(none)* | Public base URL of the site (e.g. `https://dmrmap.de`). Used to add a click-through link to the admin view in ntfy notifications |
Both `NTFY_URL` and `NTFY_TOPIC` must be set for notifications; otherwise reports are stored but nothing is pushed. Both `NTFY_URL` and `NTFY_TOPIC` must be set for notifications; otherwise reports are stored but nothing is pushed. On startup the app logs either `ntfy notifications enabled: <topic URL> (auth: ...)` or `ntfy notifications disabled`. The **Test push** button in the admin Reports view publishes a test notification and shows the upstream error if it fails.
## API Endpoints ## API Endpoints

View file

@ -59,6 +59,7 @@ func main() {
adminAPI.HandleFunc("/admin/api/reports", handleAdminReports(db)) adminAPI.HandleFunc("/admin/api/reports", handleAdminReports(db))
adminAPI.HandleFunc("/admin/api/reports/status", handleAdminUpdateReport(db)) adminAPI.HandleFunc("/admin/api/reports/status", handleAdminUpdateReport(db))
adminAPI.HandleFunc("/admin/api/reports/delete", handleAdminDeleteReport(db)) adminAPI.HandleFunc("/admin/api/reports/delete", handleAdminDeleteReport(db))
adminAPI.HandleFunc("/admin/api/reports/test-notification", handleAdminTestNotification(notifier))
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

@ -30,13 +30,23 @@ func newNtfyNotifierFromEnv() *ntfyNotifier {
return nil return nil
} }
return &ntfyNotifier{ n := &ntfyNotifier{
url: strings.TrimRight(base, "/") + "/" + strings.TrimLeft(topic, "/"), url: strings.TrimRight(base, "/") + "/" + strings.TrimLeft(topic, "/"),
user: strings.TrimSpace(os.Getenv("NTFY_USER")), user: strings.TrimSpace(os.Getenv("NTFY_USER")),
token: strings.TrimSpace(os.Getenv("NTFY_TOKEN")), token: strings.TrimSpace(os.Getenv("NTFY_TOKEN")),
clickBase: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")), "/"), clickBase: strings.TrimRight(strings.TrimSpace(os.Getenv("PUBLIC_BASE_URL")), "/"),
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
} }
auth := "none"
if n.user != "" && n.token != "" {
auth = "basic (" + n.user + ")"
} else if n.token != "" {
auth = "bearer token"
}
log.Printf("ntfy notifications enabled: %s (auth: %s)", n.url, auth)
return n
} }
// notifyReport sends a push notification for a new user report. It never // notifyReport sends a push notification for a new user report. It never
@ -52,12 +62,24 @@ func (n *ntfyNotifier) notifyReport(rep UserReport) {
}() }()
} }
// sendTest publishes a test notification and reports the outcome synchronously,
// so the admin UI can surface configuration problems.
func (n *ntfyNotifier) sendTest() error {
return n.send(UserReport{
Callsign: "TEST",
ReportType: "other",
Message: "Test notification from DMRmap admin.",
})
}
func (n *ntfyNotifier) send(rep UserReport) error { func (n *ntfyNotifier) send(rep UserReport) error {
body := reportTypeLabel(rep.ReportType) body := reportTypeLabel(rep.ReportType)
if rep.Message != "" { if rep.Message != "" {
body += "\n\n" + rep.Message body += "\n\n" + rep.Message
} }
body += fmt.Sprintf("\n\nRepeater ID: %d", rep.RepeaterID) if rep.RepeaterID > 0 {
body += fmt.Sprintf("\n\nRepeater ID: %d", rep.RepeaterID)
}
req, err := http.NewRequest(http.MethodPost, n.url, strings.NewReader(body)) req, err := http.NewRequest(http.MethodPost, n.url, strings.NewReader(body))
if err != nil { if err != nil {
@ -71,7 +93,7 @@ func (n *ntfyNotifier) send(rep UserReport) error {
req.Header.Set("Title", title) req.Header.Set("Title", title)
req.Header.Set("Tags", "warning,radio") req.Header.Set("Tags", "warning,radio")
req.Header.Set("Priority", "default") req.Header.Set("Priority", "default")
if n.clickBase != "" { if n.clickBase != "" && rep.RepeaterID > 0 {
req.Header.Set("Click", fmt.Sprintf("%s/admin/#%d", n.clickBase, rep.RepeaterID)) req.Header.Set("Click", fmt.Sprintf("%s/admin/#%d", n.clickBase, rep.RepeaterID))
} }
n.setAuth(req) n.setAuth(req)

View file

@ -246,6 +246,38 @@ func handleAdminUpdateReport(db *sql.DB) http.HandlerFunc {
} }
} }
// handleAdminTestNotification publishes a test push so ntfy credentials can be
// verified without filing a report. Unlike the report path it waits for the
// result and returns the upstream error verbatim.
func handleAdminTestNotification(notifier *ntfyNotifier) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
if notifier == nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(`{"error":"ntfy is not configured (NTFY_URL and NTFY_TOPIC must be set)"}`))
return
}
if err := notifier.sendTest(); err != nil {
log.Printf("ntfy: test notification failed: %v", err)
w.WriteHeader(http.StatusBadGateway)
json.NewEncoder(w).Encode(map[string]string{
"error": err.Error(),
"url": notifier.url,
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "url": notifier.url})
}
}
func handleAdminDeleteReport(db *sql.DB) http.HandlerFunc { func handleAdminDeleteReport(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.MethodDelete { if r.Method != http.MethodDelete {

View file

@ -881,6 +881,7 @@
<button class="reports-filter-btn active" data-report-filter="open">Open</button> <button class="reports-filter-btn active" data-report-filter="open">Open</button>
<button class="reports-filter-btn" data-report-filter="all">All</button> <button class="reports-filter-btn" data-report-filter="all">All</button>
</div> </div>
<button class="report-action-btn" id="reports-test-btn">Test push</button>
<button class="history-modal-close" id="reports-close">&times;</button> <button class="history-modal-close" id="reports-close">&times;</button>
</div> </div>
<div class="history-modal-body" id="reports-body"></div> <div class="history-modal-body" id="reports-body"></div>
@ -952,6 +953,7 @@
var reportsModal = document.getElementById("reports-modal"); var reportsModal = document.getElementById("reports-modal");
var reportsBody = document.getElementById("reports-body"); var reportsBody = document.getElementById("reports-body");
var reportsClose = document.getElementById("reports-close"); var reportsClose = document.getElementById("reports-close");
var reportsTestBtn = document.getElementById("reports-test-btn");
var reportsFilter = "open"; var reportsFilter = "open";
function escapeHtml(s) { function escapeHtml(s) {
@ -1857,6 +1859,30 @@
}); });
}); });
reportsTestBtn.addEventListener("click", function () {
reportsTestBtn.disabled = true;
// Not apiFetch: the error body carries the ntfy failure reason.
fetch("/admin/api/reports/test-notification", {
method: "POST",
headers: { "Authorization": "Bearer " + token },
})
.then(function (resp) {
return resp.json().then(function (data) { return { ok: resp.ok, data: data }; });
})
.then(function (r) {
reportsTestBtn.disabled = false;
if (r.ok) {
alert("Test notification published to " + r.data.url);
} else {
alert("Test notification failed: " + (r.data.error || "unknown error"));
}
})
.catch(function (err) {
reportsTestBtn.disabled = false;
alert("Test notification failed: " + err.message);
});
});
reportsBtn.addEventListener("click", showReports); reportsBtn.addEventListener("click", showReports);
reportsClose.addEventListener("click", closeReports); reportsClose.addEventListener("click", closeReports);
reportsOverlay.addEventListener("click", closeReports); reportsOverlay.addEventListener("click", closeReports);