From b3607c634658a577af150bc3bf80b8d3db548bfc Mon Sep 17 00:00:00 2001 From: Luke Street Date: Wed, 23 Apr 2025 23:18:11 -0600 Subject: [PATCH] Add manage pages, self-add projects & history graphs --- Cargo.lock | 11 + Cargo.toml | 1 + crates/auth/Cargo.toml | 3 +- crates/auth/src/lib.rs | 90 ++++- crates/core/src/lib.rs | 2 +- crates/core/src/models.rs | 93 ++++- crates/db/src/lib.rs | 80 +++- crates/github/src/changes.rs | 17 - crates/github/src/lib.rs | 242 +++++++++--- crates/github/src/webhook.rs | 37 +- crates/web/Cargo.toml | 4 +- crates/web/src/cron.rs | 43 ++- crates/web/src/handlers/common.rs | 49 ++- crates/web/src/handlers/manage.rs | 566 +++++++++++++++++++++++++++++ crates/web/src/handlers/mod.rs | 9 +- crates/web/src/handlers/project.rs | 7 +- crates/web/src/handlers/report.rs | 255 +++++++++---- crates/web/src/main.rs | 64 +++- css/main.scss | 44 ++- js/history.ts | 130 +++++++ js/manage.ts | 12 + 21 files changed, 1532 insertions(+), 227 deletions(-) create mode 100644 crates/web/src/handlers/manage.rs create mode 100644 js/history.ts create mode 100644 js/manage.ts diff --git a/Cargo.lock b/Cargo.lock index 5d5ff90..b42cb23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1025,6 +1025,7 @@ dependencies = [ "axum", "base64 0.22.1", "decomp-dev-core", + "maud", "octocrab", "rand 0.9.1", "serde", @@ -1250,6 +1251,15 @@ dependencies = [ "serde", ] +[[package]] +name = "english-to-cron" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a13a7d5e0ab3872c3ee478366eae624d89ab953d30276b0eee08169774ceb73" +dependencies = [ + "regex", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -5014,6 +5024,7 @@ checksum = "6a5597b569b4712cf78aa0c9ae29742461b7bda1e49c2a5fdad1d79bf022f8f0" dependencies = [ "chrono", "croner", + "english-to-cron", "num-derive", "num-traits", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 64e30fc..d7deaad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ axum = { version = "0.8", features = ["macros"] } futures-util = "0.3.31" hex = "0.4" image = "0.25" +maud = { version = "0.27", features = ["axum"] } mime = "0.3" objdiff-core = { version = "2.5", features = ["bindings"] } #objdiff-core = { path = "../objdiff/objdiff-core", features = ["bindings"] } diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index 692d700..e478738 100644 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -9,6 +9,7 @@ anyhow.workspace = true axum.workspace = true base64 = "0.22" decomp-dev-core = { path = "../core" } +maud.workspace = true octocrab.workspace = true rand = "0.9" serde.workspace = true @@ -16,4 +17,4 @@ serde_json.workspace = true time.workspace = true tower-sessions.workspace = true tracing.workspace = true -url.workspace = true \ No newline at end of file +url.workspace = true diff --git a/crates/auth/src/lib.rs b/crates/auth/src/lib.rs index 29f98e8..bbc20de 100644 --- a/crates/auth/src/lib.rs +++ b/crates/auth/src/lib.rs @@ -1,10 +1,12 @@ -use anyhow::{Context, anyhow, bail}; +use anyhow::{Context, Result, anyhow, bail}; use axum::{ - extract::{FromRef, FromRequestParts, OptionalFromRequestParts, Query, State}, - http::{StatusCode, header::ACCEPT, request::Parts}, + Extension, + extract::{FromRef, FromRequestParts, OptionalFromRequestParts, OriginalUri, Query, State}, + http::{Method, StatusCode, header::ACCEPT, request::Parts}, response::{IntoResponse, Redirect, Response}, }; use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use maud::{html, DOCTYPE}; use decomp_dev_core::{AppError, config::GitHubConfig}; use octocrab::{ Octocrab, @@ -13,9 +15,11 @@ use octocrab::{ use rand::{TryRngCore, rngs::OsRng}; use time::{Duration, UtcDateTime}; use tower_sessions::Session; +use url::form_urlencoded; const GITHUB_OAUTH_STATE: &str = "github_oauth_state"; const CURRENT_USER: &str = "current_user"; +const RETURN_TO: &str = "return_to"; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct StoredOAuth { @@ -50,6 +54,13 @@ pub struct CurrentUser { } impl CurrentUser { + pub fn client(&self) -> Result { + Octocrab::builder() + .oauth(self.oauth.clone().into()) + .build() + .context("Failed to create GitHub client") + } + pub fn permissions_for_repo(&self, id: u64) -> Permissions { self.repos .iter() @@ -82,8 +93,14 @@ impl From for CurrentUserRepo { } } +#[derive(serde::Deserialize)] +pub struct LoginQuery { + pub return_to: Option, +} + pub async fn login( session: Session, + Query(LoginQuery { return_to }): Query, State(config): State, current_user: Option, ) -> Result { @@ -98,18 +115,40 @@ pub async fn login( OsRng.try_fill_bytes(&mut bytes)?; let nonce = URL_SAFE_NO_PAD.encode(bytes); session.insert(GITHUB_OAUTH_STATE, nonce.clone()).await?; + if let Some(return_to) = return_to { + if return_to.starts_with('/') { + session.insert(RETURN_TO, return_to).await?; + } + } let mut redirect_url = url::Url::parse("https://github.com/login/oauth/authorize")?; let mut query = redirect_url.query_pairs_mut(); query.append_pair("client_id", &config.client_id); query.append_pair("redirect_uri", &config.redirect_uri); query.append_pair("state", &nonce); drop(query); - Ok(Redirect::to(redirect_url.as_str()).into_response()) + Ok(html! { + (DOCTYPE) + html { + head { + meta charset="utf-8"; + title { "Logging in... • decomp.dev" } + meta http-equiv="refresh" content=(format!("0;URL={redirect_url}")); + meta name="viewport" content="width=device-width, initial-scale=1.0"; + meta name="color-scheme" content="dark light"; + meta name="darkreader-lock"; + link rel="stylesheet" href="/css/main.min.css?3"; + } + body { + .loading-container { + div aria-busy="true" { "Logging in..." } + } + } + } + }.into_response()) } pub async fn logout(session: Session) -> Result { - session.remove_value(CURRENT_USER).await?; - session.remove_value(GITHUB_OAUTH_STATE).await?; + session.flush().await?; Ok(Redirect::to("/").into_response()) } @@ -175,8 +214,7 @@ pub async fn oauth( Query(OAuthQuery { code, state: oauth_state }): Query, State(config): State, ) -> Result { - let existing_state = session.get::(GITHUB_OAUTH_STATE).await?; - let Some(existing_state) = existing_state else { + let Some(existing_state) = session.remove::(GITHUB_OAUTH_STATE).await? else { tracing::warn!("No state found in session"); return Ok((StatusCode::BAD_REQUEST, "No state found").into_response()); }; @@ -184,12 +222,15 @@ pub async fn oauth( tracing::warn!("State mismatch: expected {}, got {}", existing_state, oauth_state); return Ok((StatusCode::BAD_REQUEST, "State mismatch").into_response()); } - session.remove_value(GITHUB_OAUTH_STATE).await?; let current_user = fetch_access_token(&config, &code).await?; session.insert(CURRENT_USER, current_user).await?; - Ok(Redirect::to("/").into_response()) + if let Some(return_to) = session.remove::(RETURN_TO).await? { + Ok(Redirect::to(&return_to).into_response()) + } else { + Ok(Redirect::to("/").into_response()) + } } fn oauth_client() -> Octocrab { @@ -225,6 +266,7 @@ async fn fetch_access_token(config: &GitHubConfig, code: &str) -> Result, S: Send + Sync, { - type Rejection = (StatusCode, &'static str); + type Rejection = Response; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - >::from_request_parts(parts, state) - .await? - .ok_or((StatusCode::UNAUTHORIZED, "Unauthorized")) + match >::from_request_parts(parts, state).await { + Ok(Some(user)) => Ok(user), + Ok(None) => { + let method = + Method::from_request_parts(parts, state).await.unwrap_or(Method::OPTIONS); + if method != Method::GET { + return Err((StatusCode::UNAUTHORIZED, "Unauthorized").into_response()); + } + let path_and_query = + as FromRequestParts>::from_request_parts( + parts, state, + ) + .await + .ok() + .and_then(|uri| uri.path_and_query().cloned()) + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "Unauthorized").into_response())?; + let mut redirect_uri = "/login?return_to=".to_string(); + redirect_uri + .extend(form_urlencoded::byte_serialize(path_and_query.as_str().as_bytes())); + Err(Redirect::to(&redirect_uri).into_response()) + } + Err(e) => Err(e.into_response()), + } } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 42b980b..1fe0856 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -20,7 +20,7 @@ impl IntoResponse for AppError { fn into_response(self) -> Response { match self { Self::Status(status) if status == StatusCode::NOT_FOUND => { - (status, "Not found!!").into_response() + (status, "Not found").into_response() } Self::Status(status) => status.into_response(), Self::Internal(err) => { diff --git a/crates/core/src/models.rs b/crates/core/src/models.rs index 2a399ff..82fbf69 100644 --- a/crates/core/src/models.rs +++ b/crates/core/src/models.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, sync::Arc}; +use std::{borrow::Cow, str::FromStr, sync::Arc}; use objdiff_core::bindings::report::{Measures, Report, ReportCategory, ReportUnit}; use serde::Serialize; @@ -18,6 +18,23 @@ pub struct Project { pub enable_pr_comments: bool, } +impl Default for Project { + fn default() -> Self { + Self { + id: 0, + owner: String::new(), + repo: String::new(), + name: None, + short_name: None, + default_category: None, + default_version: None, + platform: None, + workflow_id: None, + enable_pr_comments: true, + } + } +} + impl Project { pub fn name(&self) -> Cow { if let Some(name) = self.name.as_ref() { @@ -129,3 +146,77 @@ pub struct FrogressMapping { pub project_category_name: String, pub project_measure: String, } + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum Platform { + GBA, + GBC, + N64, + DS, + PS, + PS2, + Switch, + GC, + Wii, +} + +pub const ALL_PLATFORMS: &[Platform] = &[ + Platform::GBA, + Platform::GBC, + Platform::N64, + Platform::DS, + Platform::PS, + Platform::PS2, + Platform::Switch, + Platform::GC, + Platform::Wii, +]; + +impl Platform { + pub fn to_str(self) -> &'static str { + match self { + Self::GBA => "gba", + Self::GBC => "gbc", + Self::N64 => "n64", + Self::DS => "nds", + Self::PS => "ps", + Self::PS2 => "ps2", + Self::Switch => "switch", + Self::GC => "gc", + Self::Wii => "wii", + } + } + + pub fn name(self) -> &'static str { + match self { + Platform::GBA => "Game Boy Advance", + Platform::GBC => "Game Boy Color", + Platform::N64 => "Nintendo 64", + Platform::DS => "Nintendo DS", + Platform::PS => "PlayStation", + Platform::PS2 => "PlayStation 2", + Platform::Switch => "Nintendo Switch", + Platform::GC => "GameCube", + Platform::Wii => "Wii", + } + } +} + +impl FromStr for Platform { + type Err = (); + + fn from_str(s: &str) -> Result { + match s { + "gba" => Ok(Self::GBA), + "gbc" => Ok(Self::GBC), + "n64" => Ok(Self::N64), + "nds" => Ok(Self::DS), + "ps" => Ok(Self::PS), + "ps2" => Ok(Self::PS2), + "switch" => Ok(Self::Switch), + "gc" => Ok(Self::GC), + "wii" => Ok(Self::Wii), + _ => Err(()), + } + } +} diff --git a/crates/db/src/lib.rs b/crates/db/src/lib.rs index 03d00d6..9d24a9b 100644 --- a/crates/db/src/lib.rs +++ b/crates/db/src/lib.rs @@ -69,9 +69,9 @@ impl Database { }) .build(); let db = Self { pool, report_cache, report_unit_cache }; - db.fixup_report_units().await?; - db.migrate_reports().await?; - // db.cleanup_report_units().await?; + db.fixup_report_units().await.context("Fixing report units")?; + db.migrate_reports().await.context("Migrating reports")?; + // db.cleanup_report_units().await.context("Running report cleanup")?; Ok(db) } @@ -765,13 +765,24 @@ impl Database { pub async fn cleanup_report_units(&self) -> Result<()> { let mut conn = self.pool.acquire().await?; + conn.execute("PRAGMA foreign_keys = OFF").await?; + let mut tx = conn.begin().await?; + let deleted_reports = sqlx::query!( + r#" + DELETE FROM reports + WHERE project_id NOT IN (SELECT id FROM projects) + "#, + ) + .execute(&mut *tx) + .await? + .rows_affected(); let deleted_report_report_units = sqlx::query!( r#" DELETE FROM report_report_units WHERE report_id NOT IN (SELECT id FROM reports) "#, ) - .execute(&mut *conn) + .execute(&mut *tx) .await? .rows_affected(); let deleted_report_units = sqlx::query!( @@ -780,12 +791,15 @@ impl Database { WHERE id NOT IN (SELECT report_unit_id FROM report_report_units) "#, ) - .execute(&mut *conn) + .execute(&mut *tx) .await? .rows_affected(); - if deleted_report_units > 0 || deleted_report_report_units > 0 { + tx.commit().await?; + conn.execute("PRAGMA foreign_keys = ON").await?; + if deleted_reports > 0 || deleted_report_units > 0 || deleted_report_report_units > 0 { tracing::info!( - "Deleted {} orphaned report units and {} orphaned mappings", + "Deleted {} orphaned reports, {} orphaned report units and {} orphaned mappings", + deleted_reports, deleted_report_units, deleted_report_report_units, ); @@ -905,7 +919,7 @@ impl Database { sqlx::query!( r#" UPDATE projects - SET workflow_id = ? + SET workflow_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "#, workflow_id, @@ -927,7 +941,7 @@ impl Database { sqlx::query!( r#" UPDATE projects - SET owner = ?, repo = ? + SET owner = ?, repo = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "#, owner, @@ -939,23 +953,49 @@ impl Database { Ok(()) } - pub async fn update_project_settings( - &self, - project_id: u64, - enable_pr_comments: bool, - default_version: Option, - ) -> Result<()> { + pub async fn update_project(&self, project: &Project) -> Result<()> { let mut conn = self.pool.acquire().await?; - let project_id_db = project_id as i64; + let project_id = project.id as i64; sqlx::query!( r#" UPDATE projects - SET enable_pr_comments = ?, default_version = ? + SET owner = ?, repo = ?, name = ?, short_name = ?, default_category = ?, default_version = ?, platform = ?, workflow_id = ?, enable_pr_comments = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? "#, - enable_pr_comments, - default_version, - project_id_db, + project.owner, + project.repo, + project.name, + project.short_name, + project.default_category, + project.default_version, + project.platform, + project.workflow_id, + project.enable_pr_comments, + project_id, + ) + .execute(&mut *conn) + .await?; + Ok(()) + } + + pub async fn create_project(&self, project: &Project) -> Result<()> { + let mut conn = self.pool.acquire().await?; + let project_id = project.id as i64; + sqlx::query!( + r#" + INSERT INTO projects (id, owner, repo, name, short_name, default_category, default_version, platform, workflow_id, enable_pr_comments, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + "#, + project_id, + project.owner, + project.repo, + project.name, + project.short_name, + project.default_category, + project.default_version, + project.platform, + project.workflow_id, + project.enable_pr_comments, ) .execute(&mut *conn) .await?; diff --git a/crates/github/src/changes.rs b/crates/github/src/changes.rs index 3b48699..77a3889 100644 --- a/crates/github/src/changes.rs +++ b/crates/github/src/changes.rs @@ -264,20 +264,3 @@ pub fn generate_comment( } comment } - -#[allow(unused)] -fn platform_name(platform: &str) -> &str { - match platform { - "gc" => "GameCube", - "wii" => "Wii", - "n64" => "Nintendo 64", - "switch" => "Nintendo Switch", - "3ds" => "Nintendo 3DS", - "nds" => "Nintendo DS", - "gba" => "Game Boy Advance", - "gbc" => "Game Boy Color", - "ps" => "PlayStation", - "ps2" => "PlayStation 2", - _ => platform, - } -} diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 39fa0a4..7ddbde0 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -2,14 +2,14 @@ pub mod changes; pub mod webhook; use std::{ - collections::{HashMap, hash_map::Entry}, + collections::{HashMap, HashSet, hash_map::Entry}, ffi::OsStr, io::{Cursor, Read}, pin::pin, sync::{Arc, OnceLock}, }; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, anyhow, bail}; use decomp_dev_core::{ config::GitHubConfig, models::{Commit, Project}, @@ -20,7 +20,10 @@ use http::StatusCode; use objdiff_core::bindings::report::Report; use octocrab::{ GitHubError, Octocrab, - models::{ArtifactId, InstallationId, RunId, repos::RepoCommitPage, workflows::HeadCommit}, + models::{ + ArtifactId, InstallationId, InstallationRepositories, Repository, RunId, + repos::RepoCommitPage, workflows::HeadCommit, + }, params::actions::ArchiveFormat, }; use regex::Regex; @@ -36,35 +39,41 @@ pub struct GitHub { pub installations: Option>>, } +pub struct CachedInstallation { + pub client: Octocrab, + pub repositories: Vec, +} + pub struct Installations { pub app_client: Octocrab, - pub owner_to_installation: HashMap, - pub clients: HashMap, + pub clients: HashMap, + pub repo_to_installation: HashMap, } impl Installations { - pub fn client_for_installation( + pub async fn client_for_installation( &mut self, installation_id: InstallationId, - owner: Option<&str>, ) -> Result { match self.clients.entry(installation_id) { - Entry::Occupied(entry) => Ok(entry.get().clone()), + Entry::Occupied(entry) => Ok(entry.get().client.clone()), Entry::Vacant(entry) => { // Create a new client for the installation let client = self.app_client.installation(installation_id)?; - entry.insert(client.clone()); - if let Some(owner) = owner { - self.owner_to_installation.insert(owner.to_string(), installation_id); - } + let repositories = list_installation_repositories(&client) + .await + .context("Failed to fetch installation repositories")?; + self.repo_to_installation + .extend(repositories.iter().map(|r| (r.id.into_inner(), installation_id))); + entry.insert(CachedInstallation { client: client.clone(), repositories }); Ok(client) } } } - pub fn client_for_owner(&mut self, owner: &str) -> Result> { - if let Some(installation_id) = self.owner_to_installation.get(owner) { - return self.client_for_installation(*installation_id, None).map(Some); + pub async fn client_for_repo(&mut self, repo_id: u64) -> Result> { + if let Some(installation_id) = self.repo_to_installation.get(&repo_id) { + return self.client_for_installation(*installation_id).await.map(Some); } Ok(None) } @@ -77,24 +86,63 @@ pub struct GetCommit { sha: String, } +#[derive(serde::Serialize)] +struct PageParams { + #[serde(skip_serializing_if = "Option::is_none")] + per_page: Option, + #[serde(skip_serializing_if = "Option::is_none")] + page: Option, +} + +async fn list_installation_repositories(app_client: &Octocrab) -> Result> { + let mut page = 1; + let mut response: InstallationRepositories = app_client + .get( + "/installation/repositories", + Some(&PageParams { per_page: Some(100), page: Some(page) }), + ) + .await?; + let mut repositories = response.repositories; + while repositories.len() < response.total_count as usize { + page += 1; + response = app_client + .get( + &format!("/installation/repositories?page={}", page), + Some(&PageParams { per_page: Some(100), page: Some(page) }), + ) + .await?; + if response.repositories.is_empty() { + break; + } + repositories.extend(response.repositories); + } + Ok(repositories) +} + async fn list_installations(app_client: Octocrab) -> Result { - let mut owner_to_installation = HashMap::new(); let mut clients = HashMap::new(); + let mut repo_to_installation = HashMap::new(); { let mut stream = pin!(app_client.apps().installations().send().await?.into_stream(&app_client)); while let Some(installation) = stream.try_next().await? { - let owner = installation.account.login; - if owner_to_installation.contains_key(&owner) { - tracing::warn!("Duplicate installation for {}", owner); - continue; - } let client = app_client.installation(installation.id)?; - owner_to_installation.insert(owner.clone(), installation.id); - clients.insert(installation.id, client); + let repositories = list_installation_repositories(&client).await?; + for repository in &repositories { + if repo_to_installation + .insert(repository.id.into_inner(), installation.id) + .is_some() + { + tracing::warn!( + "Duplicate installation for repository {}", + repository.full_name.as_deref().unwrap_or_default() + ); + } + } + clients.insert(installation.id, CachedInstallation { client, repositories }); } } - Ok(Installations { app_client, owner_to_installation, clients }) + Ok(Installations { app_client, clients, repo_to_installation }) } impl GitHub { @@ -118,8 +166,25 @@ impl GitHub { let result = list_installations(app_client).await.context("Failed to fetch installations")?; tracing::info!("Found {} installations", result.clients.len()); - for (owner, installation_id) in &result.owner_to_installation { - tracing::info!(" - {}: {}", owner, installation_id); + for (installation_id, cached) in &result.clients { + let owners = cached + .repositories + .iter() + .map(|r| r.owner.as_ref().map(|o| o.login.as_str()).unwrap_or_default()) + .collect::>(); + let mut owner = String::new(); + for o in owners { + if !owner.is_empty() { + owner.push_str(", "); + } + owner.push_str(o); + } + tracing::info!( + " - {}: {} ({} repositories)", + owner, + installation_id, + cached.repositories.len() + ); } Some(Arc::new(Mutex::new(result))) } else { @@ -145,10 +210,10 @@ impl GitHub { } } - pub async fn client_for(&self, owner: &str) -> Result { + pub async fn client_for(&self, repo_id: u64) -> Result { if let Some(installations) = &self.installations { let mut installations = installations.lock().await; - if let Some(client) = installations.client_for_owner(owner)? { + if let Some(client) = installations.client_for_repo(repo_id).await? { return Ok(client); } } @@ -156,14 +221,20 @@ impl GitHub { } } -pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) -> Result<()> { +pub async fn refresh_project( + github: &GitHub, + db: &Database, + repo_id: u64, + client_override: Option<&Octocrab>, + full_refresh: bool, +) -> Result { let mut project_info = db .get_project_info_by_id(repo_id, None) .await .context("Failed to fetch project info")? .with_context(|| format!("Failed to fetch project info for ID {}", repo_id))?; - let repo = github - .client + let repo = client_override + .unwrap_or(&github.client) .repos_by_id(project_info.project.id) .get() .await @@ -189,7 +260,10 @@ pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) let project = &project_info.project; tracing::debug!("Refreshing project {}/{}", project.owner, project.repo); - let client = github.client_for(&project.owner).await?; + let client = match client_override { + Some(client) => client.clone(), + None => github.client_for(repo_id).await?, + }; let workflow_ids = if let Some(workflow_id) = &project.workflow_id { vec![workflow_id.clone()] @@ -204,7 +278,7 @@ pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) }; if workflow_ids.is_empty() { tracing::warn!("No workflows found for {}/{}", project.owner, project.repo); - return Ok(()); + return Ok(0); } for workflow_id in workflow_ids { let workflow_id = @@ -239,16 +313,14 @@ pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) } }; for run in items { - if let Some(commit) = project_info.commit.as_ref() { - if run.head_sha == commit.sha { - break 'outer; + if !full_refresh { + if let Some(commit) = project_info.commit.as_ref() { + if run.head_sha == commit.sha { + break 'outer; + } } } - let run_id = run.id; runs.push(run); - if run_id == RunId(stop_run_id) { - break 'outer; - } } page += 1; } @@ -291,7 +363,7 @@ pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) TaskResult { run_id, commit, result } }); } - let mut found_artifacts = false; + let mut imported_artifacts = 0; while let Some(join_result) = set.join_next().await { match join_result { Ok(TaskResult { @@ -316,7 +388,7 @@ pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) commit.sha, duration.as_millis() ); - found_artifacts = true; + imported_artifacts += 1; } } Ok(TaskResult { run_id, commit, result: Err(e) }) => { @@ -333,15 +405,15 @@ pub async fn run(github: &GitHub, db: &Database, repo_id: u64, stop_run_id: u64) } } - if found_artifacts { + if imported_artifacts > 0 { if project.workflow_id.is_none() { db.update_project_workflow_id(project.id, workflow_id).await?; } - break; + return Ok(imported_artifacts); } } - Ok(()) + Ok(0) } pub struct ProcessWorkflowRunResult { @@ -388,6 +460,9 @@ pub async fn process_workflow_run( result: DownloadArtifactResult, } for artifact in &artifacts { + if artifact.expired { + continue; + } let artifact_name = artifact.name.clone(); let version = if let Some(version) = regex.captures(&artifact_name).and_then(|c| c.name("version")) { @@ -423,19 +498,29 @@ pub async fn process_workflow_run( match join_result { Ok(TaskResult { artifact_name: name, result: Ok(reports) }) => { if reports.is_empty() { - tracing::warn!("No report found in artifact {}", name); + tracing::warn!("No report found in workflow run {} artifact {}", run_id, name); } else { for (version, report) in reports { - tracing::info!("Processed artifact {} ({})", name, version); + tracing::info!( + "Processed workflow run {} artifact {} ({})", + run_id, + name, + version + ); result.artifacts.push(ProcessArtifactResult { version, report }); } } } Ok(TaskResult { artifact_name: name, result: Err(e) }) => { - tracing::error!("Failed to process artifact {}: {:?}", name, e); + tracing::error!( + "Failed to process workflow run {} artifact {}: {:?}", + run_id, + name, + e + ); } Err(e) => { - tracing::error!("Failed to process artifact: {:?}", e); + tracing::error!("Failed to process workflow run {} artifact: {:?}", run_id, e); } } } @@ -484,8 +569,63 @@ async fn download_artifact( pub fn commit_from_head_commit(commit: &HeadCommit) -> Commit { Commit { sha: commit.id.clone(), - timestamp: UtcDateTime::from_unix_timestamp(commit.timestamp.to_utc().timestamp_millis()) - .unwrap_or_else(|_| UtcDateTime::now()), + timestamp: UtcDateTime::from_unix_timestamp( + commit.timestamp.to_utc().timestamp_millis() / 1000, + ) + .unwrap_or(UtcDateTime::UNIX_EPOCH), message: (!commit.message.is_empty()).then(|| commit.message.clone()), } } + +pub async fn check_for_reports( + client: &Octocrab, + project: &Project, + repo: &Repository, +) -> Result { + let workflow_ids = if let Some(workflow_id) = &project.workflow_id { + vec![workflow_id.clone()] + } else { + let workflows = client + .workflows(&project.owner, &project.repo) + .list() + .send() + .await + .context("Failed to fetch workflows")?; + workflows.items.into_iter().map(|w| w.path).collect() + }; + if workflow_ids.is_empty() { + bail!("No workflows found in repository."); + } + let branch = repo.default_branch.as_deref().unwrap_or("main"); + for workflow_id in workflow_ids { + let workflow_id = + workflow_id.strip_prefix(".github/workflows/").unwrap_or(workflow_id.as_str()); + let result = client + .workflows(&project.owner, &project.repo) + .list_runs(workflow_id) + .branch(branch) + .event("push") + .status("completed") + .exclude_pull_requests(true) + .send() + .await; + let items = match result { + Ok(result) if result.items.is_empty() => continue, + Ok(result) => result.items, + Err(octocrab::Error::GitHub { source, .. }) + if matches!(*source, GitHubError { status_code: StatusCode::NOT_FOUND, .. }) => + { + continue; + } + Err(e) => { + return Err(e).context("Failed to fetch workflow runs"); + } + }; + let run = items.first().unwrap(); + let result = process_workflow_run(&client, &project, run.id).await?; + if !result.artifacts.is_empty() { + return Ok(workflow_id.to_string()); + } + } + Err(anyhow!("No workflow runs containing reports found.")) +} diff --git a/crates/github/src/webhook.rs b/crates/github/src/webhook.rs index 7686779..d56a761 100644 --- a/crates/github/src/webhook.rs +++ b/crates/github/src/webhook.rs @@ -81,7 +81,7 @@ pub async fn webhook(GitHubEvent { event, state }: GitHubEvent) -> Result Result { // Remove the installation client let mut installations = installations.lock().await; - if let Some(owner) = &owner { - installations.owner_to_installation.remove(owner); - } else { - tracing::warn!("Received installation deleted event with no owner"); - } if let Some(installation_id) = installation_id { + installations.repo_to_installation.retain(|_, v| *v != installation_id); installations.clients.remove(&installation_id); } else { tracing::warn!( @@ -157,6 +153,35 @@ pub async fn webhook(GitHubEvent { event, state }: GitHubEvent) -> Result {} } } + WebhookEventPayload::InstallationRepositories(inner) => { + tracing::info!( + "Installation {:?} for {} repositories changed", + inner.action, + owner.as_deref().unwrap_or("[unknown]") + ); + let Some(installation_id) = installation_id else { + tracing::warn!("Received installation_repositories event with no installation ID"); + return Ok((StatusCode::OK, "No installation ID").into_response()); + }; + let mut installations = installations.lock().await; + for repository in &inner.repositories_added { + tracing::info!("Added repository {}", repository.full_name); + installations + .repo_to_installation + .insert(repository.id.into_inner(), installation_id); + } + if !inner.repositories_removed.is_empty() { + for repository in &inner.repositories_removed { + tracing::info!("Removed repository {}", repository.full_name); + } + installations.repo_to_installation.retain(|repo, id| { + if *id != installation_id { + return true; + } + inner.repositories_removed.iter().any(|r| r.id.into_inner() == *repo) + }); + } + } _ => {} } Ok((StatusCode::OK, "Event processed").into_response()) diff --git a/crates/web/Cargo.toml b/crates/web/Cargo.toml index df45758..86ed0ca 100644 --- a/crates/web/Cargo.toml +++ b/crates/web/Cargo.toml @@ -14,7 +14,7 @@ decomp-dev-github = { path = "../github" } decomp-dev-images = { path = "../images" } decomp-dev-scripts = { path = "../scripts" } itertools = "0.14" -maud = { version = "0.27", features = ["axum"] } +maud.workspace = true mime.workspace = true objdiff-core.workspace = true reqwest.workspace = true @@ -23,7 +23,7 @@ serde_json.workspace = true serde_yaml = "0.9" time.workspace = true timeago = { version = "0.4.2", default-features = false } -tokio-cron-scheduler = "0.13" +tokio-cron-scheduler = { version = "0.13", features = ["english"] } tokio.workspace = true tower = { version = "0.5", features = ["full"] } tower-http = { version = "0.6", features = ["full"] } diff --git a/crates/web/src/cron.rs b/crates/web/src/cron.rs index 0c84e43..c67aa83 100644 --- a/crates/web/src/cron.rs +++ b/crates/web/src/cron.rs @@ -15,17 +15,28 @@ pub async fn create( { let state = state.clone(); sched - .add(Job::new_async("0 0/5 * * * *", move |_uuid, _l| { + .add(Job::new_async("every 5 minutes", move |_uuid, _l| { let state = state.clone(); Box::pin(async move { - refresh_projects(&state).await.expect("Failed to refresh projects"); + refresh_projects(&state, false).await.expect("Failed to refresh projects"); + }) + })?) + .await?; + } + { + let state = state.clone(); + sched + .add(Job::new_async("every 12 hours", move |_uuid, _l| { + let state = state.clone(); + Box::pin(async move { + refresh_projects(&state, true).await.expect("Failed to refresh projects"); }) })?) .await?; } { sched - .add(Job::new_async("0 0 0/24 * * *", move |_uuid, _l| { + .add(Job::new_async("at midnight", move |_uuid, _l| { let state = state.clone(); Box::pin(async move { state.db.cleanup_report_units().await.expect("Failed to clean up report units"); @@ -35,7 +46,7 @@ pub async fn create( } { sched - .add(Job::new_async("0 0/1 * * * *", move |_uuid, _l| { + .add(Job::new_async("every 1 minute", move |_uuid, _l| { let session_store = session_store.clone(); Box::pin(async move { session_store @@ -50,17 +61,25 @@ pub async fn create( Ok(sched) } -pub async fn refresh_projects(state: &AppState) -> Result<()> { +pub async fn refresh_projects(state: &AppState, full_refresh: bool) -> Result<()> { for project_info in state.db.get_projects().await? { - // Skip projects with active app installations - if let Some(installations) = &state.github.installations { - let installations = installations.lock().await; - if installations.owner_to_installation.contains_key(&project_info.project.owner) { - continue; + if !full_refresh { + // Skip projects with active app installations + if let Some(installations) = &state.github.installations { + let installations = installations.lock().await; + if installations.repo_to_installation.contains_key(&project_info.project.id) { + continue; + } } } - if let Err(e) = - decomp_dev_github::run(&state.github, &state.db, project_info.project.id, 0).await + if let Err(e) = decomp_dev_github::refresh_project( + &state.github, + &state.db, + project_info.project.id, + None, + full_refresh, + ) + .await { log::error!( "Failed to refresh {}/{}: {:?}", diff --git a/crates/web/src/handlers/common.rs b/crates/web/src/handlers/common.rs index 01ea8aa..dedbd7b 100644 --- a/crates/web/src/handlers/common.rs +++ b/crates/web/src/handlers/common.rs @@ -1,6 +1,11 @@ -use std::time::{Duration, Instant}; +use std::{ + sync::LazyLock, + time::{Duration, Instant}, +}; +use axum::http::StatusCode; use decomp_dev_auth::CurrentUser; +use decomp_dev_core::AppError; use maud::{Markup, PreEscaped, html}; use objdiff_core::bindings::report::Measures; use time::{UtcDateTime, macros::format_description}; @@ -23,18 +28,9 @@ pub fn header() -> Markup { meta name="viewport" content="width=device-width, initial-scale=1.0"; meta name="color-scheme" content="dark light"; meta name="darkreader-lock"; - link rel="stylesheet" href="/css/main.min.css"; - script src="/js/main.min.js" defer; - script { - r#"let theme = null; -try { - theme = localStorage.getItem('theme'); -} catch (_) { -} -if (theme) { - document.documentElement.setAttribute('data-theme', theme); -}"# - } + link rel="stylesheet" href="/css/main.min.css?3"; + script src="/js/main.min.js" defer {} + script { (PreEscaped(r#"let t;try{t=localStorage.getItem("theme")}catch(_){}if(t)document.documentElement.setAttribute("data-theme",t);"#)) } } } @@ -199,3 +195,30 @@ pub fn data_progress_sections(measures: &Measures) -> Markup { } } } + +pub async fn get_robots() -> Result { + static ROBOTS_CACHE: LazyLock>> = + LazyLock::new(|| std::sync::RwLock::new(None)); + { + let cache = + ROBOTS_CACHE.read().map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?; + if let Some(robots) = &*cache { + return Ok(robots.clone()); + } + } + let response = reqwest::get( + "https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/refs/heads/main/robots.txt", + ) + .await?; + if response.status() != StatusCode::OK { + return Err(AppError::Status(StatusCode::BAD_GATEWAY)); + } + let text = response.text().await?; + { + let mut cache = ROBOTS_CACHE + .write() + .map_err(|_| AppError::Status(StatusCode::INTERNAL_SERVER_ERROR))?; + *cache = Some(text.clone()); + } + Ok(text) +} diff --git a/crates/web/src/handlers/manage.rs b/crates/web/src/handlers/manage.rs new file mode 100644 index 0000000..1bc0599 --- /dev/null +++ b/crates/web/src/handlers/manage.rs @@ -0,0 +1,566 @@ +use std::time::Instant; + +use anyhow::Result; +use axum::{ + Form, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Redirect, Response}, +}; +use decomp_dev_auth::CurrentUser; +use decomp_dev_core::{ + AppError, + models::{ALL_PLATFORMS, Project, ProjectInfo}, +}; +use decomp_dev_github::{check_for_reports, refresh_project}; +use itertools::Itertools; +use maud::{DOCTYPE, Markup, html}; +use serde::Deserialize; + +use crate::{ + AppState, + handlers::common::{footer, header, nav_links}, +}; + +pub async fn manage( + State(state): State, + current_user: CurrentUser, +) -> Result { + let start = Instant::now(); + + let projects = state + .db + .get_projects() + .await? + .into_iter() + .filter(|p| current_user.permissions_for_repo(p.project.id).admin) + .sorted_by(|a, b| a.project.name().cmp(&b.project.name())) + .collect::>(); + + Ok(html! { + (DOCTYPE) + html { + head lang="en" { + meta charset="utf-8"; + title { "Manage • decomp.dev" } + (header()) + } + body { + header { + nav { + ul { + li { + a href="https://decomp.dev" { strong { "decomp.dev" } } + } + li { + a href="/manage" { "Manage" } + } + } + (nav_links()) + } + } + main { + h3 { "Projects" } + p { a href="/manage/new" role="button" { "Add New" } } + @if projects.is_empty() { + article { "No projects found." } + } + @for project in projects { + (project_fragment(&project)) + } + } + } + (footer(start, Some(¤t_user))) + } + }) +} + +fn project_fragment(info: &ProjectInfo) -> Markup { + let project_path = format!("/manage/{}/{}", info.project.owner, info.project.repo); + html! { + article .project { + .project-header { + h3 .project-title { + a href=(project_path) { (info.project.name()) } + } + } + .project-info { + a href=(info.project.repo_url()) { (info.project.repo_url()) } + } + } + } +} + +pub async fn new( + State(state): State, + current_user: CurrentUser, +) -> Result { + render_new(&state, ¤t_user, None, None).await +} + +async fn render_new( + state: &AppState, + current_user: &CurrentUser, + message: Option<&str>, + prefill: Option<&Project>, +) -> Result { + let start = Instant::now(); + + let projects = state.db.get_projects().await?; + + let repos = current_user + .repos + .iter() + .filter(|r| r.permissions.admin) + .map(|r| { + ( + r.id.into_inner(), + format!("{}/{}", r.owner, r.repo), + projects.iter().any(|p| p.project.id == r.id.into_inner()), + ) + }) + .collect::>(); + + let current_name = prefill.as_ref().and_then(|p| p.name.as_deref()).unwrap_or(""); + let current_short_name = prefill.as_ref().and_then(|p| p.short_name.as_deref()).unwrap_or(""); + let current_platform = prefill.as_ref().and_then(|p| p.platform.as_deref()); + + let repo_options = html! { + @for (id, repo, exists) in repos { + @if exists { + option value=(id) disabled { (repo) } + } @else if prefill.is_some_and(|p| p.id == id) { + option value=(id) selected { (repo) } + } @else { + option value=(id) { (repo) } + } + } + }; + + Ok(html! { + (DOCTYPE) + html { + head lang="en" { + meta charset="utf-8"; + title { "New Project • decomp.dev" } + (header()) + script src="/js/manage.min.js" defer {} + } + body { + header { + nav { + ul { + li { + a href="https://decomp.dev" { strong { "decomp.dev" } } + } + li { + a href="/manage" { "Manage" } + } + li { + a href="/manage/new" { "New" } + } + } + (nav_links()) + } + } + main { + h3 { "Add New" } + form method="post" data-loading="Processing..." { + fieldset { + label { + "Repository" + @if let Some(message) = message { + select name="repo" aria-invalid="true" { (repo_options) } + small { (message) } + } @else { + select name="repo" { (repo_options) } + small { "Repository must be public. Admin permissions are required." } + } + } + label { + "Game name" + input name="name" required value=(current_name); + small { "Please use the full name of the game, e.g. \"The Legend of Zelda: Ocarina of Time\"." } + } + label { + "Short name " + small { "(optional)" } + input name="short_name" value=(current_short_name); + small { "If the game has a long prefix, e.g. \"The Legend of Zelda\", the short name will be \"Ocarina of Time\"." } + } + label { + "Platform" + select name="platform" required { (platform_options(current_platform)) } + small { "Platform not listed? Please open an issue on GitHub." } + } + } + button type="submit" { "Add" } + } + } + } + (footer(start, Some(current_user))) + } + }.into_response()) +} + +fn platform_options(current_platform: Option<&str>) -> Markup { + html! { + @if current_platform.is_none() { + option value="" disabled selected { "Select one" } + } + @for platform in ALL_PLATFORMS { + @let platform_str = platform.to_str(); + @if current_platform == Some(platform_str) { + option value=(platform_str) selected { (platform.name()) } + } @else { + option value=(platform_str) { (platform.name()) } + } + } + } +} + +#[derive(Deserialize)] +pub struct NewForm { + repo: u64, + name: String, + short_name: String, + platform: String, +} + +pub async fn new_save( + State(state): State, + current_user: CurrentUser, + Form(form): Form, +) -> Result { + if let Some(existing) = state.db.get_project_info_by_id(form.repo, None).await? { + return Ok(Redirect::to(&format!("/{}/{}", existing.project.owner, existing.project.repo)) + .into_response()); + } + let Some(platform) = ALL_PLATFORMS.iter().find(|p| p.to_str() == form.platform) else { + return Err(AppError::Status(StatusCode::BAD_REQUEST)); + }; + let client = current_user.client()?; + let repo = match client.repos_by_id(form.repo).get().await { + Ok(repo) => repo, + Err(e) => { + tracing::error!("Failed to fetch repository: {:?}", e); + return render_new( + &state, + ¤t_user, + Some("Failed to fetch repository information."), + None, + ) + .await; + } + }; + + let name = form.name.trim(); + let short_name = form.short_name.trim(); + let mut project = Project { + id: repo.id.into_inner(), + owner: repo.owner.as_ref().map(|o| o.login.clone()).unwrap_or_default(), + repo: repo.name.clone(), + name: (!name.is_empty()).then_some(name.to_string()), + short_name: (!short_name.is_empty()).then_some(short_name.to_string()), + platform: Some(platform.to_str().to_string()), + ..Default::default() + }; + if repo.permissions.as_ref().is_none_or(|p| !p.admin) { + return render_new( + &state, + ¤t_user, + Some("You do not have admin permissions on this repository."), + Some(&project), + ) + .await; + } + + let workflow_id = match check_for_reports(&client, &project, &repo).await { + Ok(workflow_id) => workflow_id, + Err(e) => { + let message = e.to_string(); + return render_new(&state, ¤t_user, Some(&message), Some(&project)).await; + } + }; + project.workflow_id = Some(workflow_id); + state.db.create_project(&project).await?; + refresh_project(&state.github, &state.db, project.id, Some(&client), true).await?; + Ok(Redirect::to(&format!("/{}/{}", project.owner, project.repo)).into_response()) +} + +pub async fn manage_project( + Path(params): Path, + State(state): State, + current_user: CurrentUser, +) -> Result { + let start = Instant::now(); + let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? + else { + return Err(AppError::Status(StatusCode::NOT_FOUND)); + }; + if !current_user.permissions_for_repo(project_info.project.id).admin { + return Err(AppError::Status(StatusCode::FORBIDDEN)); + } + + Ok(render_manage_project(start, &state, &project_info, ¤t_user, Message::None).await) +} + +enum Message { + None, + Info(String), + Error(String), +} + +fn render_message(message: &Message) -> Markup { + match message { + Message::None => Markup::default(), + Message::Info(msg) => html! { + article .info-card { (msg) } + }, + Message::Error(msg) => html! { + article .error-card { (msg) } + }, + } +} + +async fn render_manage_project( + start: Instant, + state: &AppState, + project_info: &ProjectInfo, + current_user: &CurrentUser, + message: Message, +) -> Markup { + let project_short_name = project_info.project.short_name(); + let project_manage_path = + format!("/manage/{}/{}", project_info.project.owner, project_info.project.repo); + let refresh_path = + format!("/manage/{}/{}/refresh", project_info.project.owner, project_info.project.repo); + let default_version = project_info.default_version(); + + let current_name = project_info.project.name.as_deref().unwrap_or(""); + let current_short_name = project_info.project.short_name.as_deref().unwrap_or(""); + let current_platform = project_info.project.platform.as_deref(); + let current_workflow_id = project_info.project.workflow_id.as_deref().unwrap_or(""); + + let installation_id = if let Some(installations) = &state.github.installations { + let installations = installations.lock().await; + installations.repo_to_installation.get(&project_info.project.id).cloned() + } else { + None + }; + + html! { + (DOCTYPE) + html { + head lang="en" { + meta charset="utf-8"; + title { (project_short_name) " • Manage" } + (header()) + script src="/js/manage.min.js" defer {} + } + body { + header { + nav { + ul { + li { + a href="https://decomp.dev" { strong { "decomp.dev" } } + } + li { + a href="/manage" { "Manage" } + } + li { + a href=(project_manage_path) { (project_short_name) } + } + } + (nav_links()) + } + } + main { + h3 { "Edit " (project_short_name) } + (render_message(&message)) + form method="post" data-loading="Saving..." { + fieldset { + label { + "Repository" + input type="text" readonly disabled value=(project_info.project.repo_url()); + } + label { + "Game name" + input name="name" required value=(current_name); + small { "Please use the full name of the game, e.g. \"The Legend of Zelda: Ocarina of Time\"." } + } + label { + "Short name " + small { "(optional)" } + input name="short_name" value=(current_short_name) placeholder=(current_name); + small { "If the game has a long prefix, e.g. \"The Legend of Zelda\", the short name will be \"Ocarina of Time\"." } + } + label { + "Platform" + select name="platform" { (platform_options(current_platform)) } + small { "Platform not listed? Please open an issue on GitHub." } + } + label { + "Default version" + select name="default_version" { + @for version in &project_info.report_versions { + @if default_version == Some(version.as_str()) { + option value=(version) selected { (version) } + } @else { + option value=(version) { (version) } + } + } + } + } + label { + "GitHub workflow ID" + input name="workflow_id" type="text" value=(current_workflow_id); + small { "The GitHub Actions workflow that contains report artifacts." } + } + label { + @if installation_id.is_some() { + @if project_info.project.enable_pr_comments { + input name="enable_pr_comments" type="checkbox" role="switch" checked; + } @else { + input name="enable_pr_comments" type="checkbox" role="switch"; + } + "Enable PR comments" + } @else { + @if project_info.project.enable_pr_comments { + input name="enable_pr_comments" type="checkbox" role="switch" disabled checked; + } @else { + input name="enable_pr_comments" type="checkbox" role="switch" disabled; + } + "Enable PR comments (requires GitHub App installation)" + } + } + } + button type="submit" { "Save" } + } + h4 { "Debug" } + @if let Some(installation_id) = installation_id { + p { + "GitHub App installation ID: " + kbd { (installation_id) } + } + } @else { + p { + "No GitHub App installation found. " + a href="https://github.com/apps/decomp-dev" target="_blank" { "Install the app" } + } + } + .grid { + form action=(refresh_path) method="post" data-loading="Refreshing..." { + button .outline .secondary type="submit" { "Force refresh" } + small { "Fetches any missing report artifacts." } + } + } + } + } + (footer(start, Some(current_user))) + } + } +} + +fn form_bool<'de, D>(deserializer: D) -> Result +where D: serde::Deserializer<'de> { + match <&str>::deserialize(deserializer)? { + "on" => Ok(true), + "off" => Ok(false), + other => Err(serde::de::Error::unknown_variant(other, &["on", "off"])), + } +} + +#[derive(Deserialize)] +pub struct ProjectForm { + pub name: String, + pub short_name: String, + pub platform: String, + pub default_version: Option, + pub workflow_id: String, + #[serde(default, deserialize_with = "form_bool")] + pub enable_pr_comments: bool, +} + +#[derive(Deserialize)] +pub struct ProjectParams { + owner: String, + repo: String, +} + +pub async fn manage_project_save( + Path(params): Path, + State(state): State, + current_user: CurrentUser, + Form(form): Form, +) -> Result { + let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? + else { + return Err(AppError::Status(StatusCode::NOT_FOUND)); + }; + if !current_user.permissions_for_repo(project_info.project.id).admin { + return Err(AppError::Status(StatusCode::FORBIDDEN)); + } + let installation_id = if let Some(installations) = &state.github.installations { + let installations = installations.lock().await; + installations.repo_to_installation.get(&project_info.project.id).cloned() + } else { + None + }; + let name = form.name.trim(); + let short_name = form.short_name.trim(); + let platform = form.platform.trim(); + let workflow_id = form.workflow_id.trim(); + let project = Project { + id: project_info.project.id, + owner: project_info.project.owner, + repo: project_info.project.repo, + name: (!name.is_empty()).then_some(name.to_string()), + short_name: (!short_name.is_empty()).then_some(short_name.to_string()), + default_category: project_info.project.default_category, + default_version: form.default_version, + platform: (!platform.is_empty()).then_some(platform.to_string()), + workflow_id: (!workflow_id.is_empty()).then_some(workflow_id.to_string()), + // If there's no installation ID, use the existing value + enable_pr_comments: if installation_id.is_some() { + form.enable_pr_comments + } else { + project_info.project.enable_pr_comments + }, + }; + state.db.update_project(&project).await?; + let redirect_url = format!("/{}/{}", params.owner, params.repo); + Ok(Redirect::to(&redirect_url).into_response()) +} + +pub async fn manage_project_refresh( + Path(params): Path, + State(state): State, + current_user: CurrentUser, +) -> Result { + let start = Instant::now(); + let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? + else { + return Err(AppError::Status(StatusCode::NOT_FOUND)); + }; + if !current_user.permissions_for_repo(project_info.project.id).admin { + return Err(AppError::Status(StatusCode::FORBIDDEN)); + } + let client = current_user.client()?; + let message = match refresh_project( + &state.github, + &state.db, + project_info.project.id, + Some(&client), + true, + ) + .await + { + Ok(inserted_reports) => Message::Info(format!("Fetched {} new reports", inserted_reports)), + Err(e) => { + tracing::error!("Failed to refresh project: {:?}", e); + Message::Error(format!("Failed to refresh project: {}", e)) + } + }; + Ok(render_manage_project(start, &state, &project_info, ¤t_user, message).await) +} diff --git a/crates/web/src/handlers/mod.rs b/crates/web/src/handlers/mod.rs index 452905d..9987d23 100644 --- a/crates/web/src/handlers/mod.rs +++ b/crates/web/src/handlers/mod.rs @@ -11,23 +11,30 @@ use mime::Mime; use crate::AppState; mod common; +mod manage; mod project; mod report; mod treemap; pub fn build_router() -> Router { Router::new() + .route("/robots.txt", get(common::get_robots)) .route("/api/github/webhook", post(decomp_dev_github::webhook::webhook)) .route("/api/github/oauth", get(decomp_dev_auth::oauth)) .route("/login", get(decomp_dev_auth::login)) .route("/logout", post(decomp_dev_auth::logout)) + .route("/manage", get(manage::manage)) + .route("/manage/new", get(manage::new)) + .route("/manage/new", post(manage::new_save)) + .route("/manage/{owner}/{repo}", get(manage::manage_project)) + .route("/manage/{owner}/{repo}", post(manage::manage_project_save)) + .route("/manage/{owner}/{repo}/refresh", post(manage::manage_project_refresh)) .route("/css/{*filename}", get(decomp_dev_scripts::get_css)) .route("/js/{*filename}", get(decomp_dev_scripts::get_js)) .route("/assets/{*filename}", get(decomp_dev_images::get_asset)) .route("/og.png", get(decomp_dev_images::get_og)) .route("/", get(project::get_projects)) .route("/{owner}/{repo}", get(report::get_report)) - .route("/{owner}/{repo}", post(report::save_project)) .route("/{owner}/{repo}/{version}", get(report::get_report)) .route("/{owner}/{repo}/{version}/{commit}", get(report::get_report)) } diff --git a/crates/web/src/handlers/project.rs b/crates/web/src/handlers/project.rs index fc97ef1..ec54710 100644 --- a/crates/web/src/handlers/project.rs +++ b/crates/web/src/handlers/project.rs @@ -1,4 +1,4 @@ -use std::{sync::Arc, time::Instant}; +use std::{str::FromStr, sync::Arc, time::Instant}; use anyhow::{Context, anyhow}; use axum::{ @@ -9,7 +9,7 @@ use axum::{ use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ AppError, FullUri, - models::{Commit, Project}, + models::{Commit, Platform, Project}, util::UrlExt, }; use maud::{DOCTYPE, Markup, html}; @@ -247,8 +247,9 @@ fn project_fragment( a href=(project_path) { (info.project.name()) } } @if let Some(platform) = &info.project.platform { + @let platform_name = Platform::from_str(platform).map(|p| p.name()).unwrap_or(platform); img class="platform-icon" src=(format!("/assets/platforms/{}.svg", platform)) - alt=(platform) width="24" height="24"; + alt=(platform_name) title=(platform_name) width="24" height="24"; } } h6 { diff --git a/crates/web/src/handlers/report.rs b/crates/web/src/handlers/report.rs index 9fe6512..364054e 100644 --- a/crates/web/src/handlers/report.rs +++ b/crates/web/src/handlers/report.rs @@ -2,10 +2,10 @@ use std::{borrow::Cow, iter, time::Instant}; use anyhow::{Context, Result}; use axum::{ - Form, Json, + Json, extract::{Path, Query, State}, http::{HeaderMap, StatusCode, Uri, header}, - response::{IntoResponse, Redirect, Response}, + response::{IntoResponse, Response}, }; use decomp_dev_auth::CurrentUser; use decomp_dev_core::{ @@ -232,7 +232,9 @@ pub async fn get_report( "shield" => mode_shield(&scope, query, &acceptable), "report" => mode_report(&scope, &state, uri, query, start, &acceptable, current_user).await, "measures" => mode_measures(&scope, &acceptable), - "history" => mode_history(&scope, &state, query, &acceptable).await, + "history" => { + mode_history(&scope, &state, uri, query, start, &acceptable, current_user).await + } _ => Err(AppError::Status(StatusCode::BAD_REQUEST)), } } @@ -251,7 +253,7 @@ async fn mode_report( if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) { - let rendered = render_template(scope, state, uri, current_user, start).await?; + let rendered = render_report(scope, state, uri, current_user, start).await?; return Ok(rendered.into_response()); } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { let flattened = scope.report.report.flatten(); @@ -336,8 +338,11 @@ struct ReportHistoryEntry { async fn mode_history( scope: &Scope<'_>, state: &AppState, + uri: Uri, query: ReportQuery, + start: Instant, acceptable: &[Mime], + current_user: Option, ) -> Result { let report_measures = state.db.fetch_all_reports(&scope.project_info.project, &scope.report.version).await?; @@ -378,8 +383,11 @@ async fn mode_history( } for mime in acceptable { if (mime.type_() == mime::STAR && mime.subtype() == mime::STAR) - || (mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON) + || (mime.type_() == mime::TEXT && mime.subtype() == mime::HTML) { + let rendered = render_history(scope, state, uri, current_user, start, result).await?; + return Ok(rendered.into_response()); + } else if mime.type_() == mime::APPLICATION && mime.subtype() == mime::JSON { return Ok(Json(result).into_response()); } } @@ -512,11 +520,18 @@ fn apply_scope<'a>( let label = current_unit .as_ref() .map(|u| u.name.rsplit_once('/').map_or(u.name.as_str(), |(_, name)| name)) - .or_else(|| current_category.as_ref().map(|c| c.name.as_str())); + .or_else(|| { + // Only show a category label if it is not the default category + let default_category_id = + project_info.project.default_category.as_deref().unwrap_or("all"); + let current_category_id = current_category.map(|c| c.id.as_str()).unwrap_or("all"); + (current_category_id != default_category_id) + .then(|| current_category.map(|c| c.name.as_str()).unwrap_or("All")) + }); Ok(Scope { report, project_info, measures, current_category, current_unit, units, label }) } -async fn render_template( +async fn render_report( scope: &Scope<'_>, state: &AppState, uri: Uri, @@ -557,6 +572,12 @@ async fn render_template( let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?; let project_base_path = format!("/{}/{}", project_info.project.owner, project_info.project.repo); + let project_history_path = request_url.query_param("mode", Some("history")); + let project_manage_path = + format!("/manage/{}/{}", project_info.project.owner, project_info.project.repo); + let can_manage = current_user + .as_ref() + .is_some_and(|u| u.permissions_for_repo(project_info.project.id).admin); let canonical_url = request_url.with_path(&format!( "/{}/{}/{}/{}", project_info.project.owner, project_info.project.repo, report.version, report.commit.sha @@ -575,7 +596,14 @@ async fn render_template( }) .collect::>(); - let all_url = canonical_url.query_param("category", None); + let all_url = canonical_url.query_param( + "category", + if project_info.project.default_category.as_deref().is_none_or(|c| c == "all") { + None + } else { + Some("all") + }, + ); let all_category = ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() }; let current_category = current_category @@ -679,6 +707,17 @@ async fn render_template( } } main { + .actions { + details class="dropdown" { + summary {} + ul dir="rtl" { + li { a href=(project_history_path) { "History" } } + @if can_manage { + li { a href=(project_manage_path) { "Manage" } } + } + } + } + } h3 { (format!("{project_short_name} is {:.2}% decompiled", measures.matched_code_percent)) } @if current_unit.is_none() && measures.complete_code_percent > 0.0 { h4 class="muted" { (format!("{:.2}% fully linked", measures.complete_code_percent)) } @@ -783,9 +822,6 @@ async fn render_template( noscript { img #treemap src=(image_url) alt="Progress graph"; } - @if current_user.as_ref().is_some_and(|u| u.permissions_for_repo(project_info.project.id).admin) { - (manage_form(project_info)) - } } } (footer(start, current_user.as_ref())) @@ -793,77 +829,146 @@ async fn render_template( }) } -fn manage_form(project_info: &ProjectInfo) -> Markup { +async fn render_history( + scope: &Scope<'_>, + state: &AppState, + uri: Uri, + current_user: Option, + start: Instant, + result: Vec, +) -> Result { + let Scope { report, project_info, measures, current_category, current_unit, units, label } = + scope; + + let request_url = Url::parse(&uri.to_string()).context("Failed to parse URI")?; let project_base_path = format!("/{}/{}", project_info.project.owner, project_info.project.repo); - let default_version = project_info.default_version(); - html! { - h6 class="report-header" { "Manage" } - form action=(project_base_path) method="post" { - fieldset { - label { - "Default version" - select name="default_version" { - @for version in &project_info.report_versions { - @if default_version == Some(version.as_str()) { - option value=(version) selected { (version) } - } @else { - option value=(version) { (version) } + let canonical_url = request_url.with_path(&format!( + "/{}/{}/{}", + project_info.project.owner, project_info.project.repo, report.version + )); + let image_url = canonical_url.with_path(&format!("{}.png", canonical_url.path())); + + let versions = project_info + .report_versions + .iter() + .map(|version| { + let version_url = request_url.with_path(&format!( + "/{}/{}/{}/{}", + project_info.project.owner, project_info.project.repo, version, report.commit.sha + )); + ReportTemplateVersion { id: version, path: version_url.path_and_query().to_string() } + }) + .collect::>(); + + let all_url = canonical_url.query_param( + "category", + if project_info.project.default_category.as_deref().is_none_or(|c| c == "all") { + None + } else { + Some("all") + }, + ); + let all_category = + ReportCategoryItem { id: "all", name: "All", path: all_url.path_and_query().to_string() }; + let current_category = current_category + .map(|c| { + let path = + canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string(); + ReportCategoryItem { id: &c.id, name: &c.name, path } + }) + .unwrap_or_else(|| all_category.clone()); + let categories = iter::once(all_category) + .chain(report.report.categories.iter().map(|c| { + let path = + canonical_url.query_param("category", Some(&c.id)).path_and_query().to_string(); + ReportCategoryItem { id: &c.id, name: &c.name, path } + })) + .collect::>(); + + let project_name = if let Some(label) = label { + Cow::Owned(format!("{} ({})", project_info.project.name(), label)) + } else { + project_info.project.name() + }; + let project_short_name = if let Some(label) = label { + Cow::Owned(format!("{} ({})", project_info.project.short_name(), label)) + } else { + Cow::Borrowed(project_info.project.short_name()) + }; + + Ok(html! { + (DOCTYPE) + html { + head lang="en" { + meta charset="utf-8"; + title { (project_short_name) " • Progress History" } + (header()) + meta name="description" content=(format!("Decompilation progress history for {project_name}")); + meta property="og:title" content=(format!("{project_short_name} is {:.2}% decompiled", measures.matched_code_percent)); + meta property="og:description" content=(format!("Decompilation progress history for {project_name}")); + meta property="og:image" content=(image_url); + meta property="og:url" content=(canonical_url); + link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.min.css"; + } + body { + header { + nav { + ul { + li { + a href="https://decomp.dev" { strong { "decomp.dev" } } + } + li { + a href="/" { "Projects" } + } + li { + a href=(project_base_path) { (project_short_name) } + } + li { + a href=(request_url) { "History" } + } + } + (nav_links()) + } + } + main { + h3 { "History for " (project_short_name) } + details class="dropdown" title="Version" { + summary { (report.version) } + ul { + @for version in &versions { + li { + a href=(version.path) { (version.id) } + } } } } - } - label { - @if project_info.project.enable_pr_comments { - input name="enable_pr_comments" type="checkbox" role="switch" checked; - } @else { - input name="enable_pr_comments" type="checkbox" role="switch"; + @if current_unit.is_none() && categories.len() > 1 { + details class="dropdown" title="Category" { + summary { (current_category.name) } + ul { + @for category in &categories { + li { + a href=(category.path) { (category.name) } + } + } + } + } + } + script src="https://cdn.jsdelivr.net/npm/uplot@1.6.32/dist/uPlot.iife.min.js" {} + script src="/js/history.min.js" {} + script { + (PreEscaped(r#"document.write('
');renderChart("chart","#)) + (PreEscaped(serde_json::to_string(&result)?)) + (PreEscaped(r#");"#)) + } + hr; + div role="group" { + a role="button" href=(project_base_path) { "Back to report" } } - "Enable PR comments" } } - button type="submit" { "Save" } + (footer(start, current_user.as_ref())) } - } -} - -fn form_bool<'de, D>(deserializer: D) -> Result -where D: serde::Deserializer<'de> { - match <&str>::deserialize(deserializer)? { - "on" => Ok(true), - "off" => Ok(false), - other => Err(serde::de::Error::unknown_variant(other, &["on", "off"])), - } -} - -#[derive(Deserialize)] -pub struct ProjectForm { - #[serde(default, deserialize_with = "form_bool")] - pub enable_pr_comments: bool, - pub default_version: Option, -} - -pub async fn save_project( - Path(params): Path, - State(state): State, - current_user: CurrentUser, - Form(form): Form, -) -> Result { - let Some(project_info) = state.db.get_project_info(¶ms.owner, ¶ms.repo, None).await? - else { - return Err(AppError::Status(StatusCode::NOT_FOUND)); - }; - if !current_user.permissions_for_repo(project_info.project.id).admin { - return Err(AppError::Status(StatusCode::FORBIDDEN)); - } - state - .db - .update_project_settings( - project_info.project.id, - form.enable_pr_comments, - form.default_version, - ) - .await?; - let redirect_url = format!("/{}/{}", params.owner, params.repo); - Ok(Redirect::to(&redirect_url).into_response()) + }) } diff --git a/crates/web/src/main.rs b/crates/web/src/main.rs index 6d24a04..5da4798 100644 --- a/crates/web/src/main.rs +++ b/crates/web/src/main.rs @@ -6,15 +6,16 @@ mod proto; use std::{ fs::File, io::BufReader, - net::{Ipv4Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, SocketAddr}, + str::FromStr, sync::Arc, time::Duration, }; use axum::{ Router, - extract::FromRef, - http::{Method, header}, + extract::{ConnectInfo, FromRef}, + http::{Method, Request, header}, }; use decomp_dev_core::config::{Config, GitHubConfig}; use decomp_dev_db::Database; @@ -25,10 +26,11 @@ use tower_http::{ ServiceBuilderExt, cors, cors::CorsLayer, timeout::TimeoutLayer, - trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer}, + trace::{DefaultOnResponse, MakeSpan, TraceLayer}, }; -use tower_sessions::{Expiry, SessionManagerLayer, SessionStore}; +use tower_sessions::{Expiry, SessionManagerLayer, SessionStore, cookie::SameSite}; use tower_sessions_sqlx_store::SqliteStore; +use tracing::{Level, Span}; use tracing_subscriber::{EnvFilter, filter::LevelFilter}; use crate::handlers::build_router; @@ -109,14 +111,15 @@ fn app(state: AppState, session_store: impl SessionStore + Clone) -> Router { .sensitive_response_headers(sensitive_headers) .layer( TraceLayer::new_for_http() - .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) - .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)), + .make_span_with(MyMakeSpan { level: Level::INFO }) + .on_response(DefaultOnResponse::new().level(Level::INFO)), ) - .layer(TimeoutLayer::new(Duration::from_secs(10))) + .layer(TimeoutLayer::new(Duration::from_secs(60))) .layer(CorsLayer::new().allow_methods([Method::GET]).allow_origin(cors::Any)) .layer( SessionManagerLayer::new(session_store) .with_secure(false) + .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity(time::Duration::days(1))), ) .compression(); @@ -147,3 +150,48 @@ async fn shutdown_signal() { _ = terminate => {}, } } + +#[derive(Debug, Clone)] +pub struct MyMakeSpan { + level: Level, +} + +impl MakeSpan for MyMakeSpan { + fn make_span(&mut self, request: &Request) -> Span { + let cf_connecting_ip = request.headers().get("CF-Connecting-IP"); + let ip = if let Some(v) = cf_connecting_ip { + str::from_utf8(v.as_bytes()).ok().and_then(|s| IpAddr::from_str(s).ok()) + } else if let Some(ConnectInfo(socket_addr)) = + request.extensions().get::>() + { + Some(socket_addr.ip()) + } else { + None + }; + let ip = ip.unwrap_or(IpAddr::from([0, 0, 0, 0])); + let user_agent = request + .headers() + .get(header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .unwrap_or("[unknown]"); + macro_rules! make_span { + ($level:expr) => { + tracing::span!( + $level, + "request", + method = %request.method(), + uri = %request.uri(), + ip = %ip, + user_agent = %user_agent, + ) + } + } + match self.level { + Level::ERROR => make_span!(Level::ERROR), + Level::WARN => make_span!(Level::WARN), + Level::INFO => make_span!(Level::INFO), + Level::DEBUG => make_span!(Level::DEBUG), + Level::TRACE => make_span!(Level::TRACE), + } + } +} diff --git a/css/main.scss b/css/main.scss index 695a1f0..cf180e4 100644 --- a/css/main.scss +++ b/css/main.scss @@ -53,7 +53,7 @@ $breakpoints: ( "layout/landmarks": true, "layout/container": true, "layout/section": true, - "layout/grid": false, + "layout/grid": true, "layout/overflow-auto": false, // Content @@ -80,7 +80,7 @@ $breakpoints: ( "components/card": true, "components/dropdown": true, "components/group": true, - "components/loading": false, + "components/loading": true, "components/modal": false, "components/nav": true, "components/progress": false, @@ -346,3 +346,43 @@ footer { transition: none; } } + +.info-card { + background-color: $azure-500; + color: $white; +} + +.warning-card { + background-color: $pumpkin-550; + color: $white; +} + +.error-card { + background-color: $red-650; + color: $white; +} + +.loading-container { + display: flex; + justify-content: center; + margin: var(--pico-spacing); +} + +.actions { + float: right; + position: relative; + + .dropdown { + summary { + &::after { + margin-inline-start: 0; + transform: none; + height: 100%; + } + } + + ul li { + text-align: left; + } + } +} diff --git a/js/history.ts b/js/history.ts new file mode 100644 index 0000000..d9de799 --- /dev/null +++ b/js/history.ts @@ -0,0 +1,130 @@ +const height = 400; +const stroke = '#a9a9b3'; +const grid = { + stroke: 'rgba(128, 128, 128, 0.1)', +}; + +function percentValue(self, rawValue) { + if (rawValue == null) { + return null; + } + return rawValue.toFixed(2) + "%"; +} + +type Measures = { + fuzzy_match_percent: number; + total_code: number; + matched_code: number; + matched_code_percent: number; + total_data: number; + matched_data: number; + matched_data_percent: number; + total_functions: number; + matched_functions: number; + matched_functions_percent: number; + complete_code: number; + complete_code_percent: number; + complete_data: number; + complete_data_percent: number; + total_units: number; + complete_units: number; +}; + +type ReportHistoryEntry = { + timestamp: string; + commit_sha: string; + measures: Measures; +}; + +function renderChart(id: string, data: ReportHistoryEntry[]) { + let chart = document.getElementById(id); + if (!chart) { + console.error(`Chart element with id ${id} not found`); + return; + } + data.reverse(); + + const u = new uPlot({ + id: id, + width: 600, + height: height, + scales: { + x: { + time: true, + }, + }, + series: [ + {}, + { + show: false, + label: "Fuzzy Match Percent", + width: 2, + stroke: "#003f5c", + value: percentValue, + }, + { + label: "Matched Code", + width: 2, + stroke: "#ff6361", + value: percentValue, + }, + { + show: false, + label: "Matched Data", + width: 2, + stroke: "#ffa600", + value: percentValue, + }, + { + show: false, + label: "Linked Code", + width: 2, + stroke: "#bc5090", + value: percentValue, + }, + { + show: false, + label: "Linked Data", + width: 2, + stroke: "#58508d", + value: percentValue, + } + ], + axes: [ + { + stroke, + grid, + }, + { + stroke, + grid, + values: (self, ticks) => ticks.map(rawValue => rawValue.toFixed(0) + "%"), + }, + ], + hooks: { + init: [ + u => { + u.over.addEventListener('click', e => { + console.log('click!', data[u.legend.idx]); + }); + } + ], + }, + }, null, chart); + + function updateSize() { + const container = chart.parentElement; + u.setSize({width: container.offsetWidth, height}); + } + + window.addEventListener('resize', updateSize); + updateSize(); + u.setData([ + data.map(e => Date.parse(e.timestamp) / 1000), + data.map(e => e.measures.fuzzy_match_percent || null), + data.map(e => e.measures.matched_code_percent || null), + data.map(e => e.measures.matched_data_percent || null), + data.map(e => e.measures.complete_code_percent || null), + data.map(e => e.measures.complete_data_percent || null), + ]); +} diff --git a/js/manage.ts b/js/manage.ts new file mode 100644 index 0000000..deb4a0e --- /dev/null +++ b/js/manage.ts @@ -0,0 +1,12 @@ +document.querySelectorAll('form[data-loading]').forEach((form) => { + const loadingText = form.getAttribute('data-loading'); + const submitButton = form.querySelector('[type="submit"]'); + if (!submitButton || !(submitButton instanceof HTMLButtonElement)) { + return; + } + form.addEventListener('submit', () => { + submitButton.disabled = true; + submitButton.setAttribute('aria-busy', 'true'); + submitButton.innerText = loadingText; + }); +});