Implement admin interface
This commit is contained in:
parent
afa8f9e9f2
commit
d887261305
5 changed files with 1106 additions and 4 deletions
110
admin.go
Normal file
110
admin.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ services:
|
|||
- JSON_PATH=/app/rptrs.json
|
||||
- BMRPTRS_PATH=/app/bmrptrs.json
|
||||
- LISTEN_ADDR=:8080
|
||||
- ADMIN_TOKEN=adminparty
|
||||
|
||||
volumes:
|
||||
go-mod-cache:
|
||||
|
|
|
|||
91
db.go
91
db.go
|
|
@ -42,6 +42,7 @@ type Repeater struct {
|
|||
Description string `json:"description"`
|
||||
ImportFreqInconsistent bool `json:"import_freq_inconsistent"`
|
||||
Inactive bool `json:"inactive"`
|
||||
LastPolled *time.Time `json:"last_polled"`
|
||||
}
|
||||
|
||||
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,
|
||||
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
|
||||
import_freq_inconsistent, last_polled
|
||||
FROM repeaters WHERE lat BETWEEN ` + nextParam() + ` AND ` + nextParam() + ` AND lng BETWEEN ` + nextParam() + ` AND ` + nextParam()
|
||||
|
||||
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.LastSeen, &r.BmStatus, &r.BmStatusText, &r.Hardware,
|
||||
&r.Firmware, &r.Pep, &r.Agl, &r.Website, &r.Description,
|
||||
&r.ImportFreqInconsistent); err != nil {
|
||||
&r.ImportFreqInconsistent, &r.LastPolled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// route segment, and the along-route distance from the route start to the
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
const r = 6371.0
|
||||
dLat := (lat2 - lat1) * math.Pi / 180
|
||||
|
|
|
|||
10
main.go
10
main.go
|
|
@ -36,6 +36,16 @@ func main() {
|
|||
mux.HandleFunc("/api/repeaters", handleRepeaters(db))
|
||||
mux.HandleFunc("/api/repeaters/radius", handleRadiusRepeaters(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")))
|
||||
|
||||
log.Printf("Listening on %s", addr)
|
||||
|
|
|
|||
894
static/admin.html
Normal file
894
static/admin.html
Normal 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">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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>
|
||||
Loading…
Reference in a new issue