0.2.45 - Server side git commits
This commit is contained in:
@@ -26,8 +26,8 @@ const adminCookieName = "memby_admin"
|
||||
// a valid session, so the only thing it can extend is its own sign-in.
|
||||
const adminActivityHeader = "X-Memby-Admin-Active"
|
||||
|
||||
// adminRoutes is the operator interface: library imports, the maintenance switch, and
|
||||
// row engagement. Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
|
||||
// adminRoutes is the operator interface: library imports, maintenance and analytics.
|
||||
// Disabled entirely when MEMBY_ADMIN_TOKEN is unset, so it cannot be
|
||||
// left exposed by accident.
|
||||
func (s *Server) adminRoutes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
@@ -57,6 +57,7 @@ func (s *Server) adminRoutes() http.Handler {
|
||||
mux.Handle("PUT /admin/api/accounts/{userID}/themes", s.adminAuth(s.handleAdminUserThemes))
|
||||
mux.Handle("GET /admin/api/recommendations", s.adminAuth(s.handleAdminRecommendations))
|
||||
mux.Handle("GET /admin/api/analytics", s.adminAuth(s.handleAdminAnalytics))
|
||||
mux.Handle("GET /admin/api/journeys", s.adminAuth(s.handleAdminJourneys))
|
||||
mux.Handle("GET /admin/api/searches", s.adminAuth(s.handleAdminSearches))
|
||||
mux.Handle("GET /admin/api/events", s.adminAuth(s.handleAdminEvents))
|
||||
mux.Handle("GET /admin/api/runtime", s.adminAuth(s.handleAdminRuntime))
|
||||
@@ -720,6 +721,8 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, "could not read analytics")
|
||||
return
|
||||
}
|
||||
// Keep the journey fields on this older endpoint for scripts written before journeys
|
||||
// gained their own page and endpoint. The console itself only reads rows from here.
|
||||
users, err := s.store.AnalyticsUsers(r.Context(), since)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("user analytics failed", "error", err)
|
||||
@@ -733,7 +736,7 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
|
||||
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
|
||||
if featureErr != nil || pathErr != nil || eventErr != nil {
|
||||
s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID)
|
||||
s.loggerFor(r.Context()).Error("legacy user journey read failed", "user_id", userID)
|
||||
writeError(w, http.StatusInternalServerError, "could not read user journey")
|
||||
return
|
||||
}
|
||||
@@ -745,6 +748,42 @@ func (s *Server) handleAdminAnalytics(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminJourneys(w http.ResponseWriter, r *http.Request) {
|
||||
days := queryInt(r, "days", 30, 90)
|
||||
since := time.Now().UTC().AddDate(0, 0, -days)
|
||||
userID := strings.TrimSpace(r.URL.Query().Get("userId"))
|
||||
users, err := s.store.AnalyticsUsers(r.Context(), since)
|
||||
if err != nil {
|
||||
s.loggerFor(r.Context()).Error("journey users failed", "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not read journeys")
|
||||
return
|
||||
}
|
||||
stats, statsErr := s.store.JourneyStats(r.Context(), userID, since)
|
||||
features, featureErr := s.store.UserFeatureStats(r.Context(), userID, since)
|
||||
paths, pathErr := s.store.UserPaths(r.Context(), userID, since)
|
||||
actions, actionErr := s.store.JourneyActionStats(r.Context(), userID, since)
|
||||
if statsErr != nil || featureErr != nil || pathErr != nil || actionErr != nil {
|
||||
s.loggerFor(r.Context()).Error("journey analytics read failed", "user_id", userID)
|
||||
writeError(w, http.StatusInternalServerError, "could not read journeys")
|
||||
return
|
||||
}
|
||||
payload := map[string]any{
|
||||
"days": days, "retentionDays": int(s.cfg.AnalyticsRetention / (24 * time.Hour)),
|
||||
"users": users, "stats": stats, "features": features, "paths": paths, "actions": actions,
|
||||
}
|
||||
if userID != "" {
|
||||
events, eventErr := s.store.UserJourneyEvents(r.Context(), userID, since, 1000)
|
||||
if eventErr != nil {
|
||||
s.loggerFor(r.Context()).Error("user journey read failed", "user_id", userID)
|
||||
writeError(w, http.StatusInternalServerError, "could not read user journey")
|
||||
return
|
||||
}
|
||||
payload["userId"] = userID
|
||||
payload["events"] = events
|
||||
}
|
||||
writeJSON(w, http.StatusOK, payload)
|
||||
}
|
||||
|
||||
// syncerHandle is the slice of the syncer the API needs, so api does not depend on the
|
||||
// concrete type for testing.
|
||||
type syncerHandle interface {
|
||||
|
||||
@@ -25,53 +25,3 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="people" data-icon-tone="info">User journeys</h2>
|
||||
<p class="card-note">Choose an Emby profile to review feature use, common paths and
|
||||
significant actions in time order. Search text, content titles and setting values
|
||||
are not stored in journey analytics.</p>
|
||||
</div>
|
||||
<label class="field narrow"><span>User</span>
|
||||
<select id="engagement-user"><option value="">Choose a user</option></select></label>
|
||||
</div>
|
||||
<div class="tiles" id="engagement-user-tiles"></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="engagement-feature-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
|
||||
<p class="card-note">Rare and unused features are shown explicitly against Memby's
|
||||
major feature catalogue.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
|
||||
<tbody id="engagement-features"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="engagement-path-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
|
||||
<p class="card-note">Repeated transitions reveal routes into playback and places a
|
||||
viewer commonly leaves a flow.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
|
||||
<tbody id="engagement-paths"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="engagement-journey-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journey</h2>
|
||||
<p class="card-note">Newest journeys first; actions within each journey run from start
|
||||
to finish. A journey without an end event indicates an interruption or abandonment.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
|
||||
<tbody id="engagement-events"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
const { fmt, ui, $ } = Admin;
|
||||
|
||||
const featureCatalogue = [
|
||||
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
|
||||
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
|
||||
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
|
||||
];
|
||||
|
||||
const label = (value) => {
|
||||
const text = String(value || '—').replaceAll('_', ' ');
|
||||
if (text === 'favorites') return 'Favourites';
|
||||
@@ -13,65 +7,8 @@ const label = (value) => {
|
||||
return text;
|
||||
};
|
||||
|
||||
function renderUser(payload) {
|
||||
const selected = $('engagement-user').value;
|
||||
const users = payload.users || [];
|
||||
const current = users.find((user) => user.userId === selected);
|
||||
$('engagement-user-tiles').innerHTML = current ? ui.tiles([
|
||||
['events', fmt.number(current.events), { icon: 'pulse', tone: 'info' }],
|
||||
['journeys', fmt.number(current.journeys), { icon: 'list', tone: 'data' }],
|
||||
['last active', fmt.when(current.lastActiveAt), { icon: 'clock', tone: 'note', small: true }],
|
||||
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
|
||||
]) : '';
|
||||
|
||||
['feature', 'path', 'journey'].forEach((name) => {
|
||||
$('engagement-' + name + '-card').hidden = !current;
|
||||
});
|
||||
if (!current) return;
|
||||
|
||||
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
|
||||
const features = [...new Set([...featureCatalogue, ...used.keys()])];
|
||||
$('engagement-features').innerHTML = features.map((name) => {
|
||||
const stat = used.get(name);
|
||||
const uses = stat?.uses || 0;
|
||||
const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok');
|
||||
return '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
|
||||
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
const paths = payload.paths || [];
|
||||
$('engagement-paths').innerHTML = paths.length ? paths.map((path) =>
|
||||
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
|
||||
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
|
||||
: ui.emptyRow(3, 'No repeated paths in this window.');
|
||||
|
||||
const grouped = new Map();
|
||||
(payload.events || []).forEach((event) => {
|
||||
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
|
||||
grouped.get(event.journeyId).push(event);
|
||||
});
|
||||
let journeyNumber = grouped.size;
|
||||
const rows = [];
|
||||
grouped.forEach((events) => {
|
||||
events.sort((a, b) => a.sequence - b.sequence);
|
||||
const number = journeyNumber--;
|
||||
events.forEach((event) => {
|
||||
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
|
||||
: label(event.target || event.screen);
|
||||
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
|
||||
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
|
||||
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
|
||||
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
|
||||
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
|
||||
});
|
||||
});
|
||||
$('engagement-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
|
||||
}
|
||||
|
||||
Admin.onRefresh(async () => {
|
||||
const selected = $('engagement-user').value;
|
||||
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value +
|
||||
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
|
||||
const payload = await Admin.api('/admin/api/analytics?days=' + $('engagement-days').value);
|
||||
const rows = payload.rows || [];
|
||||
$('engagement-rows').innerHTML = rows.length ? rows.map((row) =>
|
||||
'<tr><td>' + fmt.escape(label(row.rowId)) + '</td>' +
|
||||
@@ -84,13 +21,6 @@ Admin.onRefresh(async () => {
|
||||
'<td class="num">' + fmt.number(row.viewers) + '</td></tr>').join('')
|
||||
: ui.emptyRow(8, 'No events in this window.');
|
||||
|
||||
const users = payload.users || [];
|
||||
const existing = $('engagement-user').value;
|
||||
$('engagement-user').innerHTML = '<option value="">Choose a user</option>' + users.map((user) =>
|
||||
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').join('');
|
||||
if (users.some((user) => user.userId === existing)) $('engagement-user').value = existing;
|
||||
renderUser(payload);
|
||||
});
|
||||
|
||||
Admin.ready(() => $('engagement-days').addEventListener('change', Admin.refresh));
|
||||
Admin.ready(() => $('engagement-user').addEventListener('change', Admin.refresh));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<section class="card">
|
||||
<div class="card-head split">
|
||||
<div>
|
||||
<h2 class="card-title" data-icon="people" data-icon-tone="info">Journey health</h2>
|
||||
<p class="card-note">Server-derived foreground visits, completion and interruption.
|
||||
Search text, content titles and setting values are never stored.</p>
|
||||
</div>
|
||||
<div class="row bottom">
|
||||
<label class="field narrow"><span>Window</span>
|
||||
<select id="journeys-days">
|
||||
<option value="1">24 hours</option><option value="7">7 days</option>
|
||||
<option value="30" selected>30 days</option><option value="90">90 days</option>
|
||||
</select></label>
|
||||
<label class="field narrow"><span>User</span>
|
||||
<select id="journeys-user"><option value="">All users</option></select></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tiles" id="journeys-tiles"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="pulse" data-icon-tone="data">Feature use</h2>
|
||||
<p class="card-note">Rare and unused features are shown against Memby's major feature catalogue.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Feature</th><th class="num">Uses</th><th>Last used</th><th>Status</th></tr></thead>
|
||||
<tbody id="journeys-features"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="chart" data-icon-tone="info">Significant actions</h2>
|
||||
<p class="card-note">Actions are counted by event and by distinct journey, so repeated use
|
||||
inside one visit remains visible without looking like more visits.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Category</th><th>Action</th><th class="num">Events</th><th class="num">Journeys</th></tr></thead>
|
||||
<tbody id="journeys-actions"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="list" data-icon-tone="note">Common paths</h2>
|
||||
<p class="card-note">Repeated transitions reveal routes into playback and places viewers
|
||||
commonly leave a flow. A quiet unfinished journey becomes abandoned after 30 minutes.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>From</th><th>To</th><th class="num">Times</th></tr></thead>
|
||||
<tbody id="journeys-paths"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="journeys-events-card" hidden>
|
||||
<div class="card-head"><div>
|
||||
<h2 class="card-title" data-icon="clock" data-icon-tone="info">Chronological journeys</h2>
|
||||
<p class="card-note">Choose one user to inspect visits. Newest visits appear first;
|
||||
actions within each visit run from start to finish.</p>
|
||||
</div></div>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Time</th><th>Journey</th><th>Action</th><th>Screen / path</th><th>Feature</th><th>Content reference</th><th>Outcome</th></tr></thead>
|
||||
<tbody id="journeys-events"></tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
@@ -0,0 +1,89 @@
|
||||
const { fmt, ui, $ } = Admin;
|
||||
|
||||
const featureCatalogue = [
|
||||
'home', 'movies', 'shows', 'favorites', 'search', 'recent_searches',
|
||||
'genre_browse', 'for_you', 'for_you_time', 'recommendation', 'continue',
|
||||
'latest', 'my_shows', 'details', 'playback', 'notifications', 'profiles', 'settings',
|
||||
];
|
||||
|
||||
const label = (value) => {
|
||||
const text = String(value || '—').replaceAll('_', ' ');
|
||||
if (text === 'favorites') return 'Favourites';
|
||||
if (text === 'abandoned') return 'Abandoned / interrupted';
|
||||
return text;
|
||||
};
|
||||
|
||||
function renderEvents(events) {
|
||||
const grouped = new Map();
|
||||
events.forEach((event) => {
|
||||
if (!grouped.has(event.journeyId)) grouped.set(event.journeyId, []);
|
||||
grouped.get(event.journeyId).push(event);
|
||||
});
|
||||
let journeyNumber = grouped.size;
|
||||
const rows = [];
|
||||
grouped.forEach((steps) => {
|
||||
steps.sort((a, b) => a.sequence - b.sequence);
|
||||
const number = journeyNumber--;
|
||||
steps.forEach((event) => {
|
||||
const path = event.source && event.target ? label(event.source) + ' → ' + label(event.target)
|
||||
: label(event.target || event.screen);
|
||||
const content = event.itemId ? label(event.itemType) + ' · ' + event.itemId : '—';
|
||||
rows.push('<tr><td class="muted">' + fmt.when(event.occurredAt) + '</td>' +
|
||||
'<td class="num">' + number + '</td><td>' + fmt.escape(label(event.action)) + '</td>' +
|
||||
'<td>' + fmt.escape(path) + '</td><td>' + fmt.escape(label(event.feature)) + '</td>' +
|
||||
'<td class="muted">' + fmt.escape(content) + '</td><td>' + fmt.escape(label(event.outcome)) + '</td></tr>');
|
||||
});
|
||||
});
|
||||
$('journeys-events').innerHTML = rows.length ? rows.join('') : ui.emptyRow(7, 'No journey events in this window.');
|
||||
}
|
||||
|
||||
Admin.onRefresh(async () => {
|
||||
const selected = $('journeys-user').value;
|
||||
const payload = await Admin.api('/admin/api/journeys?days=' + $('journeys-days').value +
|
||||
(selected ? '&userId=' + encodeURIComponent(selected) : ''));
|
||||
const users = payload.users || [];
|
||||
const existing = $('journeys-user').value;
|
||||
$('journeys-user').innerHTML = '<option value="">All users</option>' + users.map((user) =>
|
||||
'<option value="' + fmt.escape(user.userId) + '">' + fmt.escape(user.username || user.userId) + '</option>').join('');
|
||||
if (users.some((user) => user.userId === existing)) $('journeys-user').value = existing;
|
||||
|
||||
const stats = payload.stats || {};
|
||||
$('journeys-tiles').innerHTML = ui.tiles([
|
||||
['journeys', fmt.number(stats.journeys || 0), { icon: 'list', tone: 'data' }],
|
||||
['viewers', fmt.number(stats.viewers || 0), { icon: 'people', tone: 'info' }],
|
||||
['completion', Math.round((stats.completionRate || 0) * 100) + '%', { icon: 'check', tone: 'ok' }],
|
||||
['abandoned', fmt.number(stats.abandoned || 0), { icon: 'alert', tone: 'note' }],
|
||||
['active now', fmt.number(stats.active || 0), { icon: 'pulse', tone: 'info' }],
|
||||
['average steps', Number(stats.averageSteps || 0).toFixed(1), { icon: 'chart' }],
|
||||
['average visit', fmt.duration(stats.averageTimeMs || 0), { icon: 'clock', small: true }],
|
||||
['history kept', payload.retentionDays + ' days', { icon: 'clock', small: true }],
|
||||
]);
|
||||
|
||||
const used = new Map((payload.features || []).map((feature) => [feature.feature, feature]));
|
||||
const features = [...new Set([...featureCatalogue, ...used.keys()])];
|
||||
$('journeys-features').innerHTML = features.map((name) => {
|
||||
const stat = used.get(name);
|
||||
const uses = stat?.uses || 0;
|
||||
const status = uses === 0 ? ui.tag('not used', 'warn') : uses < 3 ? ui.tag('rare', 'note') : ui.tag('used', 'ok');
|
||||
return '<tr><td>' + fmt.escape(label(name)) + '</td><td class="num">' + fmt.number(uses) +
|
||||
'</td><td class="muted">' + (stat ? fmt.when(stat.lastUsedAt) : '—') + '</td><td>' + status + '</td></tr>';
|
||||
}).join('');
|
||||
|
||||
const actions = payload.actions || [];
|
||||
$('journeys-actions').innerHTML = actions.length ? actions.map((action) =>
|
||||
'<tr><td>' + fmt.escape(label(action.category)) + '</td><td>' + fmt.escape(label(action.action)) +
|
||||
'</td><td class="num">' + fmt.number(action.events) + '</td><td class="num">' +
|
||||
fmt.number(action.journeys) + '</td></tr>').join('') : ui.emptyRow(4, 'No significant actions in this window.');
|
||||
|
||||
const paths = payload.paths || [];
|
||||
$('journeys-paths').innerHTML = paths.length ? paths.map((path) =>
|
||||
'<tr><td>' + fmt.escape(label(path.from)) + '</td><td>' + fmt.escape(label(path.to)) +
|
||||
'</td><td class="num">' + fmt.number(path.count) + '</td></tr>').join('')
|
||||
: ui.emptyRow(3, 'No repeated paths in this window.');
|
||||
|
||||
$('journeys-events-card').hidden = !selected;
|
||||
if (selected) renderEvents(payload.events || []);
|
||||
});
|
||||
|
||||
Admin.ready(() => $('journeys-days').addEventListener('change', Admin.refresh));
|
||||
Admin.ready(() => $('journeys-user').addEventListener('change', Admin.refresh));
|
||||
@@ -137,6 +137,11 @@ var adminNav = []adminNavGroup{
|
||||
{
|
||||
Label: "Operations",
|
||||
Items: []adminNavItem{
|
||||
{
|
||||
ID: "journeys", Label: "Journeys", Title: "User journeys",
|
||||
Intro: "How viewers move through Memby, use features and complete flows.",
|
||||
Icon: "M4 6h5v5h6v7h5M7 3 4 6l3 3m10 6 3 3-3 3",
|
||||
},
|
||||
{
|
||||
ID: "maintenance", Label: "Maintenance", Title: "Maintenance",
|
||||
Intro: "Take Memby offline for every television.",
|
||||
|
||||
@@ -166,6 +166,28 @@ func adminPreviewData() map[string]any {
|
||||
map[string]any{"rowId": "favorites", "title": "Favourites", "impressions": 1610,
|
||||
"focuses": 300, "selections": 74, "averageDwellMs": 1800},
|
||||
}},
|
||||
"/admin/api/journeys": map[string]any{
|
||||
"days": 30, "retentionDays": 90,
|
||||
"stats": map[string]any{"events": 184, "journeys": 28, "viewers": 2,
|
||||
"completed": 21, "abandoned": 5, "active": 2, "averageSteps": 6.6,
|
||||
"averageTimeMs": 1140000, "completionRate": 0.81},
|
||||
"users": []any{
|
||||
map[string]any{"userId": "u-1", "username": "matt", "events": 120, "journeys": 18, "lastActiveAt": stamp(9 * time.Minute)},
|
||||
map[string]any{"userId": "u-2", "username": "sam", "events": 64, "journeys": 10, "lastActiveAt": stamp(3 * time.Hour)},
|
||||
},
|
||||
"features": []any{
|
||||
map[string]any{"feature": "playback", "uses": 31, "lastUsedAt": stamp(9 * time.Minute)},
|
||||
map[string]any{"feature": "search", "uses": 12, "lastUsedAt": stamp(3 * time.Hour)},
|
||||
},
|
||||
"actions": []any{
|
||||
map[string]any{"category": "content", "action": "open", "events": 44, "journeys": 22},
|
||||
map[string]any{"category": "playback", "action": "start", "events": 24, "journeys": 18},
|
||||
},
|
||||
"paths": []any{
|
||||
map[string]any{"from": "home", "to": "details", "count": 31},
|
||||
map[string]any{"from": "details", "to": "playback", "count": 18},
|
||||
},
|
||||
},
|
||||
"/admin/api/requests": map[string]any{"requests": []any{}},
|
||||
// A prefix among the terms and an unattributed row in the log, because both are
|
||||
// ordinary here and a preview showing neither would not be a preview of this page.
|
||||
|
||||
@@ -576,7 +576,7 @@ func TestAdminPagesUseRealRoutes(t *testing.T) {
|
||||
for _, page := range []string{
|
||||
"overview", "accounts", "clients", "library", "recommendations", "inspector",
|
||||
"requests", "ratings", "features", "playback", "maintenance", "updates",
|
||||
"engagement", "imports", "logs",
|
||||
"journeys", "engagement", "imports", "logs",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/"+page, nil)
|
||||
addInstallerSession(t, server, req)
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.30
|
||||
0.1.31
|
||||
|
||||
@@ -97,6 +97,28 @@ type PathStat struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// JourneyStats is the server-derived health of foreground visits in a reporting window.
|
||||
// An unfinished visit is only abandoned once it has been quiet for thirty minutes; until
|
||||
// then it is active, so an open television does not immediately look like a failed flow.
|
||||
type JourneyStats struct {
|
||||
Events int64 `json:"events"`
|
||||
Journeys int64 `json:"journeys"`
|
||||
Viewers int64 `json:"viewers"`
|
||||
Completed int64 `json:"completed"`
|
||||
Abandoned int64 `json:"abandoned"`
|
||||
Active int64 `json:"active"`
|
||||
AverageSteps float64 `json:"averageSteps"`
|
||||
AverageTimeMs int64 `json:"averageTimeMs"`
|
||||
CompletionRate float64 `json:"completionRate"`
|
||||
}
|
||||
|
||||
type JourneyActionStat struct {
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Events int64 `json:"events"`
|
||||
Journeys int64 `json:"journeys"`
|
||||
}
|
||||
|
||||
// Event kinds. Impressions say a row was drawn; focus says the remote actually landed
|
||||
// on it and for how long; select says something was opened from it.
|
||||
const (
|
||||
@@ -231,10 +253,64 @@ func (s *Store) AnalyticsUsers(ctx context.Context, since time.Time) ([]Analytic
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) JourneyStats(ctx context.Context, userID string, since time.Time) (JourneyStats, error) {
|
||||
var value JourneyStats
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
WITH visits AS (
|
||||
SELECT emby_user_id, journey_id, count(*) AS steps,
|
||||
min(occurred_at) AS started_at, max(occurred_at) AS last_at,
|
||||
bool_or(action = 'journey_end') AS completed
|
||||
FROM journey_events
|
||||
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
|
||||
GROUP BY emby_user_id, journey_id
|
||||
)
|
||||
SELECT coalesce(sum(steps), 0), count(*), count(DISTINCT emby_user_id),
|
||||
count(*) FILTER (WHERE completed),
|
||||
count(*) FILTER (WHERE NOT completed AND last_at < now() - interval '30 minutes'),
|
||||
count(*) FILTER (WHERE NOT completed AND last_at >= now() - interval '30 minutes'),
|
||||
coalesce(avg(steps), 0)::double precision,
|
||||
round(coalesce(avg(extract(epoch FROM (last_at - started_at)) * 1000)
|
||||
FILTER (WHERE completed), 0))::bigint
|
||||
FROM visits`, since, userID).Scan(
|
||||
&value.Events, &value.Journeys, &value.Viewers, &value.Completed,
|
||||
&value.Abandoned, &value.Active, &value.AverageSteps, &value.AverageTimeMs,
|
||||
)
|
||||
if err != nil {
|
||||
return JourneyStats{}, fmt.Errorf("store: journey stats: %w", err)
|
||||
}
|
||||
finished := value.Completed + value.Abandoned
|
||||
if finished > 0 {
|
||||
value.CompletionRate = float64(value.Completed) / float64(finished)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Store) JourneyActionStats(ctx context.Context, userID string, since time.Time) ([]JourneyActionStat, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT category, action, count(*), count(DISTINCT journey_id)
|
||||
FROM journey_events
|
||||
WHERE occurred_at >= $1 AND ($2 = '' OR emby_user_id = $2)
|
||||
AND action NOT IN ('journey_start', 'journey_end')
|
||||
GROUP BY category, action ORDER BY count(*) DESC, category, action`, since, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: journey action stats: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []JourneyActionStat{}
|
||||
for rows.Next() {
|
||||
var value JourneyActionStat
|
||||
if err := rows.Scan(&value.Category, &value.Action, &value.Events, &value.Journeys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UserFeatureStats(ctx context.Context, userID string, since time.Time) ([]FeatureStat, error) {
|
||||
rows, err := s.pool.Query(ctx, `
|
||||
SELECT feature, count(*), max(occurred_at) FROM journey_events
|
||||
WHERE emby_user_id=$1 AND occurred_at >= $2 AND feature <> ''
|
||||
WHERE ($1 = '' OR emby_user_id=$1) AND occurred_at >= $2 AND feature <> ''
|
||||
AND action NOT IN ('screen_view', 'journey_start', 'journey_end')
|
||||
GROUP BY feature ORDER BY count(*) DESC, feature`, userID, since)
|
||||
if err != nil {
|
||||
@@ -259,7 +335,7 @@ func (s *Store) UserPaths(ctx context.Context, userID string, since time.Time) (
|
||||
coalesce(nullif(target,''), nullif(screen,''), feature) AS node,
|
||||
lag(coalesce(nullif(target,''), nullif(screen,''), feature)) OVER
|
||||
(PARTITION BY journey_id ORDER BY sequence, occurred_at, id) AS previous
|
||||
FROM journey_events WHERE emby_user_id=$1 AND occurred_at >= $2
|
||||
FROM journey_events WHERE ($1 = '' OR emby_user_id=$1) AND occurred_at >= $2
|
||||
), path_steps AS (
|
||||
SELECT previous AS from_node, node AS to_node FROM ordered
|
||||
WHERE previous IS NOT NULL AND node IS NOT NULL AND previous <> node
|
||||
|
||||
Reference in New Issue
Block a user