Compare commits
2 Commits
274fcf9a5c
...
5e8f7e6005
Author | SHA1 | Date | |
---|---|---|---|
5e8f7e6005 | |||
4962399fcf |
@ -21,59 +21,32 @@ interface SentryPagination {
|
|||||||
}
|
}
|
||||||
export default class ReplaysController {
|
export default class ReplaysController {
|
||||||
|
|
||||||
public async search({ response }: HttpContext) {
|
public async stats({ request, response }: HttpContext) {
|
||||||
let results = await db.rawQuery(`
|
const {sendToWebhook} = request.qs()
|
||||||
SELECT
|
const cacheKey = `replays:sync:latest_version`
|
||||||
u.display_name,
|
const latestFetchVersion = await redis.get(`replays:fetch:latest_version`)
|
||||||
u.sessions,
|
const latestQueryVersion = await redis.get(`replays:stats:latest_version`)
|
||||||
u.total_time_seconds,
|
if (latestFetchVersion == latestQueryVersion) {
|
||||||
u.total_time_readable,
|
let results
|
||||||
u.average_session_time_readable,
|
results = await redis.get(`replays:sync:version:${latestQueryVersion}:results`)
|
||||||
u.average_time_seconds,
|
if (!results) {
|
||||||
r.id AS last_session_id,
|
console.log('no data in cache, updating')
|
||||||
r.finished_at AS last_session_time
|
results = await getResults()
|
||||||
|
await redis.set(`replays:sync:version:${latestQueryVersion}:results`, JSON.stringify(results))
|
||||||
FROM (
|
|
||||||
-- Aggregate sessions in the last 30 days
|
|
||||||
SELECT
|
|
||||||
"user" ->> 'display_name' AS display_name,
|
|
||||||
COUNT(duration) AS sessions,
|
|
||||||
SUM(duration) AS total_time_seconds,
|
|
||||||
AVG(duration) AS average_time_seconds,
|
|
||||||
CONCAT(
|
|
||||||
FLOOR(SUM(duration) / 86400), 'd ',
|
|
||||||
FLOOR(MOD(SUM(duration), 86400) / 3600), 'h ',
|
|
||||||
FLOOR(MOD(SUM(duration), 3600) / 60), 'm'
|
|
||||||
) AS total_time_readable,
|
|
||||||
CONCAT(
|
|
||||||
FLOOR(COUNT(duration) / 86400), 'd ',
|
|
||||||
FLOOR(MOD(COUNT(duration), 86400) / 3600), 'h ',
|
|
||||||
FLOOR(MOD(COUNT(duration), 3600) / 60), 'm'
|
|
||||||
) AS average_session_time_readable
|
|
||||||
FROM
|
|
||||||
replays
|
|
||||||
WHERE
|
|
||||||
finished_at >= NOW() - INTERVAL '30 days'
|
|
||||||
GROUP BY
|
|
||||||
"user" ->> 'display_name'
|
|
||||||
) u
|
|
||||||
|
|
||||||
-- LATERAL JOIN to get latest session (either within 30d or fallback to latest overall)
|
|
||||||
JOIN LATERAL (
|
|
||||||
SELECT id, finished_at
|
|
||||||
FROM replays
|
|
||||||
WHERE "user" ->> 'display_name' = u.display_name
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN finished_at >= NOW() - INTERVAL '30 days' THEN 0 ELSE 1 END,
|
|
||||||
finished_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
) r ON true
|
|
||||||
|
|
||||||
ORDER BY
|
|
||||||
u.total_time_seconds DESC;`
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
|
|
||||||
|
}
|
||||||
|
console.log('resultssdsdfds')
|
||||||
|
return response.json(results)
|
||||||
|
} else {
|
||||||
|
let results = await getResults()
|
||||||
|
console.log('results quer', latestQueryVersion)
|
||||||
|
await redis.set(`replays:stats:version:${latestQueryVersion}:results`, JSON.stringify(results))
|
||||||
|
await redis.set(`replays:stats:latest_version`, latestFetchVersion)
|
||||||
|
await redis.set(`replays:fetch:latest_version`, latestFetchVersion)
|
||||||
|
return response.json(results)
|
||||||
|
if (sendToWebhook) {
|
||||||
|
try {
|
||||||
|
console.log('syncing to webhook')
|
||||||
await fetch(env.get('WEBHOOK_URL'),
|
await fetch(env.get('WEBHOOK_URL'),
|
||||||
{
|
{
|
||||||
headers:
|
headers:
|
||||||
@ -87,6 +60,9 @@ ORDER BY
|
|||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.error('error sending webhook data', e)
|
console.error('error sending webhook data', e)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response.json(results.rows)
|
response.json(results.rows)
|
||||||
}
|
}
|
||||||
public async list({ request, inertia }: HttpContext) {
|
public async list({ request, inertia }: HttpContext) {
|
||||||
@ -136,6 +112,13 @@ ORDER BY
|
|||||||
queryString = `?start=${start}&end=${end}`
|
queryString = `?start=${start}&end=${end}`
|
||||||
}
|
}
|
||||||
const replays = await fetchBatch(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/replays/${queryString}`)
|
const replays = await fetchBatch(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/replays/${queryString}`)
|
||||||
|
let latestVersion = await redis.get(`replays:fetch:latest_version`)
|
||||||
|
if (!latestVersion) {
|
||||||
|
redis.set('replays:fetch:latest_version', 1)
|
||||||
|
} else {
|
||||||
|
redis.set('replays:fetch:latest_version', ++latestVersion)
|
||||||
|
}
|
||||||
|
|
||||||
return response.json(replays)
|
return response.json(replays)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,4 +213,58 @@ function buildPaginationLinks(meta: { previousPageUrl: string, lastPage: number;
|
|||||||
})
|
})
|
||||||
|
|
||||||
return links
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResults(){
|
||||||
|
let results = await db.rawQuery(`
|
||||||
|
SELECT
|
||||||
|
u.display_name,
|
||||||
|
u.sessions,
|
||||||
|
u.total_time_seconds,
|
||||||
|
u.total_time_readable,
|
||||||
|
u.average_session_time_readable,
|
||||||
|
u.average_time_seconds,
|
||||||
|
r.id AS last_session_id,
|
||||||
|
r.finished_at AS last_session_time
|
||||||
|
|
||||||
|
FROM (
|
||||||
|
-- Aggregate sessions in the last 30 days
|
||||||
|
SELECT
|
||||||
|
"user" ->> 'display_name' AS display_name,
|
||||||
|
COUNT(duration) AS sessions,
|
||||||
|
SUM(duration) AS total_time_seconds,
|
||||||
|
AVG(duration) AS average_time_seconds,
|
||||||
|
CONCAT(
|
||||||
|
FLOOR(SUM(duration) / 86400), 'd ',
|
||||||
|
FLOOR(MOD(SUM(duration), 86400) / 3600), 'h ',
|
||||||
|
FLOOR(MOD(SUM(duration), 3600) / 60), 'm'
|
||||||
|
) AS total_time_readable,
|
||||||
|
CONCAT(
|
||||||
|
FLOOR(COUNT(duration) / 86400), 'd ',
|
||||||
|
FLOOR(MOD(COUNT(duration), 86400) / 3600), 'h ',
|
||||||
|
FLOOR(MOD(COUNT(duration), 3600) / 60), 'm'
|
||||||
|
) AS average_session_time_readable
|
||||||
|
FROM
|
||||||
|
replays
|
||||||
|
WHERE
|
||||||
|
finished_at >= NOW() - INTERVAL '30 days'
|
||||||
|
GROUP BY
|
||||||
|
"user" ->> 'display_name'
|
||||||
|
) u
|
||||||
|
|
||||||
|
-- LATERAL JOIN to get latest session (either within 30d or fallback to latest overall)
|
||||||
|
JOIN LATERAL (
|
||||||
|
SELECT id, finished_at
|
||||||
|
FROM replays
|
||||||
|
WHERE "user" ->> 'display_name' = u.display_name
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN finished_at >= NOW() - INTERVAL '30 days' THEN 0 ELSE 1 END,
|
||||||
|
finished_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) r ON true
|
||||||
|
|
||||||
|
ORDER BY
|
||||||
|
u.total_time_seconds DESC;`
|
||||||
|
)
|
||||||
|
return results
|
||||||
}
|
}
|
@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="m-5">
|
||||||
<h1 class="text-2xl font-bold mb-4">Replays</h1>
|
<h1 class="text-2xl font-bold mb-4">Replays</h1>
|
||||||
|
|
||||||
<table class="w-full border text-left">
|
<table class="w-full border text-left">
|
||||||
@ -7,37 +7,113 @@
|
|||||||
<tr class="bg-gray-100">
|
<tr class="bg-gray-100">
|
||||||
<th class="p-2">ID</th>
|
<th class="p-2">ID</th>
|
||||||
<th class="p-2">Email</th>
|
<th class="p-2">Email</th>
|
||||||
<th class="p-2">Started</th>
|
<th class="p-2">Date</th>
|
||||||
|
<th class="p-2">Location</th>
|
||||||
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
<tr v-for="replay in data.replays" :key="replay.id" class="border-t">
|
<tr v-for="replay in data.replays" :key="replay.id" class="border-t">
|
||||||
<td class="p-2">{{ replay.id }}</td>
|
<td class="p-2">{{ replay.id }}</td>
|
||||||
<td class="p-2">{{ replay.user.email ?? replay.user.display_name }}</td>
|
<td class="p-2">{{ replay.user.email ?? replay.user.display_name }}</td>
|
||||||
<td class="p-2">{{ replay.started_at }}</td>
|
<td class="p-2">{{ replay.finished_at }}</td>
|
||||||
|
<td class="p-2">{{ replay.user.geo ? `${replay.user.geo.city} ${replay.user.geo.subdivision}, ${replay.user.geo.region}` : 'unknown' }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<div class="mt-4 flex space-x-2">
|
<div class="mt-4 flex flex-wrap items-center gap-2" v-if="data.meta && data.meta.links && data.meta.links.length > 1">
|
||||||
<template v-for="link in data.meta.links" :key="link.label">
|
<!-- First -->
|
||||||
<Link
|
<Link
|
||||||
|
v-if="firstPageUrl && !isFirstPage"
|
||||||
|
:href="firstPageUrl"
|
||||||
|
class="px-3 py-1 border rounded text-sm"
|
||||||
|
>
|
||||||
|
« First
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<!-- Previous -->
|
||||||
|
<Link
|
||||||
|
v-if="prevPageUrl"
|
||||||
|
:href="prevPageUrl"
|
||||||
|
class="px-3 py-1 border rounded text-sm"
|
||||||
|
>
|
||||||
|
‹ Prev
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<!-- Page Numbers (windowed) -->
|
||||||
|
<template v-for="link in paginatedLinks" :key="link.label">
|
||||||
|
<component
|
||||||
|
:is="link.url ? Link : 'span'"
|
||||||
:href="link.url"
|
:href="link.url"
|
||||||
class="px-2 py-1 border rounded"
|
class="px-3 py-1 border rounded text-sm"
|
||||||
:class="{ 'font-bold bg-gray-200': link.active, 'text-gray-400': !link.url }"
|
:class="{
|
||||||
/>
|
'font-bold bg-gray-300': link.active,
|
||||||
|
'text-gray-400 cursor-not-allowed': !link.url
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span v-html="link.label" />
|
||||||
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Next -->
|
||||||
|
<Link
|
||||||
|
v-if="nextPageUrl"
|
||||||
|
:href="nextPageUrl"
|
||||||
|
class="px-3 py-1 border rounded text-sm"
|
||||||
|
>
|
||||||
|
Next ›
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<!-- Last -->
|
||||||
|
<Link
|
||||||
|
v-if="lastPageUrl && !isLastPage"
|
||||||
|
:href="lastPageUrl"
|
||||||
|
class="px-3 py-1 border rounded text-sm"
|
||||||
|
>
|
||||||
|
Last »
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Link, usePage } from '@inertiajs/vue3'
|
import { computed } from 'vue'
|
||||||
|
import { Link } from '@inertiajs/vue3'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
data: Object
|
data: Object
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Core pagination values
|
||||||
|
const links = computed(() => props.data.meta.links || [])
|
||||||
|
const currentIndex = computed(() => links.value.findIndex(link => link.active))
|
||||||
|
|
||||||
|
const maxVisible = 10
|
||||||
|
const half = Math.floor(maxVisible / 2)
|
||||||
|
|
||||||
|
const paginatedLinks = computed(() => {
|
||||||
|
const total = links.value.length
|
||||||
|
if (total <= maxVisible) return links.value
|
||||||
|
|
||||||
|
let start = Math.max(currentIndex.value - half, 0)
|
||||||
|
let end = start + maxVisible
|
||||||
|
|
||||||
|
if (end > total) {
|
||||||
|
end = total
|
||||||
|
start = Math.max(0, end - maxVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
return links.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Navigation links
|
||||||
|
const firstPageUrl = computed(() => links.value[1]?.url) // usually index 1 is page=1
|
||||||
|
const prevPageUrl = computed(() => links.value[currentIndex.value - 1]?.url)
|
||||||
|
const nextPageUrl = computed(() => links.value[currentIndex.value + 1]?.url)
|
||||||
|
const lastPageUrl = computed(() => links.value[links.value.length - 2]?.url) // last item is "Next »", second-last is last numbered
|
||||||
|
|
||||||
|
const isFirstPage = computed(() => links.value[currentIndex.value]?.label === '1')
|
||||||
|
const isLastPage = computed(() => links.value[currentIndex.value]?.label === props.data.meta.last_page)
|
||||||
</script>
|
</script>
|
||||||
|
@ -13,4 +13,4 @@ router.on('/').renderInertia('home')
|
|||||||
router.get('/replays', [ReplaysController, 'index'])
|
router.get('/replays', [ReplaysController, 'index'])
|
||||||
router.get('/list', [ReplaysController, 'list'
|
router.get('/list', [ReplaysController, 'list'
|
||||||
])
|
])
|
||||||
router.get('/search', [ReplaysController, 'search'])
|
router.get('/stats', [ReplaysController, 'stats'])
|
Reference in New Issue
Block a user