Compare commits

...

10 Commits

Author SHA1 Message Date
Lol3rrr
48e7c5c5b7 Start work on #34
Some checks failed
Testing/Linting / test (analysis) (push) Has been cancelled
Testing/Linting / test (backend) (push) Has been cancelled
Testing/Linting / lint (analysis) (push) Has been cancelled
Testing/Linting / lint (backend) (push) Has been cancelled
build-docker-image / docker (push) Has been cancelled
Started work on the garbage-collection module. This for now just loads the stored demo ids
and checks if they are also found in the database, logging every demo that could not be found
and should at some point delete these demos
2024-11-17 20:55:55 +01:00
Lol3rrr
fa21804cc3 Add Head-to-Head kill stats
Add basic head-to-head kill analysis and the corresponding matrix display in the UI
2024-11-05 07:50:43 +01:00
Lol3rrr
ecfed6b739 Another improvement to error handling 2024-11-04 22:45:06 +01:00
Lol3rrr
be220291f6 Improve error handling for queue elements 2024-11-04 22:33:30 +01:00
Lol3rrr
e01cbd0a51 Started a major frontend design overhaul 2024-11-04 01:32:25 +01:00
Lol3rrr
898a889a53 Add weapon and some other info to perround
In the events per round, it now dispalys the weapon used and headshot and noscope if applicable.
Otherwise also cleaned up other code
2024-11-03 14:47:34 +01:00
Lol3rrr
7e50a627f6 Add benchmarks for analysis passes 2024-11-02 03:30:28 +01:00
Lol3rrr
e8843540a3 Updated UI once more to allow for multiple sub-tabs 2024-11-01 23:03:25 +01:00
Lol3rrr
101833d0d8 Minor design improvements 2024-11-01 21:47:25 +01:00
Lol3rrr
1d6b6555b1 Minor UI improvements 2024-11-01 20:43:19 +01:00
32 changed files with 982 additions and 141 deletions

49
Cargo.lock generated
View File

@@ -38,6 +38,7 @@ version = "0.1.0"
dependencies = [
"colors-transform",
"csdemo",
"divan",
"image",
"phf",
"pretty_assertions",
@@ -554,6 +555,7 @@ dependencies = [
"anstyle",
"clap_lex",
"strsim",
"terminal_size",
]
[[package]]
@@ -606,6 +608,12 @@ dependencies = [
"serde",
]
[[package]]
name = "condtype"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af"
[[package]]
name = "config"
version = "0.14.1"
@@ -927,6 +935,31 @@ dependencies = [
"subtle",
]
[[package]]
name = "divan"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e05d17bd4ff1c1e7998ed4623d2efd91f72f1e24141ac33aac9377974270e1f"
dependencies = [
"cfg-if",
"clap",
"condtype",
"divan-macros",
"libc",
"regex-lite",
]
[[package]]
name = "divan-macros"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b4464d46ce68bfc7cb76389248c7c254def7baca8bece0693b02b83842c4c88"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dlv-list"
version = "0.5.2"
@@ -2933,6 +2966,12 @@ dependencies = [
"regex-syntax 0.8.5",
]
[[package]]
name = "regex-lite"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a"
[[package]]
name = "regex-syntax"
version = "0.6.29"
@@ -3709,6 +3748,16 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "terminal_size"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f599bd7ca042cfdf8f4512b277c02ba102247820f9d9d4a9f521f496751a6ef"
dependencies = [
"rustix",
"windows-sys 0.59.0",
]
[[package]]
name = "thiserror"
version = "1.0.66"

View File

@@ -17,3 +17,9 @@ phf = { version = "0.11" }
[dev-dependencies]
pretty_assertions = { version = "1.4" }
tracing-test = { version = "0.2", features = ["no-env-filter"] }
divan = "0.1.15"
[[bench]]
name = "analysis"
harness = false

View File

@@ -0,0 +1,35 @@
fn main() {
divan::main();
}
#[divan::bench(args = ["dust2.dem", "inferno.dem", "nuke.dem"])]
fn heatmap(bencher: divan::Bencher, file: &str) {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../testfiles/")
.join(file);
let data = std::fs::read(path).unwrap();
let config = analysis::heatmap::Config { cell_size: 2.0 };
bencher.bench(|| analysis::heatmap::parse(divan::black_box(&config), divan::black_box(&data)));
}
#[divan::bench(args = ["dust2.dem", "inferno.dem", "nuke.dem"])]
fn perround(bencher: divan::Bencher, file: &str) {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../testfiles/")
.join(file);
let data = std::fs::read(path).unwrap();
bencher.bench(|| analysis::perround::parse(divan::black_box(&data)));
}
#[divan::bench(args = ["dust2.dem", "inferno.dem", "nuke.dem"])]
fn endofgame(bencher: divan::Bencher, file: &str) {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../testfiles/")
.join(file);
let data = std::fs::read(path).unwrap();
bencher.bench(|| analysis::endofgame::parse(divan::black_box(&data)));
}

View File

@@ -0,0 +1,52 @@
use std::collections::HashMap;
#[derive(Debug, PartialEq)]
pub struct Output {
pub players: HashMap<csdemo::UserId, csdemo::parser::Player>,
pub head_to_head: HashMap<csdemo::UserId, HashMap<csdemo::UserId, usize>>,
}
pub fn parse(buf: &[u8]) -> Result<Output, ()> {
let tmp = csdemo::Container::parse(buf).map_err(|e| ())?;
let output = csdemo::lazyparser::LazyParser::new(tmp);
let players = output.player_info();
let mut head_to_head = HashMap::new();
for event in output.events().filter_map(|e| e.ok()) {
let event = match event {
csdemo::DemoEvent::GameEvent(ge) => *ge,
_ => continue,
};
match event {
csdemo::game_event::GameEvent::PlayerDeath(death) => {
let (attacker_player, attacker) = match death.attacker.and_then(|u| players.get(&u).zip(Some(u))) {
Some(a) => a,
None => continue,
};
let (died_player, died) = match death.userid.and_then(|u| players.get(&u).zip(Some(u))) {
Some(d) => d,
None => continue,
};
if attacker_player.team == died_player.team {
continue;
}
let attacker_entry: &mut HashMap<_, _> = head_to_head.entry(attacker).or_default();
let died_killed: &mut usize = attacker_entry.entry(died).or_default();
*died_killed += 1;
}
_ => {}
};
}
Ok(Output {
players,
head_to_head,
})
}

View File

@@ -1,3 +1,4 @@
pub mod endofgame;
pub mod heatmap;
pub mod perround;
pub mod head_to_head;

View File

@@ -60,7 +60,16 @@ pub struct Round {
pub enum RoundEvent {
BombPlanted,
BombDefused,
Kill { attacker: u64, died: u64 },
Kill {
attacker: u64,
died: u64,
#[serde(default)]
weapon: Option<String>,
#[serde(default)]
headshot: bool,
#[serde(default)]
noscope: bool,
},
}
#[derive(Debug)]
@@ -138,7 +147,7 @@ pub fn parse(buf: &[u8]) -> Result<PerRound, ()> {
};
}
let event = match ge.as_ref() {
let event = match *ge {
csdemo::game_event::GameEvent::BombPlanted(planted) => RoundEvent::BombPlanted,
csdemo::game_event::GameEvent::BombDefused(defused) => RoundEvent::BombDefused,
csdemo::game_event::GameEvent::PlayerDeath(death) => {
@@ -157,6 +166,9 @@ pub fn parse(buf: &[u8]) -> Result<PerRound, ()> {
RoundEvent::Kill {
attacker: attacker_player.xuid,
died: died_player.xuid,
weapon: death.weapon,
noscope: death.noscope.unwrap_or(false),
headshot: death.headshot.unwrap_or(false),
}
}
_ => continue,

View File

@@ -0,0 +1,40 @@
use analysis::head_to_head;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
#[test]
#[ignore = "Testing"]
fn head_to_head_nuke() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../testfiles/nuke.dem");
dbg!(path);
let input_bytes = std::fs::read(path).unwrap();
let result = head_to_head::parse(&input_bytes).unwrap();
let expected = head_to_head::Output {
players: [(csdemo::UserId(0), csdemo::parser::Player {
xuid: 0,
name: "".to_owned(),
team: 0,
color: 0,
})].into_iter().collect(),
head_to_head: [
(csdemo::UserId(0), HashMap::new()),
(csdemo::UserId(1), HashMap::new()),
(csdemo::UserId(2), HashMap::new()),
(csdemo::UserId(3), HashMap::new()),
(csdemo::UserId(4), HashMap::new()),
(csdemo::UserId(5), HashMap::new()),
(csdemo::UserId(6), HashMap::new()),
(csdemo::UserId(7), HashMap::new()),
(csdemo::UserId(8), HashMap::new()),
(csdemo::UserId(9), HashMap::new()),
].into_iter().collect(),
};
dbg!(&expected, &result);
assert_eq!(result, expected);
todo!()
}

View File

@@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl;
pub mod base;
pub mod heatmap;
pub mod perround;
pub mod head_to_head;
#[derive(Debug, Clone)]
pub enum AnalysisData {
@@ -23,8 +24,8 @@ impl AnalysisInput {
steamid: String,
demoid: String,
storage: &dyn crate::storage::DemoStorage,
) -> Result<Self, ()> {
let data = storage.load(steamid.clone(), demoid.clone()).await.unwrap();
) -> Result<Self, String> {
let data = storage.load(steamid.clone(), demoid.clone()).await?;
Ok(Self {
steamid,
@@ -61,12 +62,13 @@ pub trait Analysis {
>;
}
pub static ANALYSIS_METHODS: std::sync::LazyLock<[std::sync::Arc<dyn Analysis + Send + Sync>; 3]> =
pub static ANALYSIS_METHODS: std::sync::LazyLock<[std::sync::Arc<dyn Analysis + Send + Sync>; 4]> =
std::sync::LazyLock::new(|| {
[
std::sync::Arc::new(base::BaseAnalysis::new()),
std::sync::Arc::new(heatmap::HeatmapAnalysis::new()),
std::sync::Arc::new(perround::PerRoundAnalysis::new()),
std::sync::Arc::new(head_to_head::HeadToHeadAnalysis::new()),
]
});
@@ -111,6 +113,8 @@ where
let result = db_con
.build_transaction()
.run::<_, TaskError<AE>, _>(|conn| {
Box::pin(async move {
let mut results: Vec<crate::models::AnalysisTask> = query.load(conn).await?;
let task = match results.pop() {
@@ -118,21 +122,28 @@ where
None => return Ok(None),
};
let input = AnalysisInput::load(
task.steam_id.clone(),
task.demo_id.clone(),
let delete_query =
diesel::dsl::delete(crate::schema::analysis_queue::dsl::analysis_queue)
.filter(crate::schema::analysis_queue::dsl::demo_id.eq(task.demo_id.clone()))
.filter(crate::schema::analysis_queue::dsl::steam_id.eq(task.steam_id.clone()));
let input = match AnalysisInput::load(
task.steam_id,
task.demo_id,
storage.as_ref(),
)
.await
.unwrap();
.await {
Ok(i) => i,
Err(e) => {
tracing::error!("Loading Analysis Input: {:?}", e);
delete_query.execute(conn).await?;
return Ok(Some(()));
}
};
let tmp = action(input, &mut *conn);
tmp.await.map_err(|e| TaskError::RunningAction(e))?;
let delete_query =
diesel::dsl::delete(crate::schema::analysis_queue::dsl::analysis_queue)
.filter(crate::schema::analysis_queue::dsl::demo_id.eq(task.demo_id))
.filter(crate::schema::analysis_queue::dsl::steam_id.eq(task.steam_id));
delete_query.execute(conn).await?;
Ok(Some(()))

View File

@@ -0,0 +1,72 @@
use super::*;
pub struct HeadToHeadAnalysis {}
impl HeadToHeadAnalysis {
pub fn new() -> Self {
Self {}
}
}
impl Analysis for HeadToHeadAnalysis {
#[tracing::instrument(name = "HeadToHead", skip(self, input))]
fn analyse(
&self,
input: AnalysisInput,
) -> Result<
Box<
dyn FnOnce(
&mut diesel_async::pg::AsyncPgConnection,
) -> core::pin::Pin<
Box<
(dyn core::future::Future<Output = Result<(), diesel::result::Error>>
+ Send
+ '_),
>,
> + Send,
>,
(),
> {
tracing::info!("Performing Head-to-Head analysis");
let result = analysis::head_to_head::parse(input.data()).inspect_err(|e| {
tracing::error!("{:?}", e);
}).map_err(|e| ())?;
let values_to_insert: Vec<_> = result.head_to_head.into_iter().flat_map(|(user_id, enemies)| {
enemies.into_iter().map(move |(enemy, kills)| (user_id, enemy, kills))
}).filter_map(|(user_id, enemy_id, kills)| {
let player = result.players.get(&user_id)?;
let enemy = result.players.get(&enemy_id)?;
Some(crate::models::DemoHeadToHead {
demo_id: input.demoid.clone(),
player: player.xuid.to_string(),
enemy: enemy.xuid.to_string(),
kills: kills as i16,
})
}).collect();
Ok(Box::new(move |connection| {
// TODO
// Construct the actual queries
let query = diesel::insert_into(crate::schema::demo_head_to_head::dsl::demo_head_to_head)
.values(values_to_insert)
.on_conflict((
crate::schema::demo_head_to_head::dsl::demo_id,
crate::schema::demo_head_to_head::dsl::player,
crate::schema::demo_head_to_head::dsl::enemy,
))
.do_update().set(crate::schema::demo_head_to_head::kills.eq(diesel::upsert::excluded(crate::schema::demo_head_to_head::kills)));
Box::pin(async move {
// TODO
// Execute queries
query.execute(connection).await?;
Ok(())
})
}))
}
}

View File

@@ -47,7 +47,16 @@ impl Analysis for PerRoundAnalysis {
Box::pin(async move {
let query = diesel::dsl::insert_into(crate::schema::demo_round::dsl::demo_round)
.values(&values)
.on_conflict_do_nothing();
.on_conflict((
crate::schema::demo_round::dsl::demo_id,
crate::schema::demo_round::dsl::round_number,
))
.do_update()
.set(
crate::schema::demo_round::dsl::events.eq(diesel::upsert::excluded(
crate::schema::demo_round::dsl::events,
)),
);
query.execute(connection).await?;

View File

@@ -22,6 +22,7 @@ pub fn router(storage: Box<dyn crate::storage::DemoStorage>) -> axum::Router {
.route("/:id/analysis/scoreboard", axum::routing::get(scoreboard))
.route("/:id/analysis/perround", axum::routing::get(perround))
.route("/:id/analysis/heatmap", axum::routing::get(heatmap))
.route("/:id/analysis/headtohead", axum::routing::get(head_to_head))
.with_state(Arc::new(DemoState { storage }))
}
@@ -44,12 +45,22 @@ async fn list(
crate::schema::demo_teams::table
.on(crate::schema::demos::dsl::demo_id.eq(crate::schema::demo_teams::dsl::demo_id)),
)
.inner_join(
crate::schema::demo_players::table
.on(crate::schema::demos::dsl::demo_id
.eq(crate::schema::demo_players::dsl::demo_id)),
)
.select((
crate::models::Demo::as_select(),
crate::models::DemoInfo::as_select(),
crate::models::DemoTeam::as_select(),
crate::models::DemoPlayer::as_select(),
))
.filter(crate::schema::demos::dsl::steam_id.eq(steam_id.to_string()));
.filter(
crate::schema::demos::dsl::steam_id
.eq(steam_id.to_string())
.and(crate::schema::demo_players::dsl::steam_id.eq(steam_id.to_string())),
);
let pending_query = crate::schema::demos::dsl::demos
.inner_join(crate::schema::processing_status::table.on(
crate::schema::demos::dsl::demo_id.eq(crate::schema::processing_status::dsl::demo_id),
@@ -72,6 +83,7 @@ async fn list(
crate::models::Demo,
crate::models::DemoInfo,
crate::models::DemoTeam,
crate::models::DemoPlayer,
)> = done_query.load(con).await?;
let pending_results: Vec<(crate::models::Demo)> = pending_query.load(con).await?;
@@ -83,7 +95,7 @@ async fn list(
.unwrap();
let mut demos = std::collections::HashMap::new();
for (demo, info, team) in results.into_iter() {
for (demo, info, team, player) in results.into_iter() {
let entry = demos
.entry(demo.demo_id.clone())
.or_insert(common::BaseDemoInfo {
@@ -92,6 +104,7 @@ async fn list(
uploaded_at: demo.uploaded_at,
team2_score: 0,
team3_score: 0,
player_team: player.team,
});
if team.team == 2 {
@@ -287,18 +300,34 @@ async fn scoreboard(
),
),
)
.filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id));
.filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id.clone()));
let team_query = crate::schema::demo_teams::dsl::demo_teams
.filter(crate::schema::demo_teams::dsl::demo_id.eq(demo_id));
let mut db_con = crate::db_connection().await;
let response: Vec<(crate::models::DemoPlayer, crate::models::DemoPlayerStats)> =
match query.load(&mut db_con).await {
Ok(d) => d,
Err(e) => {
tracing::error!("Querying DB: {:?}", e);
return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
};
let db_result = db_con
.build_transaction()
.read_only()
.run::<_, diesel::result::Error, _>(|con| {
Box::pin(async move {
let players: Vec<(crate::models::DemoPlayer, crate::models::DemoPlayerStats)> =
query.load(con).await?;
let teams: Vec<crate::models::DemoTeam> = team_query.load(con).await?;
Ok((players, teams))
})
})
.await;
let (response, team_response) = match db_result {
Ok(d) => d,
Err(e) => {
tracing::error!("Querying DB {:?}", e);
return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
}
};
if response.is_empty() {
tracing::error!("DB Response was empty");
@@ -307,9 +336,16 @@ async fn scoreboard(
let mut teams = std::collections::BTreeMap::new();
for (player, stats) in response {
let team = teams.entry(player.team as u32).or_insert(Vec::new());
let team =
teams
.entry(player.team as u32)
.or_insert(common::demo_analysis::ScoreBoardTeam {
number: player.team as u32,
players: Vec::new(),
score: 0,
});
team.push(common::demo_analysis::ScoreBoardPlayer {
team.players.push(common::demo_analysis::ScoreBoardPlayer {
name: player.name,
kills: stats.kills as usize,
deaths: stats.deaths as usize,
@@ -318,8 +354,15 @@ async fn scoreboard(
});
}
for team in team_response {
let number = team.team as u32;
if let Some(entry) = teams.get_mut(&number) {
entry.score = team.end_score;
}
}
Ok(axum::Json(common::demo_analysis::ScoreBoard {
teams: teams.into_iter().collect::<Vec<_>>(),
teams: teams.into_values().collect::<Vec<_>>(),
}))
}
@@ -463,7 +506,13 @@ async fn perround(
analysis::perround::RoundEvent::BombDefused => {
common::demo_analysis::RoundEvent::BombDefused
}
analysis::perround::RoundEvent::Kill { attacker, died } => {
analysis::perround::RoundEvent::Kill {
attacker,
died,
weapon,
noscope,
headshot,
} => {
let attacker_name = players
.iter()
.find(|p| p.steam_id == attacker.to_string())
@@ -478,6 +527,9 @@ async fn perround(
common::demo_analysis::RoundEvent::Killed {
attacker: attacker_name,
died: died_name,
weapon,
headshot,
noscope,
}
}
})
@@ -505,6 +557,49 @@ async fn perround(
}))
}
#[tracing::instrument(skip(session))]
async fn head_to_head(
session: UserSession,
Path(demo_id): Path<String>,
) -> Result<axum::response::Json<common::demo_analysis::HeadToHead>, axum::http::StatusCode> {
let mut db_con = crate::db_connection().await;
let head_to_head_query = crate::schema::demo_head_to_head::dsl::demo_head_to_head
.filter(crate::schema::demo_head_to_head::dsl::demo_id.eq(demo_id.clone()));
let player_query = crate::schema::demo_players::dsl::demo_players
.filter(crate::schema::demo_players::dsl::demo_id.eq(demo_id));
let (players, head_to_head_entries) = db_con.build_transaction().read_only().run(|connection| Box::pin(async move {
let head_to_head_entries: Vec<crate::models::DemoHeadToHead> = head_to_head_query.load(connection).await?;
let players: Vec<crate::models::DemoPlayer> = player_query.load(connection).await?;
Ok::<_, diesel::result::Error>((players, head_to_head_entries))
})).await.map_err(|e| {
tracing::error!("Querying DB: {:?}", e);
axum::http::StatusCode::INTERNAL_SERVER_ERROR
})?;
let (mut row_team, mut column_team): (Vec<_>, Vec<_>) = players.into_iter().partition(|p| p.team == 2);
row_team.sort_unstable_by_key(|p| p.color);
column_team.sort_unstable_by_key(|p| p.color);
let results: Vec<_> = row_team.iter().map(|row_player| {
column_team.iter().map(|column_player| {
let row_kills = head_to_head_entries.iter().find(|entry| entry.player == row_player.steam_id && entry.enemy == column_player.steam_id);
let column_kills = head_to_head_entries.iter().find(|entry| entry.player == column_player.steam_id && entry.enemy == row_player.steam_id);
(row_kills.map(|k| k.kills).unwrap_or(0), column_kills.map(|k| k.kills).unwrap_or(0))
}).collect::<Vec<_>>()
}).collect();
Ok(axum::Json(common::demo_analysis::HeadToHead {
row_players: row_team.into_iter().map(|p| p.name).collect(),
column_players: column_team.into_iter().map(|p| p.name).collect(),
entries: results,
}))
}
// The corresponding values for each map can be found using the Source2 Viewer and opening the
// files in 'game/csgo/pak01_dir.vpk' and then 'resource/overviews/{map}.txt'
static MINIMAP_COORDINATES: phf::Map<&str, MiniMapDefinition> = phf::phf_map! {

36
backend/src/gc.rs Normal file
View File

@@ -0,0 +1,36 @@
use diesel::prelude::*;
use diesel_async::RunQueryDsl;
#[tracing::instrument(skip(storage))]
pub async fn run_gc(storage: &mut dyn crate::storage::DemoStorage) -> Result<(), ()> {
let stored_demos = match storage.list_demos().await {
Ok(ds) => ds,
Err(e) => return Err(()),
};
tracing::info!("Found {} demos in storage", stored_demos.len());
let mut db_con = crate::db_connection().await;
let db_res = db_con
.build_transaction()
.run(move |con| {
Box::pin(async move {
for demo in stored_demos {
let query = crate::schema::demos::dsl::demos
.filter(crate::schema::demos::dsl::demo_id.eq(&demo)).select(crate::models::Demo::as_select());
let matching: Vec<crate::models::Demo> = query.load(con).await?;
if matching.is_empty() {
tracing::debug!("Should delete old demo {:?}", demo);
}
}
Ok::<(), diesel::result::Error>(())
})
})
.await;
db_res.map_err(|e| ())
}

View File

@@ -8,6 +8,8 @@ pub mod diesel_sessionstore;
pub mod analysis;
mod gc;
pub async fn db_connection() -> diesel_async::AsyncPgConnection {
use diesel_async::AsyncConnection;
@@ -132,3 +134,16 @@ pub async fn run_analysis(storage: Box<dyn crate::storage::DemoStorage>) {
}
}
}
#[tracing::instrument(skip(storage))]
pub async fn run_garbage_collection(mut storage: Box<dyn crate::storage::DemoStorage>) {
loop {
tracing::info!("Running Garbage Collection");
if let Err(e) = gc::run_gc(storage.as_mut()).await {
tracing::error!("Running GC {:?}", e);
}
tokio::time::sleep(std::time::Duration::from_secs(15 * 60)).await;
}
}

View File

@@ -27,6 +27,9 @@ struct CliArgs {
#[clap(long = "analysis", default_value_t = true)]
analysis: bool,
#[clap(long = "gc", default_value_t = true)]
garbage_collection: bool,
}
#[tokio::main(flavor = "multi_thread")]
@@ -93,7 +96,12 @@ async fn main() {
if args.analysis {
tracing::info!("Enabled Analysis module");
component_set.spawn(backend::run_analysis(storage));
component_set.spawn(backend::run_analysis(storage.duplicate()));
}
if args.garbage_collection {
tracing::info!("Enabled Garbage-Collection module");
component_set.spawn(backend::run_garbage_collection(storage));
}
tracing::info!("Started modules");

View File

@@ -120,3 +120,13 @@ pub struct DemoRound {
pub win_reason: String,
pub events: serde_json::Value,
}
#[derive(Queryable, Selectable, Insertable, Debug)]
#[diesel(table_name = crate::schema::demo_head_to_head)]
#[diesel(check_for_backend(diesel::pg::Pg))]
pub struct DemoHeadToHead {
pub demo_id: String,
pub player: String,
pub enemy: String,
pub kills: i16,
}

View File

@@ -8,6 +8,15 @@ diesel::table! {
}
}
diesel::table! {
demo_head_to_head (demo_id, player, enemy) {
demo_id -> Text,
player -> Text,
enemy -> Text,
kills -> Int2,
}
}
diesel::table! {
demo_heatmaps (demo_id, steam_id, team) {
demo_id -> Text,
@@ -97,6 +106,7 @@ diesel::table! {
diesel::allow_tables_to_appear_in_same_query!(
analysis_queue,
demo_head_to_head,
demo_heatmaps,
demo_info,
demo_player_stats,

View File

@@ -21,6 +21,9 @@ pub trait DemoStorage: Send + Sync {
) -> futures::future::BoxFuture<'f, Result<crate::analysis::AnalysisData, String>>
where
'own: 'f;
fn list_demos<'own, 'f>(&'own self) -> futures::future::BoxFuture<'f, Result<Vec<String>, String>>
where 'own: 'f;
}
pub struct FileStorage {
@@ -107,6 +110,14 @@ impl DemoStorage for FileStorage {
}
.boxed()
}
fn list_demos<'own, 'f>(&'own self) -> futures::future::BoxFuture<'f, Result<Vec<String>, String>>
where 'own: 'f {
async move {
// TODO
Ok(Vec::new())
}.boxed()
}
}
pub struct S3Storage {
@@ -154,12 +165,10 @@ impl DemoStorage for S3Storage {
let body_reader = tokio_util::io::StreamReader::new(body_with_io_error);
futures::pin_mut!(body_reader);
self.bucket.list(String::new(), None).await.unwrap();
self.bucket
.put_object_stream(&mut body_reader, path)
.await
.unwrap();
.map_err(|e| format!("Uploading Stream to bucket: {:?}", e))?;
Ok(())
}
@@ -178,7 +187,7 @@ impl DemoStorage for S3Storage {
let path = std::path::PathBuf::new().join(user_id).join(demo_id);
let path = path.to_str().unwrap();
let resp = self.bucket.get_object(path).await.unwrap();
let resp = self.bucket.get_object(path).await.map_err(|e| format!("Loading from Bucket: {:?}", e))?;
Ok(crate::analysis::AnalysisData::Preloaded(
resp.to_vec().into(),
@@ -186,4 +195,20 @@ impl DemoStorage for S3Storage {
}
.boxed()
}
fn list_demos<'own, 'f>(&'own self) -> futures::future::BoxFuture<'f, Result<Vec<String>, String>>
where 'own: 'f {
async move {
let listed = match self.bucket.list("".to_string(), None).await {
Ok(l) => l,
Err(e) => return Err(format!("{:?}", e)),
};
Ok(listed.into_iter().flat_map(|rs| {
rs.contents.into_iter().map(|entry| {
entry.key
})
}).collect())
}.boxed()
}
}

View File

@@ -1,6 +1,13 @@
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ScoreBoard {
pub teams: Vec<(u32, Vec<ScoreBoardPlayer>)>,
pub teams: Vec<ScoreBoardTeam>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ScoreBoardTeam {
pub number: u32,
pub score: i16,
pub players: Vec<ScoreBoardPlayer>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -67,5 +74,18 @@ pub enum RoundWinReason {
pub enum RoundEvent {
BombPlanted,
BombDefused,
Killed { attacker: String, died: String },
Killed {
attacker: String,
died: String,
weapon: Option<String>,
noscope: bool,
headshot: bool,
},
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct HeadToHead {
pub row_players: Vec<String>,
pub column_players: Vec<String>,
pub entries: Vec<Vec<(i16, i16)>>,
}

View File

@@ -11,6 +11,7 @@ pub struct BaseDemoInfo {
pub uploaded_at: chrono::naive::NaiveDateTime,
pub team2_score: i16,
pub team3_score: i16,
pub player_team: i16,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]

26
frontend/colors.css Normal file
View File

@@ -0,0 +1,26 @@
* {
/** Generated using https://colorffy.com/dark-theme-generator?colors=40f7f4-121212 */
/** Dark theme primary colors */
--color-primary-a0: #40f7f4;
--color-primary-a10: #66f8f5;
--color-primary-a20: #81faf6;
--color-primary-a30: #98fbf8;
--color-primary-a40: #acfcf9;
--color-primary-a50: #befdfa;
/** Dark theme surface colors */
--color-surface-a0: #121212;
--color-surface-a10: #282828;
--color-surface-a20: #3f3f3f;
--color-surface-a30: #575757;
--color-surface-a40: #717171;
--color-surface-a50: #8b8b8b;
/** Dark theme mixed surface colors */
--color-surface-mixed-a0: #1b2525;
--color-surface-mixed-a10: #303939;
--color-surface-mixed-a20: #474f4f;
--color-surface-mixed-a30: #5e6666;
--color-surface-mixed-a40: #777e7d;
--color-surface-mixed-a50: #919696;
}

View File

@@ -3,9 +3,17 @@
<head>
<link rel="stylesheet" href="/main.css">
<link data-trunk rel="css" href="colors.css">
<link data-trunk rel="copy-dir" href="static/"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
background-color: var(--color-surface-a0);
color: var(--color-primary-a50);
}
</style>
</head>
<body></body>
</html>

View File

@@ -42,27 +42,8 @@ pub fn demo() -> impl leptos::IntoView {
None => String::new(),
};
let selected_tab = move || {
let loc = leptos_router::use_location();
let loc_path = loc.pathname.get();
let trailing = loc_path.split('/').last();
trailing.unwrap_or("/").to_owned()
};
let style = stylers::style! {
"Demo",
.analysis_bar {
display: grid;
grid-template-columns: auto auto auto;
column-gap: 20px;
background-color: #2d2d2d;
}
.analysis_selector {
display: inline-block;
}
span {
display: inline-block;
@@ -73,19 +54,93 @@ pub fn demo() -> impl leptos::IntoView {
.current {
background-color: #5d5d5d;
}
.demo_heading {
display: grid;
margin: 2vh 0px;
grid-template-columns: auto auto;
}
};
view! {class = style,
<h2>Demo - { id } - { map }</h2>
<button on:click=move |_| rerun_analysis.dispatch(())>Rerun analysis</button>
<div class="analysis_bar">
<div class="analysis_selector" class:current=move || selected_tab() == "scoreboard"><A href="scoreboard"><span>Scoreboard</span></A></div>
<div class="analysis_selector" class:current=move || selected_tab() == "perround"><A href="perround"><span>Per Round</span></A></div>
<div class="analysis_selector" class:current=move || selected_tab() == "heatmaps"><A href="heatmaps"><span>Heatmaps</span></A></div>
<div class="demo_heading">
<h2>Demo - { id } - { map }</h2>
<button on:click=move |_| rerun_analysis.dispatch(()) style="display: inline-block;">Rerun Analysis</button>
</div>
<TabBar prefix=move || format!("/demo/{}/", id()) parts=&[("scoreboard", "Scoreboard"), ("perround", "Per Round"), ("heatmaps", "Heatmaps")] />
<div>
<Outlet/>
</div>
}
}
#[leptos::component]
pub fn tab_bar<P>(
prefix: P,
parts: &'static [(&'static str, &'static str)],
) -> impl leptos::IntoView
where
P: Fn() -> String + Copy + 'static,
{
let selected_tab = move || {
let prefix = prefix();
let loc = leptos_router::use_location();
let loc_path = loc.pathname.get();
let trailing = loc_path
.strip_prefix(&prefix)
.unwrap_or(&loc_path)
.split('/')
.filter(|l| !l.is_empty())
.next();
trailing
.or(parts.first().map(|p| p.0))
.unwrap_or("")
.to_owned()
};
let style = stylers::style! {
"TabBar",
.analysis_bar {
display: grid;
grid-template-columns: repeat(var(--rows), auto);
column-gap: 20px;
background-color: var(--color-surface-a10);
}
.analysis_selector {
display: inline-block;
}
span {
display: inline-block;
padding: 1vh 1vw;
color: #d5d5d5;
background-color: var(--color-surface-a20);
}
.current {
background-color: var(--color-surface-a30);
}
};
let tabs = move || {
parts.into_iter().map(|(routename, name)| {
view! {class=style,
<div class="analysis_selector" class:current=move || selected_tab() == routename.to_string()>
<A href=routename.to_string()>
<span>{ name.to_string() }</span>
</A>
</div>
}
}).collect::<Vec<_>>()
};
view! {class = style,
<div class="analysis_bar" style=format!("--rows: {}", parts.len())>
{ tabs }
</div>
}
}

View File

@@ -31,6 +31,8 @@ pub fn per_round() -> impl leptos::IntoView {
.round_overview {
display: inline-grid;
margin-top: 2vh;
width: 90vw;
grid-template-columns: auto repeat(12, 1fr) 5px repeat(12, 1fr) 5px repeat(3, 1fr) 5px repeat(3, 1fr);
grid-template-rows: repeat(3, auto);
@@ -79,24 +81,33 @@ pub fn per_round() -> impl leptos::IntoView {
match (current_round, teams) {
(Some(round), Some(teams)) => {
round.events.iter().map(|event| {
round.events.into_iter().map(|event| {
match event {
common::demo_analysis::RoundEvent::BombPlanted => view! { <li>Bomb has been planted</li> }.into_view(),
common::demo_analysis::RoundEvent::BombDefused => view! { <li>Bomb has been defused</li> }.into_view(),
common::demo_analysis::RoundEvent::Killed { attacker, died } => {
let mut attacker_t = teams.iter().find(|t| t.players.contains(attacker)).map(|t| t.name == "TERRORIST").unwrap_or(false);
let mut died_t = teams.iter().find(|t| t.players.contains(died)).map(|t| t.name == "TERRORIST").unwrap_or(false);
common::demo_analysis::RoundEvent::Killed { attacker, died, weapon, headshot, noscope } => {
let mut attacker_t = teams.iter().find(|t| t.players.contains(&attacker)).map(|t| t.name == "TERRORIST").unwrap_or(false);
let mut died_t = teams.iter().find(|t| t.players.contains(&died)).map(|t| t.name == "TERRORIST").unwrap_or(false);
if (12..27).contains(&round_index) {
attacker_t = !attacker_t;
died_t = !died_t;
}
let weapon_display = move || {
let parts = weapon.as_ref().into_iter().map(|w| w.as_str())
.chain(headshot.then_some("Headshot").into_iter())
.chain(noscope.then_some("Noscope").into_iter());
format!("(using {})", parts.collect::<Vec<_>>().join(","))
};
view! {
class=style,
<li>{"'"}
<span class:t_player=move || attacker_t class:ct_player=move || !attacker_t>{ attacker }</span>{"'"} killed {"'"}
<span class:t_player=move || died_t class:ct_player=move || !died_t>{ died }</span>{"'"}
<li>
{"'"}<span class:t_player=move || attacker_t class:ct_player=move || !attacker_t>{ attacker }</span>{"'"}
killed { weapon_display }
{"'"}<span class:t_player=move || died_t class:ct_player=move || !died_t>{ died }</span>{"'"}
</li>
}.into_view()
},
@@ -192,8 +203,6 @@ pub fn per_round() -> impl leptos::IntoView {
view! {
class=style,
<h3>Per Round</h3>
<div class="round_overview">
{ team_names }
{ round_overview }

View File

@@ -1,46 +1,20 @@
use leptos::*;
use leptos_router::Outlet;
pub mod general;
pub mod headtohead;
use crate::demo::TabBar;
#[leptos::component]
pub fn scoreboard() -> impl leptos::IntoView {
use leptos::Suspense;
let scoreboard_resource =
create_resource(leptos_router::use_params_map(), |params| async move {
let id = params.get("id").unwrap();
let res =
reqwasm::http::Request::get(&format!("/api/demos/{}/analysis/scoreboard", id))
.send()
.await
.unwrap();
res.json::<common::demo_analysis::ScoreBoard>()
.await
.unwrap()
});
let (ordering, set_ordering) = create_signal::<orderings::Ordering>(orderings::DAMAGE);
let scoreboards = move || {
scoreboard_resource
.get()
.into_iter()
.flat_map(|v| v.teams.into_iter())
.map(|(team, players)| {
view! {
<TeamScoreboard value=players team_name=format!("Team {}", team) />
}
})
.collect::<Vec<_>>()
};
let params = leptos_router::use_params_map();
let id = move || params.with(|params| params.get("id").cloned().unwrap_or_default());
view! {
<h2>Scoreboard</h2>
<TabBar prefix=move || format!("/demo/{}/scoreboard", id()) parts=&[("general", "General"), ("headtohead", "Head-to-Head")] />
<Suspense
fallback=move || view! { <p>Loading Scoreboard data</p> }
>
{ scoreboards }
</Suspense>
<Outlet />
}
}
@@ -103,7 +77,7 @@ fn team_scoreboard(
let style = stylers::style! {
"Team-Scoreboard",
tr:nth-child(even) {
background-color: #dddddd;
background-color: var(--color-surface-a10);
}
th {

View File

@@ -0,0 +1,45 @@
use leptos::*;
use super::*;
#[leptos::component]
pub fn general() -> impl leptos::IntoView {
use leptos::Suspense;
let scoreboard_resource =
create_resource(leptos_router::use_params_map(), |params| async move {
let id = params.get("id").unwrap();
let res =
reqwasm::http::Request::get(&format!("/api/demos/{}/analysis/scoreboard", id))
.send()
.await
.unwrap();
res.json::<common::demo_analysis::ScoreBoard>()
.await
.unwrap()
});
let (ordering, set_ordering) = create_signal::<orderings::Ordering>(orderings::DAMAGE);
let scoreboards = move || {
scoreboard_resource
.get()
.into_iter()
.flat_map(|v| v.teams.into_iter())
.map(|team| {
view! {
<TeamScoreboard value=team.players team_name=format!("Team {} - {}", team.number, team.score) />
}
})
.collect::<Vec<_>>()
};
view! {
<Suspense
fallback=move || view! { <p>Loading Scoreboard data</p> }
>
{ scoreboards }
</Suspense>
}
}

View File

@@ -0,0 +1,98 @@
use leptos::*;
use leptos::Suspense;
#[leptos::component]
pub fn head_to_head() -> impl leptos::IntoView {
let head_to_head_resource =
create_resource(leptos_router::use_params_map(), |params| async move {
let id = params.get("id").unwrap();
let res =
reqwasm::http::Request::get(&format!("/api/demos/{}/analysis/headtohead", id))
.send()
.await
.unwrap();
res.json::<common::demo_analysis::HeadToHead>()
.await
.unwrap()
});
let style = stylers::style! {
"Head-to-Head-root",
div {
display: grid;
}
};
let matrix_view = move || head_to_head_resource.get().map(|r| view! {
<Matrix data=r />
});
view! {
class=style,
<Suspense fallback=move || view! { <p>Loading Head-to-Head data...</p> }>
<div>
{ matrix_view }
</div>
</Suspense>
}
}
#[leptos::component]
fn matrix(data: common::demo_analysis::HeadToHead) -> impl leptos::IntoView {
let row_player_view = move || {
data.row_players.iter().enumerate().map(|(idx, name)| {
view! {
<span style=format!("grid-row: {}; grid-column: 1;", idx + 2)> {name} </span>
}
}).collect::<Vec<_>>()
};
let column_player_view = move || {
data.column_players.iter().enumerate().map(|(idx, name)| {
view! {
<span style=format!("grid-row: 1; grid-column: {}; text-align: right", idx + 2)> {name} </span>
}
}).collect::<Vec<_>>()
};
let style = stylers::style! {
"Head-to-Head-Matrix-Cell",
.cell {
display: grid;
}
.cell_back {
grid-row: 1/3;
grid-column: 1/3;
background-color: var(--color-surface-a0);
background-image: linear-gradient(to right top, var(--color-surface-a0) 50%, var(--color-surface-a10) 50%);
}
};
let entry_view = move || {
data.entries.iter().enumerate().flat_map(|(row, row_data)| {
row_data.iter().enumerate().map(move |(column, &(row_kills, column_kills))| {
let entry = move || view! {
<div style="grid-row: 2; grid-column: 1; text-align: center;">{ row_kills }</div>
<div style="grid-row: 1; grid-column: 2; text-align: center;">{ column_kills }</div>
};
view! {
class = style,
<div class="cell" style=format!("grid-row: {}; grid-column: {};", row + 2, column + 2)>
<div class="cell_back"></div>
{ entry }
</div>
}
})
}).collect::<Vec<_>>()
};
view! {
{ row_player_view }
{ column_player_view }
{ entry_view }
}
}

View File

@@ -3,9 +3,7 @@ use leptos::*;
#[leptos::component]
pub fn homepage(get_notification: ReadSignal<u8>) -> impl leptos::IntoView {
let demo_data = create_resource(
move || {
get_notification.get()
},
move || get_notification.get(),
|_| async move {
let res = reqwasm::http::Request::get("/api/demos/list")
.send()
@@ -16,11 +14,24 @@ pub fn homepage(get_notification: ReadSignal<u8>) -> impl leptos::IntoView {
},
);
let pending_display = move || {
demo_data
.get()
.map(|d| d.pending)
.filter(|p| p.len() > 0)
.map(|pending| {
view! {
<p>{pending.len()} demos are pending/waiting for analysis</p>
}
})
};
view! {
<div>
<div>
<h2>Demos</h2>
</div>
{ pending_display }
<DemoList demos=demo_data />
</div>
}
@@ -34,18 +45,23 @@ fn demo_list(
"DemoList",
.list {
display: inline-grid;
width: 100%;
grid-template-columns: auto auto auto;
row-gap: 1ch;
row-gap: 1vh;
}
.headers {
font-size: 22px;
}
};
view! {
class=style,
<div class="list">
<span>Score</span>
<span>Date</span>
<span>Map</span>
<span class="headers">Score</span>
<span class="headers">Date</span>
<span class="headers">Map</span>
{ move || demos.get().map(|d| d.done).unwrap_or_default().into_iter().enumerate().map(|(i, demo)| view! { <DemoListEntry demo idx=i+1 /> }).collect::<Vec<_>>() }
</div>
@@ -59,12 +75,14 @@ fn demo_list_entry(demo: common::BaseDemoInfo, idx: usize) -> impl leptos::IntoV
.entry {
display: inline-block;
border: solid #030303aa 1px;
grid-column: 1 / 4;
width: 100%;
height: 100%;
}
.background_entry {
background-color: var(--color-surface-a20);
border-radius: 6px;
}
.score, .map {
padding-left: 5px;
@@ -90,14 +108,37 @@ fn demo_list_entry(demo: common::BaseDemoInfo, idx: usize) -> impl leptos::IntoV
grid-template-columns: auto;
grid-template-rows: auto auto;
}
.won {
color: #00aa00;
}
.lost {
color: #aa0000;
}
};
let (player_score, enemy_score) = if demo.player_team == 2 {
(demo.team2_score, demo.team3_score)
} else {
(demo.team3_score, demo.team2_score)
};
let won = move || player_score > enemy_score;
let lost = move || enemy_score > player_score;
let tie = move || player_score == enemy_score;
view! {
class=style,
<span class="score" style=format!("grid-row: {};", idx + 1)>{demo.team2_score}:{demo.team3_score}</span>
<span class="entry background_entry" style=format!("grid-row: {};", idx + 1)></span>
<span
class="score"
style=format!("grid-row: {};", idx + 1)
class:won=won
class:lost=lost
class:tie=tie
>{demo.team2_score}:{demo.team3_score}</span>
<div class="date" style=format!("grid-row: {};", idx + 1)>
<span>{demo.uploaded_at.format("%Y-%m-%d").to_string()}</span>
<span>{demo.uploaded_at.format("%H-%M-%S").to_string()}</span>
<span>{demo.uploaded_at.format("%H:%M:%S").to_string()}</span>
</div>
<span class="map" style=format!("grid-row: {};", idx + 1)>{demo.map}</span>
<a class="entry" href=format!("demo/{}/scoreboard", demo.id) style=format!("grid-row: {};", idx + 1)></a>

View File

@@ -47,27 +47,89 @@ pub fn upload_demo(
.shown {
display: block;
}
.lds-ellipsis,
.lds-ellipsis div {
box-sizing: border-box;
}
.lds-ellipsis {
display: inline-block;
position: relative;
width: 60px;
height: 20px;
}
.lds-ellipsis div {
position: absolute;
top: 7px;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation-timing-function: cubic-bezier(0, 1, 1, 0);
}
.lds-ellipsis div:nth-child(1) {
left: 8px;
animation: lds-ellipsis1 0.6s infinite;
}
.lds-ellipsis div:nth-child(2) {
left: 8px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(3) {
left: 28px;
animation: lds-ellipsis2 0.6s infinite;
}
.lds-ellipsis div:nth-child(4) {
left: 48px;
animation: lds-ellipsis3 0.6s infinite;
}
@keyframes lds-ellipsis1 {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
@keyframes lds-ellipsis3 {
0% {
transform: scale(1);
}
100% {
transform: scale(0);
}
}
@keyframes lds-ellipsis2 {
0% {
transform: translate(0, 0);
}
100% {
transform: translate(20px, 0);
}
}
};
let uploading = RwSignal::new(false);
let handle_resp: std::rc::Rc<dyn Fn(&leptos::web_sys::Response)> = std::rc::Rc::new(move |resp: &leptos::web_sys::Response| {
if resp.status() != 200 {
// TODO
// Display error somehow
return;
}
let handle_resp: std::rc::Rc<dyn Fn(&leptos::web_sys::Response)> =
std::rc::Rc::new(move |resp: &leptos::web_sys::Response| {
if resp.status() != 200 {
// TODO
// Display error somehow
return;
}
uploading.set(false);
uploading.set(false);
// Remove the Upload popup
update_shown.set(DemoUploadStatus::Hidden);
// Remove the Upload popup
update_shown.set(DemoUploadStatus::Hidden);
// Reload the demo list
reload_demos.update(|v| {
*v = v.wrapping_add(1);
// Reload the demo list
reload_demos.update(|v| {
*v = v.wrapping_add(1);
});
});
});
let on_submit: std::rc::Rc<dyn Fn(&leptos::web_sys::FormData)> = std::rc::Rc::new(move |_| {
uploading.set(true);
@@ -86,10 +148,13 @@ pub fn upload_demo(
Close
</button>
<p
<div
class:shown=move || uploading.get()
class:hidden=move || !uploading.get()
>Uploading...</p>
>
<span>Uploading</span>
<div class="lds-ellipsis"><div></div><div></div><div></div><div></div></div>
</div>
</div>
}
}

View File

@@ -20,8 +20,12 @@ fn main() {
<Routes>
<Route path="/" view=move || view! { <Homepage get_notification=get_reload_demos /> } />
<Route path="/demo/:id" view=Demo>
<Route path="scoreboard" view=frontend::demo::scoreboard::Scoreboard>
<Route path="general" view=frontend::demo::scoreboard::general::General />
<Route path="headtohead" view=frontend::demo::scoreboard::headtohead::HeadToHead />
<Route path="" view=frontend::demo::scoreboard::general::General />
</Route>
<Route path="perround" view=frontend::demo::perround::PerRound />
<Route path="scoreboard" view=frontend::demo::scoreboard::Scoreboard />
<Route path="heatmaps" view=frontend::demo::heatmap::Heatmaps />
<Route path="" view=frontend::demo::scoreboard::Scoreboard />
</Route>

View File

@@ -31,9 +31,7 @@ fn steam_login(height: &'static str, width: &'static str) -> impl leptos::IntoVi
}
};
view! {
{ tmp }
}
tmp
}
#[leptos::component]
@@ -45,8 +43,9 @@ pub fn top_bar(update_demo_visible: WriteSignal<DemoUploadStatus>) -> impl lepto
height: 4vh;
padding-top: 0.5vh;
padding-bottom: 0.5vh;
border-radius: 6px;
background-color: #28282f;
background-color: var(--color-surface-a10);
color: #d5d5d5;
display: grid;
@@ -60,7 +59,7 @@ pub fn top_bar(update_demo_visible: WriteSignal<DemoUploadStatus>) -> impl lepto
}
.logo {
color: #d5d5d5;
color: var(--color-primary-a50);
width: 15vw;
font-size: 24px;
padding: 0px;

View File

@@ -0,0 +1,2 @@
-- This file should undo anything in `up.sql`
DROP TABLE demo_head_to_head;

View File

@@ -0,0 +1,8 @@
-- Your SQL goes here
CREATE TABLE IF NOT EXISTS demo_head_to_head (
demo_id TEXT NOT NULL,
player TEXT NOT NULL,
enemy TEXT NOT NULL,
kills int2 NOT NULL,
PRIMARY KEY (demo_id, player, enemy)
);