Add redis and basic frontend for viewing data

This commit is contained in:
Mike Conrad
2025-05-19 11:07:03 -04:00
parent f12cdbf76a
commit a96b9f2c8b
10 changed files with 258 additions and 20 deletions

View File

@ -9,3 +9,6 @@ PG_HOST=localhost
PG_PASSWORD=password
SENTRY_TOKEN=
SENTRY_ORG=
REDIS_HOST=sentry-redis-1
REDIS_PORT=6379
REDIS_PASSWORD=

View File

@ -53,6 +53,7 @@ export default defineConfig({
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('@adonisjs/inertia/inertia_provider'),
() => import('@adonisjs/redis/redis_provider')
],
/*

View File

@ -4,6 +4,7 @@ import type { HttpContext } from '@adonisjs/core/http'
const SENTRY_TOKEN = env.get('SENTRY_TOKEN')
const SENTRY_ORG = env.get('SENTRY_ORG')
let recordsUpdated = 0
import redis from '@adonisjs/redis/services/main'
interface ApiResponse<T> {
@ -19,6 +20,41 @@ interface SentryPagination {
}
export default class ReplaysController {
public async list({ request, inertia }: HttpContext) {
const page = request.input('page', 1)
const perPage = 20
const cacheKey = `replays:page:${page}`
let data = await redis.get(cacheKey)
let paginated, meta, replays
if (data) {
({ paginated, meta, replays } = JSON.parse(data))
} else {
paginated = await Replay.query().paginate(page, perPage)
paginated.baseUrl('/list')
const json = paginated.toJSON()
meta = {
...json.meta,
links: buildPaginationLinks(json.meta)
}
replays = json.data
await redis.set(cacheKey, JSON.stringify({ paginated, meta, replays }), 'EX', 60)
}
return inertia.render('Replays/Index', {
data: {
replays,
meta
}
})
}
async index({ request, response }: HttpContext) {
const {statsPeriod, start, end} = request.qs()
@ -98,3 +134,31 @@ function parseSentryLinkHeader(header:string): SentryPagination {
return acc
}, {} as Record<string, any>)
}
function buildPaginationLinks(meta) {
const links = []
// Previous
links.push({
url: meta.previousPageUrl,
label: '&laquo; Prev',
active: false
})
for (let page = 1; page <= meta.lastPage; page++) {
links.push({
url: `/list?page=${page}`,
label: page.toString(),
active: page === meta.currentPage
})
}
// Next
links.push({
url: meta.nextPageUrl,
label: 'Next &raquo;',
active: false
})
return links
}

36
config/redis.ts Normal file
View File

@ -0,0 +1,36 @@
import env from '#start/env'
import { defineConfig } from '@adonisjs/redis'
import { InferConnections } from '@adonisjs/redis/types'
const redisConfig = defineConfig({
connection: 'main',
connections: {
/*
|--------------------------------------------------------------------------
| The default connection
|--------------------------------------------------------------------------
|
| The main connection you want to use to execute redis commands. The same
| connection will be used by the session provider, if you rely on the
| redis driver.
|
*/
main: {
host: 'sentry-redis-1',
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD', ''),
db: 0,
keyPrefix: '',
retryStrategy(times) {
return times > 10 ? null : times * 50
},
},
},
})
export default redisConfig
declare module '@adonisjs/redis/types' {
export interface RedisConnections extends InferConnections<typeof redisConfig> {}
}

View File

@ -32,5 +32,8 @@
image: grafana/grafana:latest
labels:
- "traefik.http.routers.grafana.rule=Host(`grafana.docker.localhost`)"
redis:
image: redis:latest
volumes:
backend_node_modules: {}

View File

@ -0,0 +1,43 @@
<template>
<div>
<h1 class="text-2xl font-bold mb-4">Replays</h1>
<table class="w-full border text-left">
<thead>
<tr class="bg-gray-100">
<th class="p-2">ID</th>
<th class="p-2">Email</th>
<th class="p-2">Started</th>
</tr>
</thead>
<tbody>
<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.user.email ?? replay.user.display_name }}</td>
<td class="p-2">{{ replay.started_at }}</td>
</tr>
</tbody>
</table>
<!-- Pagination -->
<div class="mt-4 flex space-x-2">
<template v-for="link in data.meta.links" :key="link.label">
<Link
:href="link.url"
class="px-2 py-1 border rounded"
:class="{ 'font-bold bg-gray-200': link.active, 'text-gray-400': !link.url }"
/>
</template>
</div>
</div>
</template>
<script setup>
import { Link, usePage } from '@inertiajs/vue3'
const props = defineProps({
data: Object
})
</script>

110
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@adonisjs/cors": "^2.2.1",
"@adonisjs/inertia": "^3.1.1",
"@adonisjs/lucid": "^21.6.1",
"@adonisjs/redis": "^9.2.0",
"@adonisjs/session": "^7.5.1",
"@adonisjs/shield": "^8.2.0",
"@adonisjs/static": "^1.1.1",
@ -24,6 +25,7 @@
"edge.js": "^6.2.1",
"luxon": "^3.6.1",
"pg": "^8.16.0",
"pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.2",
"vue": "^3.5.14"
},
@ -41,7 +43,6 @@
"@vitejs/plugin-vue": "^5.2.4",
"eslint": "^9.26.0",
"hot-hook": "^0.4.0",
"pino-pretty": "^13.0.0",
"prettier": "^3.5.3",
"ts-node-maintained": "^10.9.5",
"typescript": "~5.8.3",
@ -594,6 +595,22 @@
"prettier-plugin-edgejs": "^1.0.0"
}
},
"node_modules/@adonisjs/redis": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/@adonisjs/redis/-/redis-9.2.0.tgz",
"integrity": "sha512-DUI9NrHDLZ2ISNjMlqWbKJT99ZYj1ZmvhNFTfhVs9lc7K2KJmNKZfK8Y85a8eN7q+ZYMBYSu1uRemxGs6xRaYw==",
"dependencies": {
"@poppinss/utils": "^6.9.2",
"emittery": "^1.1.0",
"ioredis": "^5.4.2"
},
"engines": {
"node": ">=20.6.0"
},
"peerDependencies": {
"@adonisjs/core": "^6.2.0"
}
},
"node_modules/@adonisjs/repl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@adonisjs/repl/-/repl-4.1.0.tgz",
@ -1578,6 +1595,11 @@
"vue": "^3.0.0"
}
},
"node_modules/@ioredis/commands": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
},
"node_modules/@japa/assert": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@japa/assert/-/assert-4.0.1.tgz",
@ -3746,6 +3768,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/code-block-writer": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
@ -3973,7 +4003,6 @@
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@ -4053,6 +4082,14 @@
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -4238,7 +4275,6 @@
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
@ -4766,7 +4802,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz",
"integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-deep-equal": {
@ -4839,7 +4874,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fastest-levenshtein": {
@ -5451,7 +5485,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"dev": true,
"license": "MIT"
},
"node_modules/hosted-git-info": {
@ -5648,6 +5681,29 @@
"node": ">= 0.10"
}
},
"node_modules/ioredis": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
"integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
"dependencies": {
"@ioredis/commands": "^1.1.1",
"cluster-key-slot": "^1.1.0",
"debug": "^4.3.4",
"denque": "^2.1.0",
"lodash.defaults": "^4.2.0",
"lodash.isarguments": "^3.1.0",
"redis-errors": "^1.2.0",
"redis-parser": "^3.0.0",
"standard-as-callback": "^2.1.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -5813,7 +5869,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@ -6072,6 +6127,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
},
"node_modules/lodash.isarguments": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -6351,7 +6416,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -6523,7 +6587,6 @@
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"license": "ISC",
"dependencies": {
"wrappy": "1"
@ -6953,7 +7016,6 @@
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.0.0.tgz",
"integrity": "sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
@ -6978,7 +7040,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz",
"integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/pino-std-serializers": {
@ -7266,7 +7327,6 @@
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
"dev": true,
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
@ -7627,6 +7687,25 @@
"node": ">= 10.13.0"
}
},
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"engines": {
"node": ">=4"
}
},
"node_modules/redis-parser": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
"dependencies": {
"redis-errors": "^1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/reflect-metadata": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@ -8218,6 +8297,11 @@
"get-source": "^2.0.12"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
@ -8328,7 +8412,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -9041,7 +9124,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"node_modules/xtend": {

View File

@ -56,6 +56,7 @@
"@adonisjs/cors": "^2.2.1",
"@adonisjs/inertia": "^3.1.1",
"@adonisjs/lucid": "^21.6.1",
"@adonisjs/redis": "^9.2.0",
"@adonisjs/session": "^7.5.1",
"@adonisjs/shield": "^8.2.0",
"@adonisjs/static": "^1.1.1",
@ -66,10 +67,9 @@
"edge.js": "^6.2.1",
"luxon": "^3.6.1",
"pg": "^8.16.0",
"pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.2",
"vue": "^3.5.14",
"pino-pretty": "^13.0.0"
"vue": "^3.5.14"
},
"hotHook": {
"boundaries": [

View File

@ -24,4 +24,8 @@ export default await Env.create(new URL('../', import.meta.url), {
|----------------------------------------------------------
*/
SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const),
REDIS_HOST: Env.schema.string({ format: 'host' }),
REDIS_PORT: Env.schema.number(),
REDIS_PASSWORD: Env.schema.string.optional()
})

View File

@ -11,3 +11,5 @@ import ReplaysController from '#controllers/replays_controller'
import router from '@adonisjs/core/services/router'
router.on('/').renderInertia('home')
router.get('/replays', [ReplaysController, 'index'])
router.get('/list', [ReplaysController, 'list'
])