Add geo stats collection and improve admin dashboard UI
- Add geo IP lookup for listener locations (ip-api.com) - Add geo stats table with country flags to admin dashboard - Fix listener stats table alignment with proper centering - Fix Now Playing display to update without requiring audio playback - Add caching for geo lookups to reduce API calls
This commit is contained in:
parent
4be3b83da1
commit
c89e31b998
14
TODO.org
14
TODO.org
|
|
@ -25,18 +25,18 @@
|
||||||
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
1) [X] Liquidsoap is exposing its management console via telnet on the exterior network interface of b612.asteroid.radio
|
||||||
2) [X] icecast is also binding the external interface on b612, which it
|
2) [X] icecast is also binding the external interface on b612, which it
|
||||||
should not be. HAproxy is there to mediate this flow.
|
should not be. HAproxy is there to mediate this flow.
|
||||||
3) [ ] We're still on the built in i-lambdalite database
|
3) [X] We're still on the built in i-lambdalite database
|
||||||
4) [X] The templates still advertise the default administrator password,
|
4) [X] The templates still advertise the default administrator password,
|
||||||
which is no bueno.
|
which is no bueno.
|
||||||
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
5) [ ] We need to work out the TLS situation with letsencrypt, and
|
||||||
integrate it into HAproxy.
|
integrate it into HAproxy.
|
||||||
|
|
||||||
6) [ ] The administrative interface should be beefed up.
|
6) [ ] The administrative interface should be beefed up.
|
||||||
6.1) [ ] Deactivate users
|
6.1) [X] Deactivate users
|
||||||
6.2) [ ] Change user access permissions
|
6.2) [X] Change user access permissions
|
||||||
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
6.3) [ ] Listener statistics, breakdown by day/hour, new users, % changed &c
|
||||||
|
|
||||||
7) [ ] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
7) [X] When the player is paused, there are pretty serious stream sync issues in the form of stuttering for some time after the stream is unpaused.
|
||||||
8) [ ] User profile pages should probably be fleshed out.
|
8) [ ] User profile pages should probably be fleshed out.
|
||||||
9) [ ] the stream management features aren't there for Admins or DJs.
|
9) [ ] the stream management features aren't there for Admins or DJs.
|
||||||
10) [ ] The "Scan Library" feature is not working in the main branch
|
10) [ ] The "Scan Library" feature is not working in the main branch
|
||||||
|
|
@ -48,11 +48,11 @@
|
||||||
- [ ] strip hard coded configurations out of the system
|
- [ ] strip hard coded configurations out of the system
|
||||||
- [ ] add configuration template file to the project
|
- [ ] add configuration template file to the project
|
||||||
|
|
||||||
** [ ] Database [0/1]
|
** [X] Database [0/1]
|
||||||
- [-] PostgresQL [1/3]
|
- [-] PostgresQL [1/3]
|
||||||
- [X] Add a postgresql docker image to our docker-compose file.
|
- [X] Add a postgresql docker image to our docker-compose file.
|
||||||
- [ ] Configure radiance for postres.
|
- [X] Configure radiance for postres.
|
||||||
- [ ] Migrate all schema to new database.
|
- [X] Migrate all schema to new database.
|
||||||
|
|
||||||
|
|
||||||
** [X] Page Flow [2/2] ✅ COMPLETE
|
** [X] Page Flow [2/2] ✅ COMPLETE
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,13 @@
|
||||||
(defvar *active-listeners* (make-hash-table :test 'equal)
|
(defvar *active-listeners* (make-hash-table :test 'equal)
|
||||||
"Hash table tracking active listeners by IP hash")
|
"Hash table tracking active listeners by IP hash")
|
||||||
|
|
||||||
|
;;; Geo lookup cache (IP hash -> country code)
|
||||||
|
(defvar *geo-cache* (make-hash-table :test 'equal)
|
||||||
|
"Cache of IP hash to country code mappings")
|
||||||
|
|
||||||
|
(defvar *geo-cache-ttl* 3600
|
||||||
|
"Seconds to cache geo lookups (1 hour)")
|
||||||
|
|
||||||
;;; Utility Functions
|
;;; Utility Functions
|
||||||
|
|
||||||
(defun hash-ip-address (ip-address)
|
(defun hash-ip-address (ip-address)
|
||||||
|
|
@ -128,6 +135,30 @@
|
||||||
(when xml-string
|
(when xml-string
|
||||||
(extract-xml-sources xml-string)))
|
(extract-xml-sources xml-string)))
|
||||||
|
|
||||||
|
(defun fetch-icecast-listclients (mount)
|
||||||
|
"Fetch listener list for a specific mount from Icecast admin"
|
||||||
|
(handler-case
|
||||||
|
(let* ((url (format nil "http://localhost:8000/admin/listclients?mount=~a" mount))
|
||||||
|
(response (drakma:http-request url
|
||||||
|
:want-stream nil
|
||||||
|
:connection-timeout 5
|
||||||
|
:basic-authorization (list *icecast-admin-user*
|
||||||
|
*icecast-admin-pass*))))
|
||||||
|
(if (stringp response)
|
||||||
|
response
|
||||||
|
(babel:octets-to-string response :encoding :utf-8)))
|
||||||
|
(error (e)
|
||||||
|
(log:debug "Failed to fetch listclients for ~a: ~a" mount e)
|
||||||
|
nil)))
|
||||||
|
|
||||||
|
(defun extract-listener-ips (xml-string)
|
||||||
|
"Extract listener IPs from Icecast listclients XML"
|
||||||
|
(let ((ips nil)
|
||||||
|
(pattern "<IP>([^<]+)</IP>"))
|
||||||
|
(cl-ppcre:do-register-groups (ip) (pattern xml-string)
|
||||||
|
(push ip ips))
|
||||||
|
(nreverse ips)))
|
||||||
|
|
||||||
;;; Database Operations
|
;;; Database Operations
|
||||||
|
|
||||||
(defun store-listener-snapshot (mount listener-count)
|
(defun store-listener-snapshot (mount listener-count)
|
||||||
|
|
@ -178,6 +209,21 @@
|
||||||
(error (e)
|
(error (e)
|
||||||
(log:error "Session cleanup failed: ~a" e))))
|
(log:error "Session cleanup failed: ~a" e))))
|
||||||
|
|
||||||
|
(defun update-geo-stats (country-code listener-count)
|
||||||
|
"Update geo stats for today"
|
||||||
|
(when country-code
|
||||||
|
(handler-case
|
||||||
|
(with-db
|
||||||
|
(postmodern:execute
|
||||||
|
(format nil "INSERT INTO listener_geo_stats (date, country_code, listener_count, listen_minutes)
|
||||||
|
VALUES (CURRENT_DATE, '~a', ~a, 1)
|
||||||
|
ON CONFLICT (date, country_code)
|
||||||
|
DO UPDATE SET listener_count = listener_geo_stats.listener_count + ~a,
|
||||||
|
listen_minutes = listener_geo_stats.listen_minutes + 1"
|
||||||
|
country-code listener-count listener-count)))
|
||||||
|
(error (e)
|
||||||
|
(log:error "Failed to update geo stats: ~a" e)))))
|
||||||
|
|
||||||
;;; Statistics Aggregation
|
;;; Statistics Aggregation
|
||||||
;;; Note: Complex aggregation queries use raw SQL via postmodern:execute
|
;;; Note: Complex aggregation queries use raw SQL via postmodern:execute
|
||||||
|
|
||||||
|
|
@ -299,6 +345,36 @@
|
||||||
|
|
||||||
;;; Polling Service
|
;;; Polling Service
|
||||||
|
|
||||||
|
(defun get-cached-geo (ip)
|
||||||
|
"Get cached geo data for IP, or lookup and cache"
|
||||||
|
(let* ((ip-hash (hash-ip-address ip))
|
||||||
|
(cached (gethash ip-hash *geo-cache*)))
|
||||||
|
(if (and cached (< (- (get-universal-time) (getf cached :time)) *geo-cache-ttl*))
|
||||||
|
(getf cached :country)
|
||||||
|
;; Lookup and cache
|
||||||
|
(let ((geo (lookup-geoip ip)))
|
||||||
|
(when geo
|
||||||
|
(let ((country (getf geo :country-code)))
|
||||||
|
(setf (gethash ip-hash *geo-cache*)
|
||||||
|
(list :country country :time (get-universal-time)))
|
||||||
|
country))))))
|
||||||
|
|
||||||
|
(defun collect-geo-stats-for-mount (mount)
|
||||||
|
"Collect geo stats for all listeners on a mount"
|
||||||
|
(let ((listclients-xml (fetch-icecast-listclients mount)))
|
||||||
|
(when listclients-xml
|
||||||
|
(let ((ips (extract-listener-ips listclients-xml))
|
||||||
|
(country-counts (make-hash-table :test 'equal)))
|
||||||
|
;; Group by country
|
||||||
|
(dolist (ip ips)
|
||||||
|
(let ((country (get-cached-geo ip)))
|
||||||
|
(when country
|
||||||
|
(incf (gethash country country-counts 0)))))
|
||||||
|
;; Store each country's count
|
||||||
|
(maphash (lambda (country count)
|
||||||
|
(update-geo-stats country count))
|
||||||
|
country-counts)))))
|
||||||
|
|
||||||
(defun poll-and-store-stats ()
|
(defun poll-and-store-stats ()
|
||||||
"Single poll iteration: fetch stats and store"
|
"Single poll iteration: fetch stats and store"
|
||||||
(let ((stats (fetch-icecast-stats)))
|
(let ((stats (fetch-icecast-stats)))
|
||||||
|
|
@ -309,6 +385,9 @@
|
||||||
(listeners (getf source :listeners)))
|
(listeners (getf source :listeners)))
|
||||||
(when mount
|
(when mount
|
||||||
(store-listener-snapshot mount listeners)
|
(store-listener-snapshot mount listeners)
|
||||||
|
;; Collect geo stats if there are listeners
|
||||||
|
(when (and listeners (> listeners 0))
|
||||||
|
(collect-geo-stats-for-mount mount))
|
||||||
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
|
(log:debug "Stored snapshot: ~a = ~a listeners" mount listeners))))))))
|
||||||
|
|
||||||
(defun stats-polling-loop ()
|
(defun stats-polling-loop ()
|
||||||
|
|
|
||||||
|
|
@ -367,16 +367,11 @@
|
||||||
|
|
||||||
;; Live stream info update
|
;; Live stream info update
|
||||||
(defun update-live-stream-info ()
|
(defun update-live-stream-info ()
|
||||||
;; Don't update if stream is paused
|
|
||||||
(let ((live-audio (ps:chain document (get-element-by-id "live-stream-audio"))))
|
|
||||||
(when (and live-audio (ps:@ live-audio paused))
|
|
||||||
(return)))
|
|
||||||
|
|
||||||
(ps:chain
|
(ps:chain
|
||||||
(fetch "/api/asteroid/partial/now-playing-inline")
|
(fetch "/api/asteroid/partial/now-playing-inline")
|
||||||
(then (lambda (response)
|
(then (lambda (response)
|
||||||
(let ((content-type (ps:chain response headers (get "content-type"))))
|
(let ((content-type (ps:chain response headers (get "content-type"))))
|
||||||
(unless (ps:chain content-type (includes "text/plain"))
|
(unless (and content-type (ps:chain content-type (includes "text/plain")))
|
||||||
(ps:chain console (error "Unexpected content type:" content-type))
|
(ps:chain console (error "Unexpected content type:" content-type))
|
||||||
(return))
|
(return))
|
||||||
(ps:chain response (text)))))
|
(ps:chain response (text)))))
|
||||||
|
|
|
||||||
|
|
@ -1230,6 +1230,46 @@ body .stat-card .stat-detail{
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body .stat-card .listener-stats-table{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #111;
|
||||||
|
border: 1px solid #333;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .stat-card .listener-stats-table th{
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #00ff00;
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #333;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .stat-card .listener-stats-table td{
|
||||||
|
padding: 12px 20px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #333;
|
||||||
|
vertical-align: middle;
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .stat-card .listener-stats-table .stat-number{
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00ffff;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body .stat-card .listener-stats-table .stat-peak-row{
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
body.persistent-player-container{
|
body.persistent-player-container{
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 10px;;
|
padding: 10px;;
|
||||||
|
|
|
||||||
|
|
@ -982,7 +982,39 @@
|
||||||
|
|
||||||
(.stat-detail :color "#888"
|
(.stat-detail :color "#888"
|
||||||
:font-size 0.75rem
|
:font-size 0.75rem
|
||||||
:margin-top 0.25rem))
|
:margin-top 0.25rem)
|
||||||
|
|
||||||
|
(.listener-stats-table
|
||||||
|
:width 100%
|
||||||
|
:border-collapse collapse
|
||||||
|
:background "#111"
|
||||||
|
:border "1px solid #333"
|
||||||
|
:table-layout fixed
|
||||||
|
|
||||||
|
(th :background "#1a1a1a"
|
||||||
|
:color "#00ff00"
|
||||||
|
:padding "12px 20px"
|
||||||
|
:text-align center
|
||||||
|
:border "1px solid #333"
|
||||||
|
:font-size "0.9rem"
|
||||||
|
:width "25%")
|
||||||
|
|
||||||
|
(td :padding "12px 20px"
|
||||||
|
:text-align "center"
|
||||||
|
:border "1px solid #333"
|
||||||
|
:vertical-align "middle"
|
||||||
|
:width "25%")
|
||||||
|
|
||||||
|
(.stat-number :font-size 1.75rem
|
||||||
|
:font-weight bold
|
||||||
|
:color "#00ffff"
|
||||||
|
:display block
|
||||||
|
:text-align "center")
|
||||||
|
|
||||||
|
(.stat-peak-row
|
||||||
|
:font-size 0.8rem
|
||||||
|
:color "#888"
|
||||||
|
:background "#0a0a0a")))
|
||||||
|
|
||||||
;; Center alignment for player page
|
;; Center alignment for player page
|
||||||
;; (body.player-page
|
;; (body.player-page
|
||||||
|
|
|
||||||
|
|
@ -44,37 +44,58 @@
|
||||||
|
|
||||||
<!-- Listener Statistics -->
|
<!-- Listener Statistics -->
|
||||||
<div class="admin-section">
|
<div class="admin-section">
|
||||||
<h2>📊 Listener Statistics</h2>
|
<h2>📊 Current Listeners</h2>
|
||||||
<div class="admin-grid" id="listener-stats-grid">
|
<table class="listener-stats-table" style="table-layout: fixed; width: 100%;">
|
||||||
<div class="status-card">
|
<colgroup>
|
||||||
<h3>🎵 asteroid.mp3</h3>
|
<col style="width: 25%;">
|
||||||
<p class="stat-number" id="listeners-mp3">0</p>
|
<col style="width: 25%;">
|
||||||
<p class="stat-label">Current Listeners</p>
|
<col style="width: 25%;">
|
||||||
<p class="stat-detail">Peak: <span id="peak-mp3">0</span></p>
|
<col style="width: 25%;">
|
||||||
</div>
|
</colgroup>
|
||||||
<div class="status-card">
|
<thead>
|
||||||
<h3>🎧 asteroid.aac</h3>
|
<tr>
|
||||||
<p class="stat-number" id="listeners-aac">0</p>
|
<th>🎵 MP3</th>
|
||||||
<p class="stat-label">Current Listeners</p>
|
<th>🎧 AAC</th>
|
||||||
<p class="stat-detail">Peak: <span id="peak-aac">0</span></p>
|
<th>📱 Low</th>
|
||||||
</div>
|
<th>📈 Total</th>
|
||||||
<div class="status-card">
|
</tr>
|
||||||
<h3>📱 asteroid-low.mp3</h3>
|
</thead>
|
||||||
<p class="stat-number" id="listeners-low">0</p>
|
<tbody>
|
||||||
<p class="stat-label">Current Listeners</p>
|
<tr>
|
||||||
<p class="stat-detail">Peak: <span id="peak-low">0</span></p>
|
<td style="text-align: center;"><span class="stat-number" id="listeners-mp3">0</span></td>
|
||||||
</div>
|
<td style="text-align: center;"><span class="stat-number" id="listeners-aac">0</span></td>
|
||||||
<div class="status-card">
|
<td style="text-align: center;"><span class="stat-number" id="listeners-low">0</span></td>
|
||||||
<h3>📈 Total Listeners</h3>
|
<td style="text-align: center;"><span class="stat-number" id="listeners-total">0</span></td>
|
||||||
<p class="stat-number" id="listeners-total">0</p>
|
</tr>
|
||||||
<p class="stat-label">All Streams</p>
|
<tr class="stat-peak-row">
|
||||||
<p class="stat-detail">Updated: <span id="stats-updated">--</span></p>
|
<td style="text-align: center;">Peak: <span id="peak-mp3">0</span></td>
|
||||||
</div>
|
<td style="text-align: center;">Peak: <span id="peak-aac">0</span></td>
|
||||||
</div>
|
<td style="text-align: center;">Peak: <span id="peak-low">0</span></td>
|
||||||
<div class="admin-controls" style="margin-top: 15px;">
|
<td style="text-align: center;">Updated: <span id="stats-updated">--</span></td>
|
||||||
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh Stats</button>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="admin-controls" style="margin-top: 10px;">
|
||||||
|
<button id="refresh-stats" class="btn btn-secondary" onclick="refreshListenerStats()">🔄 Refresh</button>
|
||||||
<span id="stats-status" style="margin-left: 15px;"></span>
|
<span id="stats-status" style="margin-left: 15px;"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Geo Stats -->
|
||||||
|
<h3 style="margin-top: 20px;">🌍 Listener Locations (Last 7 Days)</h3>
|
||||||
|
<div id="geo-stats-container">
|
||||||
|
<table class="listener-stats-table" id="geo-stats-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Country</th>
|
||||||
|
<th>Listeners</th>
|
||||||
|
<th>Minutes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="geo-stats-body">
|
||||||
|
<tr><td colspan="3" style="color: #888;">Loading...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Music Library Management -->
|
<!-- Music Library Management -->
|
||||||
|
|
@ -273,12 +294,54 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Country code to flag emoji
|
||||||
|
function countryToFlag(countryCode) {
|
||||||
|
if (!countryCode || countryCode.length !== 2) return '🌍';
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map(char => 127397 + char.charCodeAt(0));
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and display geo stats
|
||||||
|
function refreshGeoStats() {
|
||||||
|
fetch('/api/asteroid/stats/geo?days=7')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
const data = result.data || result;
|
||||||
|
const tbody = document.getElementById('geo-stats-body');
|
||||||
|
|
||||||
|
if (data.status === 'success' && data.geo && data.geo.length > 0) {
|
||||||
|
tbody.innerHTML = data.geo.map(item => {
|
||||||
|
const country = item.country_code || item[0];
|
||||||
|
const listeners = item.total_listeners || item[1] || 0;
|
||||||
|
const minutes = item.total_minutes || item[2] || 0;
|
||||||
|
return `<tr>
|
||||||
|
<td>${countryToFlag(country)} ${country}</td>
|
||||||
|
<td>${listeners}</td>
|
||||||
|
<td>${minutes}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" style="color: #888;">No geo data yet</td></tr>';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error fetching geo stats:', error);
|
||||||
|
document.getElementById('geo-stats-body').innerHTML =
|
||||||
|
'<tr><td colspan="3" style="color: #ff6666;">Error loading geo data</td></tr>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-refresh stats every 30 seconds
|
// Auto-refresh stats every 30 seconds
|
||||||
setInterval(refreshListenerStats, 30000);
|
setInterval(refreshListenerStats, 30000);
|
||||||
|
setInterval(refreshGeoStats, 60000);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
refreshListenerStats();
|
refreshListenerStats();
|
||||||
|
refreshGeoStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin password reset handler
|
// Admin password reset handler
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue