add ingest analytics page
This commit is contained in:
parent
2665a6d25f
commit
1e41262d70
7 changed files with 270 additions and 10 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
@ -2530,6 +2530,7 @@ version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async_once",
|
"async_once",
|
||||||
"axum",
|
"axum",
|
||||||
|
"chrono",
|
||||||
"futures",
|
"futures",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
|
|
|
@ -1,11 +1,50 @@
|
||||||
use async_graphql::Object;
|
use async_graphql::{futures_util::TryStreamExt, Context, Object, SimpleObject};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use sqlx::{query, Pool, Postgres, Row};
|
||||||
|
|
||||||
pub struct Analytics {}
|
pub struct Analytics {}
|
||||||
|
|
||||||
|
#[derive(SimpleObject, Debug, Clone)]
|
||||||
|
pub struct Event {
|
||||||
|
pub time: DateTime<Utc>,
|
||||||
|
pub event_name: String,
|
||||||
|
pub world_id: i32,
|
||||||
|
pub count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[Object]
|
#[Object]
|
||||||
impl Analytics {
|
impl Analytics {
|
||||||
async fn population(&self) -> i32 {
|
/// Get all events in analytics, bucket_size is in seconds
|
||||||
0
|
async fn events<'ctx>(
|
||||||
|
&self,
|
||||||
|
ctx: &Context<'ctx>,
|
||||||
|
#[graphql(default = 60)] bucket_size: u64,
|
||||||
|
world_id: Option<i32>,
|
||||||
|
) -> Vec<Event> {
|
||||||
|
let pool = ctx.data::<Pool<Postgres>>().unwrap();
|
||||||
|
|
||||||
|
let sql = format!("SELECT time_bucket('{} seconds', time) AS bucket, count(*), event_name, world_id FROM analytics WHERE time > now() - interval '1 day' {} GROUP BY bucket, world_id, event_name ORDER BY bucket ASC",
|
||||||
|
bucket_size,
|
||||||
|
if let Some(world_id) = world_id {
|
||||||
|
format!("AND world_id = {}", world_id)
|
||||||
|
} else {
|
||||||
|
"".to_string()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut result = query(sql.as_str()).fetch(pool);
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
while let Some(row) = result.try_next().await.unwrap() {
|
||||||
|
events.push(Event {
|
||||||
|
time: row.get("bucket"),
|
||||||
|
event_name: row.get("event_name"),
|
||||||
|
world_id: row.get("world_id"),
|
||||||
|
count: row.get("count"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
events
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,6 +14,213 @@
|
||||||
color: #cead42;
|
color: #cead42;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
height: 50vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.smaller {
|
||||||
|
height: 33vh;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<h1>Ingest Stats</h1>
|
<h1>Ingest Stats</h1>
|
||||||
<p>sorry wip</p>
|
<div>
|
||||||
|
<h3>All Events by Type</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="all-events-by-type" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Events by World</h3>
|
||||||
|
<div class="chart-container">
|
||||||
|
<canvas id="events-by-world" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Connery</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="connery" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Miller</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="miller" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Cobalt</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="cobalt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Emerald</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="emerald" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Jaeger</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="jaeger" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>SolTech</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="soltech" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Genudine</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="genudine" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>Ceres</h3>
|
||||||
|
<div class="chart-container smaller">
|
||||||
|
<canvas id="ceres" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
const renderChart = (id, data, config = {}) => {};
|
||||||
|
|
||||||
|
const allEventsByType = (id, events) => {
|
||||||
|
let allEvents = events.reduce(
|
||||||
|
(acc, ev) => {
|
||||||
|
acc[ev.eventName][ev.time] = (acc[ev.time] || 0) + ev.count;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ Death: {}, VehicleDestroy: {} }
|
||||||
|
);
|
||||||
|
|
||||||
|
new Chart(document.getElementById(id), {
|
||||||
|
type: "line",
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true, suggestedMin: 0 },
|
||||||
|
x: { stacked: true, type: "timeseries" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Deaths",
|
||||||
|
data: allEvents.Death,
|
||||||
|
// backgroundColor: "#cead42",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Vehicle Destroys",
|
||||||
|
data: allEvents.VehicleDestroy,
|
||||||
|
// backgroundColor: "#7842ce",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventsByWorld = (events) => {
|
||||||
|
let allEvents = events.reduce((acc, ev) => {
|
||||||
|
acc[ev.worldId] = acc[ev.worldId] || {};
|
||||||
|
acc[ev.worldId][ev.time] = (acc[ev.time] || 0) + ev.count;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
new Chart(document.getElementById("events-by-world"), {
|
||||||
|
type: "line",
|
||||||
|
options: {
|
||||||
|
scales: {
|
||||||
|
y: { beginAtZero: true },
|
||||||
|
x: {
|
||||||
|
type: "timeseries",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: "Connery",
|
||||||
|
data: allEvents["1"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Miller",
|
||||||
|
data: allEvents["10"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cobalt",
|
||||||
|
data: allEvents["13"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Emerald",
|
||||||
|
data: allEvents["17"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Jaeger",
|
||||||
|
data: allEvents["19"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "SolTech",
|
||||||
|
data: allEvents["40"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Genudine",
|
||||||
|
data: allEvents["1000"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Ceres",
|
||||||
|
data: allEvents["2000"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let resp = await fetch("/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
{
|
||||||
|
analytics {
|
||||||
|
events(bucketSize: ${60 * 5}) {
|
||||||
|
eventName
|
||||||
|
count
|
||||||
|
time
|
||||||
|
worldId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let body = await resp.json();
|
||||||
|
|
||||||
|
let events = body.data.analytics.events;
|
||||||
|
window.events = events;
|
||||||
|
|
||||||
|
allEventsByType("all-events-by-type", events);
|
||||||
|
eventsByWorld(events);
|
||||||
|
[
|
||||||
|
["connery", 1],
|
||||||
|
["miller", 10],
|
||||||
|
["cobalt", 13],
|
||||||
|
["emerald", 17],
|
||||||
|
["jaeger", 19],
|
||||||
|
["soltech", 40],
|
||||||
|
["genudine", 1000],
|
||||||
|
["ceres", 2000],
|
||||||
|
].forEach(([world, id]) => {
|
||||||
|
let worldEvents = events.filter((ev) => ev.worldId === id);
|
||||||
|
allEventsByType(world, worldEvents);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
classes::ClassesQuery, health::HealthQuery, population::PopulationQuery,
|
analytics::AnalyticsQuery, classes::ClassesQuery, health::HealthQuery,
|
||||||
vehicles::VehicleQuery, world::WorldQuery, zone::ZoneQuery,
|
population::PopulationQuery, vehicles::VehicleQuery, world::WorldQuery, zone::ZoneQuery,
|
||||||
};
|
};
|
||||||
use async_graphql::MergedObject;
|
use async_graphql::MergedObject;
|
||||||
|
|
||||||
|
@ -12,4 +12,5 @@ pub struct Query(
|
||||||
WorldQuery,
|
WorldQuery,
|
||||||
ZoneQuery,
|
ZoneQuery,
|
||||||
HealthQuery,
|
HealthQuery,
|
||||||
|
AnalyticsQuery,
|
||||||
);
|
);
|
||||||
|
|
|
@ -65,6 +65,10 @@ async fn main() {
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"help" => cmd_help(),
|
"help" => cmd_help(),
|
||||||
"prune" => cmd_prune().await,
|
"prune" => cmd_prune().await,
|
||||||
|
"auto-prune" => loop {
|
||||||
|
cmd_prune().await;
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(60 * 5)).await;
|
||||||
|
},
|
||||||
"migrate" => cmd_migrate().await,
|
"migrate" => cmd_migrate().await,
|
||||||
_ => {
|
_ => {
|
||||||
println!("Unknown command: {}", command);
|
println!("Unknown command: {}", command);
|
||||||
|
|
|
@ -18,3 +18,4 @@ futures = "0.3.25"
|
||||||
async_once = "0.2.6"
|
async_once = "0.2.6"
|
||||||
serde-aux = "4.1.2"
|
serde-aux = "4.1.2"
|
||||||
axum = "0.6.1"
|
axum = "0.6.1"
|
||||||
|
chrono = "0.4.23"
|
|
@ -1,5 +1,5 @@
|
||||||
use async_once::AsyncOnce;
|
use async_once::AsyncOnce;
|
||||||
use axum::{routing::get, Router};
|
use axum::{routing::get, Json, Router};
|
||||||
use futures::{pin_mut, FutureExt};
|
use futures::{pin_mut, FutureExt};
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
|
@ -283,7 +283,14 @@ struct Payload {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn healthz() {
|
async fn healthz() {
|
||||||
let app = Router::new().route("/healthz", get(|| async { "ok" }));
|
let app = Router::new().route(
|
||||||
|
"/healthz",
|
||||||
|
get(|| async {
|
||||||
|
Json(json!({
|
||||||
|
"status": "ok",
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let port: u16 = std::env::var("PORT")
|
let port: u16 = std::env::var("PORT")
|
||||||
.unwrap_or("8999".to_string())
|
.unwrap_or("8999".to_string())
|
||||||
|
@ -314,7 +321,7 @@ async fn main() {
|
||||||
|
|
||||||
let fused_writer = rx.map(Ok).forward(write).fuse();
|
let fused_writer = rx.map(Ok).forward(write).fuse();
|
||||||
let fused_reader = read
|
let fused_reader = read
|
||||||
.for_each(|msg| async move {
|
.for_each(|msg| async {
|
||||||
let body = &msg.unwrap().to_string();
|
let body = &msg.unwrap().to_string();
|
||||||
|
|
||||||
let data: Payload = match serde_json::from_str(body) {
|
let data: Payload = match serde_json::from_str(body) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue