diff --git a/internal/store/command_log.go b/internal/store/command_log.go index 7306b63..f243ddf 100644 --- a/internal/store/command_log.go +++ b/internal/store/command_log.go @@ -104,6 +104,47 @@ func (s *Store) ListCommandLogSessions(ctx context.Context, repeaterID int64, li return groups, rows.Err() } +// OwnerCommandLogEntry is a recent command across all repeaters a user owns, +// joined with the repeater's name and the sender's name (for the dashboard). +type OwnerCommandLogEntry struct { + RepeaterID int64 + RepeaterName string + SenderName string + CommandText string + SentAt time.Time + AckReceived bool + ResponseText *string +} + +// ListRecentCommandsForOwner returns the most recent commands run on any +// repeater owned by ownerID, newest first, bounded by limit. +func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, limit int) ([]OwnerCommandLogEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT l.repeater_id, r.name, + COALESCE(NULLIF(u.display_name, ''), u.username, '(deleted)'), + l.command_text, l.sent_at, l.ack_received, l.response_text + FROM command_log l + JOIN repeaters r ON r.id = l.repeater_id + LEFT JOIN users u ON u.id = l.user_id + WHERE r.owner_id = $1 + ORDER BY l.sent_at DESC + LIMIT $2`, ownerID, limit) + if err != nil { + return nil, fmt.Errorf("list owner commands: %w", err) + } + defer rows.Close() + var out []OwnerCommandLogEntry + for rows.Next() { + var e OwnerCommandLogEntry + if err := rows.Scan(&e.RepeaterID, &e.RepeaterName, &e.SenderName, + &e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText); err != nil { + return nil, fmt.Errorf("scan owner command: %w", err) + } + out = append(out, e) + } + return out, rows.Err() +} + // ListCommandLog returns recent log entries for a repeater, newest first. func (s *Store) ListCommandLog(ctx context.Context, repeaterID int64, limit int) ([]*CommandLogEntry, error) { rows, err := s.pool.Query(ctx, ` diff --git a/internal/web/orgs.go b/internal/web/orgs.go index b2f0334..7210923 100644 --- a/internal/web/orgs.go +++ b/internal/web/orgs.go @@ -107,23 +107,32 @@ func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) { http.Error(w, "could not load repeaters", http.StatusInternalServerError) return } - hasMap := false + mapped := 0 for _, rp := range repeaters { if rp.HasLocation { - hasMap = true - break + mapped++ + } + } + adminCount := 0 + for _, m := range members { + if m.Role == "admin" { + adminCount++ } } isAdmin := role == "admin" data := map[string]any{ - "Org": org, - "Role": role, - "IsAdmin": isAdmin, - "Members": members, - "Repeaters": repeaters, - "HasMap": hasMap, - "Self": uid, - "Error": r.URL.Query().Get("error"), + "Org": org, + "Role": role, + "IsAdmin": isAdmin, + "Members": members, + "Repeaters": repeaters, + "HasMap": mapped > 0, + "MemberCount": len(members), + "RepeaterCount": len(repeaters), + "AdminCount": adminCount, + "MappedCount": mapped, + "Self": uid, + "Error": r.URL.Query().Get("error"), } if isAdmin { invites, err := s.store.ListOrgInvites(r.Context(), id) diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 463cb3c..ad43e85 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -41,10 +41,24 @@ code.pubkey { .ev-sent { color: var(--tblr-azure); } .ev-reply { color: var(--tblr-body-color); white-space: pre-wrap; } -/* ---------- Leaflet map ---------- */ +/* ---------- Leaflet map (dark) ---------- */ #map { height: 360px; border-radius: var(--tblr-border-radius); border: 1px solid var(--tblr-border-color); } -.leaflet-popup-content { color: #111; } +.leaflet-container { background: #14171c; } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: var(--tblr-bg-surface, #1a1d24); + color: var(--tblr-body-color, #e6edf3); + box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4)); +} +.leaflet-popup-content { color: var(--tblr-body-color, #e6edf3); } +.leaflet-bar a, +.leaflet-control-zoom a { + background: var(--tblr-bg-surface, #1a1d24); + color: var(--tblr-body-color, #e6edf3); + border-bottom-color: var(--tblr-border-color, #2b2f36); +} +.leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary, #22262e); } diff --git a/internal/web/static/meshmap.js b/internal/web/static/meshmap.js new file mode 100644 index 0000000..8a69954 --- /dev/null +++ b/internal/web/static/meshmap.js @@ -0,0 +1,24 @@ +// meshMap renders a dark-mode Leaflet map of points into the element with the +// given id. pts is an array of {name, lat, lon}. Does nothing if pts is empty. +function meshMap(elId, pts) { + if (!pts || !pts.length) return; + var map = L.map(elId, { scrollWheelZoom: false }); + // CARTO "dark matter" basemap (OpenStreetMap data) for a dark UI. + L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", { + maxZoom: 19, + subdomains: "abcd", + attribution: "© OpenStreetMap © CARTO", + }).addTo(map); + var group = L.featureGroup( + pts.map(function (p) { + return L.circleMarker([p.lat, p.lon], { + radius: 7, + color: "#4dabf7", + weight: 2, + fillColor: "#4dabf7", + fillOpacity: 0.6, + }).bindPopup(p.name); + }) + ).addTo(map); + map.fitBounds(group.getBounds().pad(0.3)); +} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 6ca54ab..276100b 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -16,12 +16,10 @@ -
-