Implement admin interface

This commit is contained in:
Marcus Kida 2026-02-10 10:31:38 +01:00
parent afa8f9e9f2
commit d887261305
5 changed files with 1106 additions and 4 deletions

110
admin.go Normal file
View file

@ -0,0 +1,110 @@
package main
import (
"database/sql"
"encoding/json"
"net/http"
"strconv"
"strings"
)
func adminAuth(token string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "Bearer "+token {
next.ServeHTTP(w, r)
return
}
// For the HTML page, allow token via query param so it can be bookmarked
if r.URL.Query().Get("token") == token {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`))
})
}
func handleAdminPage() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "static/admin.html")
}
}
type adminRepeatersResponse struct {
Repeaters []Repeater `json:"repeaters"`
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
}
func handleAdminUpdateRepeater(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 rpt Repeater
if err := json.NewDecoder(r.Body).Decode(&rpt); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if rpt.ID <= 0 {
http.Error(w, `{"error":"missing repeater id"}`, http.StatusBadRequest)
return
}
if err := updateRepeater(db, rpt); err != nil {
http.Error(w, `{"error":"update failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
}
}
func handleAdminRepeaters(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed)
return
}
q := r.URL.Query()
page := 1
if v := q.Get("page"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
page = p
}
}
perPage := 50
if v := q.Get("per_page"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
perPage = p
}
}
if perPage > 200 {
perPage = 200
}
search := strings.TrimSpace(q.Get("q"))
repeaters, total, err := queryAdminRepeaters(db, search, page, perPage)
if err != nil {
http.Error(w, `{"error":"database query failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(adminRepeatersResponse{
Repeaters: repeaters,
Total: total,
Page: page,
PerPage: perPage,
})
}
}

View file

@ -27,6 +27,7 @@ services:
- JSON_PATH=/app/rptrs.json - JSON_PATH=/app/rptrs.json
- BMRPTRS_PATH=/app/bmrptrs.json - BMRPTRS_PATH=/app/bmrptrs.json
- LISTEN_ADDR=:8080 - LISTEN_ADDR=:8080
- ADMIN_TOKEN=adminparty
volumes: volumes:
go-mod-cache: go-mod-cache:

91
db.go
View file

@ -42,6 +42,7 @@ type Repeater struct {
Description string `json:"description"` Description string `json:"description"`
ImportFreqInconsistent bool `json:"import_freq_inconsistent"` ImportFreqInconsistent bool `json:"import_freq_inconsistent"`
Inactive bool `json:"inactive"` Inactive bool `json:"inactive"`
LastPolled *time.Time `json:"last_polled"`
} }
func openDB(dsn string) (*sql.DB, error) { func openDB(dsn string) (*sql.DB, error) {
@ -83,7 +84,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country, query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, ts_linked, trustee, ipsc_network, network, hotspot, status, color_code, ts_linked, trustee, ipsc_network, network, hotspot, status,
last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description, last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent import_freq_inconsistent, last_polled
FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam() FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam()
args := []interface{}{minLat, maxLat, minLng, maxLng} args := []interface{}{minLat, maxLat, minLng, maxLng}
@ -149,7 +150,7 @@ func queryRepeaters(db *sql.DB, minLat, maxLat, minLng, maxLng float64, band str
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status, &r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status,
&r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware, &r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description, &r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent); err != nil { &r.ImportFreqInconsistent, &r.LastPolled); err != nil {
return nil, err return nil, err
} }
r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold) r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold)
@ -251,6 +252,74 @@ func queryRepeatersInRadius(db *sql.DB, lat, lng, radiusKm float64, band string,
return results, nil return results, nil
} }
// Admin query: paginated list of all repeaters with optional search.
func queryAdminRepeaters(db *sql.DB, search string, page, perPage int) ([]Repeater, int, error) {
paramIdx := 0
nextParam := func() string {
paramIdx++
return fmt.Sprintf("$%d", paramIdx)
}
where := ""
var args []interface{}
if search != "" {
p := nextParam()
where = " WHERE callsign ILIKE " + p +
" OR city ILIKE " + p +
" OR state ILIKE " + p +
" OR country ILIKE " + p +
" OR network ILIKE " + p +
" OR id::text = " + nextParam()
pattern := "%" + search + "%"
args = append(args, pattern, search)
}
var total int
err := db.QueryRow("SELECT COUNT(*) FROM repeaters"+where, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
offset := (page - 1) * perPage
query := `SELECT id, callsign, freq_tx, freq_rx, freq_offset, band, lat, lng, city, state, country,
color_code, ts_linked, trustee, ipsc_network, network, hotspot, status,
last_seen, bm_status, bm_status_text, hardware, firmware, pep, agl, website, description,
import_freq_inconsistent, last_polled
FROM repeaters` + where + ` ORDER BY callsign LIMIT ` + nextParam() + ` OFFSET ` + nextParam()
args = append(args, perPage, offset)
rows, err := db.Query(query, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
threshold := time.Now().Add(-7 * 24 * time.Hour)
var results []Repeater
for rows.Next() {
var r Repeater
if err := rows.Scan(&r.ID, &r.Callsign, &r.FreqTx, &r.FreqRx, &r.FreqOffset, &r.Band,
&r.Lat, &r.Lng, &r.City, &r.State, &r.Country,
&r.ColorCode, &r.TsLinked, &r.Trustee,
&r.IpscNetwork, &r.Network, &r.Hotspot, &r.Status,
&r.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
&r.ImportFreqInconsistent, &r.LastPolled); err != nil {
return nil, 0, err
}
r.Inactive = r.LastSeen != nil && r.LastSeen.Before(threshold)
results = append(results, r)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if results == nil {
results = []Repeater{}
}
return results, total, nil
}
// routeDistances returns the perpendicular distance from a point to the nearest // routeDistances returns the perpendicular distance from a point to the nearest
// route segment, and the along-route distance from the route start to the // route segment, and the along-route distance from the route start to the
// projection point on that segment. // projection point on that segment.
@ -299,6 +368,24 @@ func distToSegmentWithParam(pLat, pLng, aLat, aLng, bLat, bLng float64) (dist, t
return math.Sqrt(dx*dx + dy*dy), t return math.Sqrt(dx*dx + dy*dy), t
} }
func updateRepeater(db *sql.DB, r Repeater) error {
_, err := db.Exec(`UPDATE repeaters SET
callsign=$2, freq_tx=$3, freq_rx=$4, freq_offset=$5, band=$6,
lat=$7, lng=$8, city=$9, state=$10, country=$11,
color_code=$12, ts_linked=$13, trustee=$14, ipsc_network=$15,
network=$16, hotspot=$17, status=$18,
hardware=$19, firmware=$20, pep=$21, agl=$22,
website=$23, description=$24
WHERE id=$1`,
r.ID, r.Callsign, r.FreqTx, r.FreqRx, r.FreqOffset, r.Band,
r.Lat, r.Lng, r.City, r.State, r.Country,
r.ColorCode, r.TsLinked, r.Trustee, r.IpscNetwork,
r.Network, r.Hotspot, r.Status,
r.Hardware, r.Firmware, r.Pep, r.Agl,
r.Website, r.Description)
return err
}
func haversineKm(lat1, lng1, lat2, lng2 float64) float64 { func haversineKm(lat1, lng1, lat2, lng2 float64) float64 {
const r = 6371.0 const r = 6371.0
dLat := (lat2 - lat1) * math.Pi / 180 dLat := (lat2 - lat1) * math.Pi / 180

10
main.go
View file

@ -36,6 +36,16 @@ func main() {
mux.HandleFunc("/api/repeaters", handleRepeaters(db)) mux.HandleFunc("/api/repeaters", handleRepeaters(db))
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db)) mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(db))
mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db)) mux.HandleFunc("/api/repeaters/route", handleRouteRepeaters(db))
if adminToken := os.Getenv("ADMIN_TOKEN"); adminToken != "" {
mux.HandleFunc("/admin/", handleAdminPage())
adminAPI := http.NewServeMux()
adminAPI.HandleFunc("/admin/api/repeaters", handleAdminRepeaters(db))
adminAPI.HandleFunc("/admin/api/repeaters/update", handleAdminUpdateRepeater(db))
mux.Handle("/admin/api/", adminAuth(adminToken, adminAPI))
log.Println("Admin interface enabled at /admin/")
}
mux.Handle("/", http.FileServer(http.Dir("static"))) mux.Handle("/", http.FileServer(http.Dir("static")))
log.Printf("Listening on %s", addr) log.Printf("Listening on %s", addr)

894
static/admin.html Normal file
View file

@ -0,0 +1,894 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DMRmap Admin</title>
<style>
:root {
--bg: #fff;
--bg-secondary: #f8f9fa;
--text: #333;
--text-muted: #666;
--text-faint: #999;
--border: #ddd;
--border-light: #eee;
--hover-bg: #f0f4ff;
--accent: #4CAF50;
--accent-hover: #43A047;
--input-bg: #fff;
--shadow: rgba(0, 0, 0, 0.08);
--tag-blue-bg: #e3f2fd;
--tag-blue-text: #1565c0;
--tag-red-bg: #ffebee;
--tag-red-text: #c62828;
--tag-green-bg: #e8f5e9;
--tag-green-text: #2e7d32;
--tag-gray-bg: #f5f5f5;
--tag-gray-text: #616161;
--danger: #ef5350;
--overlay-bg: rgba(0, 0, 0, 0.4);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1e1e2e;
--bg-secondary: #181825;
--text: #cdd6f4;
--text-muted: #a6adc8;
--text-faint: #6c7086;
--border: #45475a;
--border-light: #313244;
--hover-bg: #313244;
--input-bg: #313244;
--shadow: rgba(0, 0, 0, 0.3);
--tag-blue-bg: #1e3a5f;
--tag-blue-text: #89b4fa;
--tag-red-bg: #4a1c1c;
--tag-red-text: #f38ba8;
--tag-green-bg: #1c3a1c;
--tag-green-text: #a6e3a1;
--tag-gray-bg: #313244;
--tag-gray-text: #a6adc8;
--overlay-bg: rgba(0, 0, 0, 0.6);
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg-secondary);
color: var(--text);
min-height: 100vh;
}
.login-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.login-box {
background: var(--bg);
border-radius: 12px;
padding: 32px;
box-shadow: 0 2px 12px var(--shadow);
width: 340px;
}
.login-box h1 {
font-size: 18px;
margin-bottom: 16px;
}
.login-box input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
background: var(--input-bg);
color: var(--text);
outline: none;
}
.login-box input:focus { border-color: var(--accent); }
.login-box button {
width: 100%;
margin-top: 12px;
padding: 10px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.login-box button:hover { background: var(--accent-hover); }
.login-error {
color: var(--danger);
font-size: 12px;
margin-top: 8px;
display: none;
}
.admin-wrap { display: none; }
.topbar {
background: var(--bg);
border-bottom: 1px solid var(--border-light);
padding: 12px 24px;
display: flex;
align-items: center;
gap: 16px;
position: sticky;
top: 0;
z-index: 10;
}
.topbar h1 {
font-size: 16px;
font-weight: 700;
white-space: nowrap;
}
.search-input {
flex: 1;
max-width: 400px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
background: var(--input-bg);
color: var(--text);
outline: none;
}
.search-input:focus { border-color: var(--accent); }
.topbar-info {
margin-left: auto;
font-size: 12px;
color: var(--text-faint);
white-space: nowrap;
}
.logout-btn {
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
}
.logout-btn:hover { background: var(--hover-bg); }
.table-wrap {
overflow-x: auto;
padding: 16px 24px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: var(--bg);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 4px var(--shadow);
}
th {
background: var(--bg-secondary);
font-weight: 600;
text-align: left;
padding: 10px 12px;
white-space: nowrap;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
border-bottom: 2px solid var(--border-light);
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border-light);
white-space: nowrap;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
}
tr:hover td { background: var(--hover-bg); }
tr { cursor: pointer; }
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
}
.tag-2m { background: var(--tag-blue-bg); color: var(--tag-blue-text); }
.tag-70cm { background: var(--tag-red-bg); color: var(--tag-red-text); }
.tag-active { background: var(--tag-green-bg); color: var(--tag-green-text); }
.tag-inactive { background: var(--tag-red-bg); color: var(--tag-red-text); }
.tag-gray { background: var(--tag-gray-bg); color: var(--tag-gray-text); }
.tag-yes { background: var(--tag-blue-bg); color: var(--tag-blue-text); }
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px;
font-size: 13px;
color: var(--text-muted);
}
.pagination button {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
cursor: pointer;
font-size: 13px;
}
.pagination button:hover:not(:disabled) { background: var(--hover-bg); }
.pagination button:disabled {
opacity: 0.4;
cursor: default;
}
.empty-state {
text-align: center;
padding: 48px;
color: var(--text-faint);
font-size: 14px;
}
.loading {
text-align: center;
padding: 48px;
color: var(--text-faint);
}
a { color: var(--tag-blue-text); text-decoration: none; }
a:hover { text-decoration: underline; }
/* Detail panel overlay */
.detail-overlay {
display: none;
position: fixed;
inset: 0;
background: var(--overlay-bg);
z-index: 100;
}
.detail-panel {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 520px;
max-width: 100vw;
background: var(--bg);
box-shadow: -4px 0 24px var(--shadow);
z-index: 101;
display: none;
flex-direction: column;
overflow: hidden;
}
.detail-panel.open {
display: flex;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
flex-shrink: 0;
}
.detail-header h2 {
font-size: 16px;
font-weight: 700;
}
.detail-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 4px;
}
.detail-close:hover { background: var(--hover-bg); }
.detail-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.detail-field {
margin-bottom: 14px;
}
.detail-field label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted);
margin-bottom: 4px;
}
.detail-field input,
.detail-field textarea,
.detail-field select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
background: var(--input-bg);
color: var(--text);
outline: none;
font-family: inherit;
}
.detail-field input:focus,
.detail-field textarea:focus,
.detail-field select:focus {
border-color: var(--accent);
}
.detail-field textarea {
resize: vertical;
min-height: 60px;
}
.detail-field .readonly-value {
padding: 8px 0;
font-size: 13px;
color: var(--text-faint);
}
.detail-row {
display: flex;
gap: 12px;
}
.detail-row .detail-field {
flex: 1;
}
.detail-footer {
padding: 16px 20px;
border-top: 1px solid var(--border-light);
display: flex;
gap: 10px;
justify-content: flex-end;
flex-shrink: 0;
}
.btn-save {
padding: 8px 20px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-save:hover { background: var(--accent-hover); }
.btn-save:disabled {
opacity: 0.5;
cursor: default;
}
.btn-cancel {
padding: 8px 20px;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
color: var(--text-muted);
cursor: pointer;
}
.btn-cancel:hover { background: var(--hover-bg); }
.save-status {
font-size: 12px;
align-self: center;
margin-right: auto;
}
.save-status.success { color: var(--tag-green-text); }
.save-status.error { color: var(--danger); }
</style>
</head>
<body>
<div class="login-wrap" id="login-wrap">
<div class="login-box">
<h1>DMRmap Admin</h1>
<input type="password" id="token-input" placeholder="Admin token" autofocus />
<button id="login-btn">Login</button>
<div class="login-error" id="login-error">Invalid token</div>
</div>
</div>
<div class="admin-wrap" id="admin-wrap">
<div class="topbar">
<h1>DMRmap Admin</h1>
<input type="text" class="search-input" id="search-input" placeholder="Search callsign, city, country, network..." />
<span class="topbar-info" id="total-info"></span>
<button class="logout-btn" id="logout-btn">Logout</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Callsign</th>
<th>Band</th>
<th>TX</th>
<th>RX</th>
<th>Offset</th>
<th>CC</th>
<th>Network</th>
<th>City</th>
<th>State</th>
<th>Country</th>
<th>Status</th>
<th>Timeslots</th>
<th>Last Seen</th>
<th>BM Status</th>
<th>Hardware</th>
<th>Last Polled</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
<div id="empty-state" class="empty-state" style="display:none">No repeaters found.</div>
<div id="loading" class="loading">Loading...</div>
</div>
<div class="pagination" id="pagination" style="display:none">
<button id="prev-btn">Prev</button>
<span id="page-info"></span>
<button id="next-btn">Next</button>
</div>
</div>
<div class="detail-overlay" id="detail-overlay"></div>
<div class="detail-panel" id="detail-panel">
<div class="detail-header">
<h2 id="detail-title">Repeater</h2>
<button class="detail-close" id="detail-close">&times;</button>
</div>
<div class="detail-body" id="detail-body"></div>
<div class="detail-footer">
<span class="save-status" id="save-status"></span>
<button class="btn-cancel" id="detail-cancel">Cancel</button>
<button class="btn-save" id="detail-save">Save</button>
</div>
</div>
<script>
(function () {
"use strict";
var token = sessionStorage.getItem("admin_token") || "";
var currentPage = 1;
var perPage = 50;
var totalCount = 0;
var debounceTimer = null;
var currentRepeaters = [];
var loginWrap = document.getElementById("login-wrap");
var adminWrap = document.getElementById("admin-wrap");
var tokenInput = document.getElementById("token-input");
var loginBtn = document.getElementById("login-btn");
var loginError = document.getElementById("login-error");
var logoutBtn = document.getElementById("logout-btn");
var searchInput = document.getElementById("search-input");
var tableBody = document.getElementById("table-body");
var emptyState = document.getElementById("empty-state");
var loadingEl = document.getElementById("loading");
var totalInfo = document.getElementById("total-info");
var pagination = document.getElementById("pagination");
var prevBtn = document.getElementById("prev-btn");
var nextBtn = document.getElementById("next-btn");
var pageInfo = document.getElementById("page-info");
var detailOverlay = document.getElementById("detail-overlay");
var detailPanel = document.getElementById("detail-panel");
var detailTitle = document.getElementById("detail-title");
var detailBody = document.getElementById("detail-body");
var detailClose = document.getElementById("detail-close");
var detailCancel = document.getElementById("detail-cancel");
var detailSave = document.getElementById("detail-save");
var saveStatus = document.getElementById("save-status");
function escapeHtml(s) {
if (!s) return "";
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function formatDate(s) {
if (!s) return "";
return s.replace("T", " ").substring(0, 19);
}
function showAdmin() {
loginWrap.style.display = "none";
adminWrap.style.display = "block";
fetchRepeaters();
}
function showLogin() {
adminWrap.style.display = "none";
loginWrap.style.display = "flex";
loginError.style.display = "none";
}
function apiFetch(url, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
opts.headers["Authorization"] = "Bearer " + token;
return fetch(url, opts).then(function (resp) {
if (resp.status === 401) {
sessionStorage.removeItem("admin_token");
token = "";
showLogin();
throw new Error("unauthorized");
}
if (!resp.ok) throw new Error("HTTP " + resp.status);
return resp.json();
});
}
function fetchRepeaters() {
loadingEl.style.display = "";
emptyState.style.display = "none";
tableBody.innerHTML = "";
var q = searchInput.value.trim();
var params = new URLSearchParams({
page: currentPage,
per_page: perPage
});
if (q) params.set("q", q);
apiFetch("/admin/api/repeaters?" + params)
.then(function (data) {
loadingEl.style.display = "none";
totalCount = data.total;
totalInfo.textContent = data.total + " repeater" + (data.total !== 1 ? "s" : "") + " total";
currentRepeaters = data.repeaters;
renderTable(data.repeaters);
renderPagination(data.page, data.per_page, data.total);
})
.catch(function (err) {
if (err.message === "unauthorized") return;
loadingEl.textContent = "Error loading data.";
});
}
function renderTable(repeaters) {
tableBody.innerHTML = "";
if (!repeaters.length) {
emptyState.style.display = "";
pagination.style.display = "none";
return;
}
emptyState.style.display = "none";
repeaters.forEach(function (r, idx) {
var tr = document.createElement("tr");
var bandTag = r.band === "2m"
? '<span class="tag tag-2m">2m</span>'
: '<span class="tag tag-70cm">70cm</span>';
var statusTag = r.inactive
? '<span class="tag tag-inactive">Inactive</span>'
: '<span class="tag tag-active">' + escapeHtml(r.status) + '</span>';
var bmStatus = r.bm_status_text || (r.bm_status !== null ? "Code " + r.bm_status : "");
tr.innerHTML =
'<td>' + r.id + '</td>' +
'<td><strong>' + escapeHtml(r.callsign) + '</strong></td>' +
'<td>' + bandTag + '</td>' +
'<td>' + r.freq_tx.toFixed(4) + '</td>' +
'<td>' + (r.freq_rx ? r.freq_rx.toFixed(4) : '') + '</td>' +
'<td>' + escapeHtml(r.freq_offset) + '</td>' +
'<td>' + r.color_code + '</td>' +
'<td>' + (r.network ? '<span class="tag tag-gray">' + escapeHtml(r.network) + '</span>' : '') + '</td>' +
'<td>' + escapeHtml(r.city) + '</td>' +
'<td>' + escapeHtml(r.state) + '</td>' +
'<td>' + escapeHtml(r.country) + '</td>' +
'<td>' + statusTag + '</td>' +
'<td>' + escapeHtml(r.ts_linked) + '</td>' +
'<td>' + formatDate(r.last_seen) + '</td>' +
'<td>' + escapeHtml(bmStatus) + '</td>' +
'<td>' + escapeHtml(r.hardware) + '</td>' +
'<td>' + formatDate(r.last_polled) + '</td>';
tr.addEventListener("click", function () {
openDetail(idx);
});
tableBody.appendChild(tr);
});
}
function renderPagination(page, pp, total) {
var totalPages = Math.ceil(total / pp) || 1;
if (totalPages <= 1) {
pagination.style.display = "none";
return;
}
pagination.style.display = "flex";
pageInfo.textContent = "Page " + page + " of " + totalPages;
prevBtn.disabled = page <= 1;
nextBtn.disabled = page >= totalPages;
}
// --- Detail panel ---
var editingRepeater = null;
var fields = [
{ key: "id", label: "ID", type: "readonly" },
{ key: "callsign", label: "Callsign", type: "text" },
{ key: "freq_tx", label: "TX Frequency", type: "number", step: "0.0001" },
{ key: "freq_rx", label: "RX Frequency", type: "number", step: "0.0001" },
{ key: "freq_offset", label: "Frequency Offset", type: "text" },
{ key: "band", label: "Band", type: "select", options: ["2m", "70cm"] },
{ key: "lat", label: "Latitude", type: "number", step: "0.000001" },
{ key: "lng", label: "Longitude", type: "number", step: "0.000001" },
{ key: "city", label: "City", type: "text" },
{ key: "state", label: "State", type: "text" },
{ key: "country", label: "Country", type: "text" },
{ key: "color_code", label: "Color Code", type: "number", step: "1" },
{ key: "ts_linked", label: "Timeslots Linked", type: "text" },
{ key: "trustee", label: "Trustee", type: "text" },
{ key: "ipsc_network", label: "IPSC Network", type: "text" },
{ key: "network", label: "Network", type: "text" },
{ key: "hotspot", label: "Hotspot", type: "select", options: [{ v: 0, l: "No" }, { v: 1, l: "Yes" }] },
{ key: "status", label: "Status", type: "text" },
{ key: "last_seen", label: "Last Seen", type: "readonly" },
{ key: "bm_status", label: "BM Status Code", type: "readonly" },
{ key: "bm_status_text", label: "BM Status Text", type: "readonly" },
{ key: "hardware", label: "Hardware", type: "text" },
{ key: "firmware", label: "Firmware", type: "text" },
{ key: "pep", label: "Power (W)", type: "number", step: "1" },
{ key: "agl", label: "Antenna Height (m)", type: "number", step: "1" },
{ key: "website", label: "Website", type: "text" },
{ key: "description", label: "Description", type: "textarea" },
{ key: "import_freq_inconsistent", label: "Frequency Inconsistency", type: "readonly" },
{ key: "last_polled", label: "Last Polled", type: "readonly" }
];
function openDetail(idx) {
var r = currentRepeaters[idx];
if (!r) return;
editingRepeater = JSON.parse(JSON.stringify(r));
detailTitle.textContent = r.callsign + " (#" + r.id + ")";
saveStatus.textContent = "";
saveStatus.className = "save-status";
detailSave.disabled = false;
var html = "";
for (var i = 0; i < fields.length; i++) {
var f = fields[i];
var val = r[f.key];
if (val === null || val === undefined) val = "";
html += '<div class="detail-field">';
html += '<label>' + escapeHtml(f.label) + '</label>';
if (f.type === "readonly") {
var display = val;
if (f.key === "last_seen" || f.key === "last_polled") display = formatDate(String(val));
if (f.key === "import_freq_inconsistent") display = val ? "Yes" : "No";
if (f.key === "bm_status") display = val !== "" && val !== null ? String(val) : "N/A";
html += '<div class="readonly-value">' + escapeHtml(String(display || "N/A")) + '</div>';
} else if (f.type === "select") {
html += '<select data-key="' + f.key + '">';
var opts = f.options;
for (var j = 0; j < opts.length; j++) {
var optVal, optLabel;
if (typeof opts[j] === "object") {
optVal = opts[j].v;
optLabel = opts[j].l;
} else {
optVal = opts[j];
optLabel = opts[j];
}
var sel = String(val) === String(optVal) ? " selected" : "";
html += '<option value="' + escapeHtml(String(optVal)) + '"' + sel + '>' + escapeHtml(String(optLabel)) + '</option>';
}
html += '</select>';
} else if (f.type === "textarea") {
html += '<textarea data-key="' + f.key + '" rows="3">' + escapeHtml(String(val)) + '</textarea>';
} else {
var inputType = f.type === "number" ? "number" : "text";
var stepAttr = f.step ? ' step="' + f.step + '"' : '';
html += '<input type="' + inputType + '"' + stepAttr + ' data-key="' + f.key + '" value="' + escapeHtml(String(val)) + '" />';
}
html += '</div>';
}
detailBody.innerHTML = html;
detailOverlay.style.display = "block";
detailPanel.classList.add("open");
}
function closeDetail() {
detailOverlay.style.display = "none";
detailPanel.classList.remove("open");
editingRepeater = null;
}
function collectFormData() {
if (!editingRepeater) return null;
var data = JSON.parse(JSON.stringify(editingRepeater));
var inputs = detailBody.querySelectorAll("[data-key]");
for (var i = 0; i < inputs.length; i++) {
var key = inputs[i].getAttribute("data-key");
var val = inputs[i].value;
var field = null;
for (var j = 0; j < fields.length; j++) {
if (fields[j].key === key) { field = fields[j]; break; }
}
if (!field) continue;
if (field.type === "number") {
if (field.step && field.step.indexOf(".") !== -1) {
data[key] = parseFloat(val) || 0;
} else {
data[key] = parseInt(val, 10) || 0;
}
} else if (field.type === "select" && typeof field.options[0] === "object") {
data[key] = parseInt(val, 10) || 0;
} else {
data[key] = val;
}
}
return data;
}
function saveDetail() {
var data = collectFormData();
if (!data) return;
detailSave.disabled = true;
saveStatus.textContent = "Saving...";
saveStatus.className = "save-status";
apiFetch("/admin/api/repeaters/update", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
})
.then(function () {
saveStatus.textContent = "Saved";
saveStatus.className = "save-status success";
detailSave.disabled = false;
fetchRepeaters();
})
.catch(function (err) {
if (err.message === "unauthorized") return;
saveStatus.textContent = "Error saving";
saveStatus.className = "save-status error";
detailSave.disabled = false;
});
}
detailClose.addEventListener("click", closeDetail);
detailCancel.addEventListener("click", closeDetail);
detailOverlay.addEventListener("click", closeDetail);
detailSave.addEventListener("click", saveDetail);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && detailPanel.classList.contains("open")) {
closeDetail();
}
});
// Login
function doLogin() {
var t = tokenInput.value.trim();
if (!t) return;
token = t;
sessionStorage.setItem("admin_token", t);
loginError.style.display = "none";
apiFetch("/admin/api/repeaters?per_page=1")
.then(function () {
showAdmin();
})
.catch(function () {
loginError.style.display = "";
token = "";
sessionStorage.removeItem("admin_token");
});
}
loginBtn.addEventListener("click", doLogin);
tokenInput.addEventListener("keydown", function (e) {
if (e.key === "Enter") doLogin();
});
// Logout
logoutBtn.addEventListener("click", function () {
token = "";
sessionStorage.removeItem("admin_token");
tokenInput.value = "";
showLogin();
});
// Search
searchInput.addEventListener("input", function () {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function () {
currentPage = 1;
fetchRepeaters();
}, 300);
});
// Pagination
prevBtn.addEventListener("click", function () {
if (currentPage > 1) {
currentPage--;
fetchRepeaters();
}
});
nextBtn.addEventListener("click", function () {
var totalPages = Math.ceil(totalCount / perPage) || 1;
if (currentPage < totalPages) {
currentPage++;
fetchRepeaters();
}
});
// Init
if (token) {
showAdmin();
} else {
showLogin();
}
})();
</script>
</body>
</html>