collin/anvil · 583f0af5
Add periodic job infrastructure: language detection, preview images, disk usage cache
Collin Richards · 2026-08-18 00:38 UTC · 583f0af501d5dc104870453d05613544853c0c11 · parent 203d130c · browse files
modifiedCargo.lock+3 −0
| ⋯ 155 unchanged lines | |||
| 156 | 156 | version = "0.0.0" | |
| 157 | 157 | dependencies = [ | |
| 158 | 158 | "argon2 0.5.3", | |
| 159 | + | "async-trait", | |
| 159 | 160 | "gix", | |
| 160 | 161 | "hmac 0.12.1", | |
| 162 | + | "pulldown-cmark", | |
| 161 | 163 | "rusqlite", | |
| 162 | 164 | "serde", | |
| 165 | + | "serde_json", | |
| 163 | 166 | "serde_yaml", | |
| 164 | 167 | "sha2 0.10.9", | |
| 165 | 168 | "ssh-key", | |
| ⋯ 5183 unchanged lines | |||
modifiedCargo.toml+1 −0
| ⋯ 21 unchanged lines | |||
| 22 | 22 | ||
| 23 | 23 | anyhow = "1" | |
| 24 | 24 | argon2 = { version = "0.5", features = ["std"] } | |
| 25 | + | async-trait = "0.1" | |
| 25 | 26 | axum = "0.8" | |
| 26 | 27 | axum-extra = { version = "0.10", features = ["cookie"] } | |
| 27 | 28 | base64 = "0.22" | |
| ⋯ 53 unchanged lines | |||
modifiedTODO.md+5 −0
| ⋯ 22 unchanged lines | |||
| 23 | 23 | is no repo-delete path yet (only the create-rollback uses it) | |
| 24 | 24 | - [ ] admin usage: per-repo drill-down, and a cheap cached/periodic variant if | |
| 25 | 25 | the on-demand disk walk gets slow on large instances | |
| 26 | + | - [ ] periodic disk usage cache: run `usage::compute()` on a timer (e.g., hourly) | |
| 27 | + | and store the result so the admin dashboard doesn't block on disk walks | |
| 28 | + | - [ ] repository preview images: extract the first "real" image (>few hundred px) | |
| 29 | + | from README.md on a periodic scan, cache the attachment hash, and display in | |
| 30 | + | repo listings for visual browsing | |
| 26 | 31 | - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and | |
| 27 | 32 | `last_used_at` tracking | |
modifiedcrates/anvil-cli/src/main.rs+20 −0
| ⋯ 147 unchanged lines | |||
| 148 | 148 | app.ci_tx = Some(ci_tx); | |
| 149 | 149 | tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx)); | |
| 150 | 150 | ||
| 151 | + | // Start periodic background jobs (language detection, preview images, disk usage cache). | |
| 152 | + | let periodic_jobs = vec![ | |
| 153 | + | ( | |
| 154 | + | std::time::Duration::from_secs(app.config.periodic.language_detection_interval_secs), | |
| 155 | + | Box::new(anvil_core::periodic::LanguageDetectionJob) | |
| 156 | + | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 157 | + | ), | |
| 158 | + | ( | |
| 159 | + | std::time::Duration::from_secs(app.config.periodic.preview_image_interval_secs), | |
| 160 | + | Box::new(anvil_core::periodic::PreviewImageJob) | |
| 161 | + | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 162 | + | ), | |
| 163 | + | ( | |
| 164 | + | std::time::Duration::from_secs(app.config.periodic.disk_usage_interval_secs), | |
| 165 | + | Box::new(anvil_core::periodic::DiskUsageCacheJob) | |
| 166 | + | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 167 | + | ), | |
| 168 | + | ]; | |
| 169 | + | anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await; | |
| 170 | + | ||
| 151 | 171 | if app.config.ssh.enabled { | |
| 152 | 172 | // Run the HTTP and SSH servers concurrently; if either exits, stop. | |
| 153 | 173 | tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?; | |
| ⋯ 137 unchanged lines | |||
modifiedcrates/anvil-core/Cargo.toml+3 −0
| ⋯ 7 unchanged lines | |||
| 8 | 8 | description = "Domain model, persistence, and on-disk repository storage for the anvil git forge." | |
| 9 | 9 | ||
| 10 | 10 | [dependencies] | |
| 11 | + | async-trait.workspace = true | |
| 11 | 12 | gix.workspace = true | |
| 12 | 13 | toasty.workspace = true | |
| 13 | 14 | rusqlite.workspace = true | |
| ⋯ 2 unchanged lines | |||
| 16 | 17 | sha2.workspace = true | |
| 17 | 18 | ssh-key.workspace = true | |
| 18 | 19 | serde.workspace = true | |
| 20 | + | serde_json.workspace = true | |
| 19 | 21 | serde_yaml.workspace = true | |
| 20 | 22 | toml.workspace = true | |
| 21 | 23 | thiserror.workspace = true | |
| 22 | 24 | tracing.workspace = true | |
| 23 | 25 | tokio.workspace = true | |
| 26 | + | pulldown-cmark.workspace = true | |
| 24 | 27 | ||
| 25 | 28 | [dev-dependencies] | |
| 26 | 29 | tokio = { workspace = true } | |
| ⋯ 1 unchanged line | |||
addedcrates/anvil-core/src/admin_cache.rs+55 −0
| 1 | + | //! Admin dashboard cache storage (disk usage, etc.) | |
| 2 | + | ||
| 3 | + | use toasty::create; | |
| 4 | + | ||
| 5 | + | use crate::{ | |
| 6 | + | error::Result, | |
| 7 | + | models::AdminCache, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | /// Store or update a cached value by key. | |
| 11 | + | pub async fn upsert(db: &toasty::Db, key: &str, value: &str) -> Result<()> { | |
| 12 | + | let mut conn = db.clone(); | |
| 13 | + | ||
| 14 | + | // Try to find existing entry | |
| 15 | + | let existing = AdminCache::filter(AdminCache::fields().key().eq(key)) | |
| 16 | + | .first() | |
| 17 | + | .exec(&mut conn) | |
| 18 | + | .await?; | |
| 19 | + | ||
| 20 | + | let now = std::time::SystemTime::now() | |
| 21 | + | .duration_since(std::time::UNIX_EPOCH) | |
| 22 | + | .unwrap_or_default() | |
| 23 | + | .as_secs() as i64; | |
| 24 | + | ||
| 25 | + | if let Some(mut cache) = existing { | |
| 26 | + | let mut conn = db.clone(); | |
| 27 | + | cache | |
| 28 | + | .update() | |
| 29 | + | .value(value) | |
| 30 | + | .computed_at(now) | |
| 31 | + | .exec(&mut conn) | |
| 32 | + | .await?; | |
| 33 | + | } else { | |
| 34 | + | let mut conn = db.clone(); | |
| 35 | + | create!(AdminCache { | |
| 36 | + | key: key.to_string(), | |
| 37 | + | value: value.to_string(), | |
| 38 | + | computed_at: now, | |
| 39 | + | }) | |
| 40 | + | .exec(&mut conn) | |
| 41 | + | .await?; | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | Ok(()) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Retrieve a cached value by key. | |
| 48 | + | pub async fn get(db: &toasty::Db, key: &str) -> Result<Option<AdminCache>> { | |
| 49 | + | let mut conn = db.clone(); | |
| 50 | + | let entry = AdminCache::filter(AdminCache::fields().key().eq(key)) | |
| 51 | + | .first() | |
| 52 | + | .exec(&mut conn) | |
| 53 | + | .await?; | |
| 54 | + | Ok(entry) | |
| 55 | + | } |
modifiedcrates/anvil-core/src/config.rs+27 −0
| ⋯ 28 unchanged lines | |||
| 29 | 29 | pub ssh: SshConfig, | |
| 30 | 30 | /// Continuous-deployment settings (the single-repo redeploy webhook). | |
| 31 | 31 | pub ci: CiConfig, | |
| 32 | + | /// Periodic background job settings. | |
| 33 | + | pub periodic: PeriodicConfig, | |
| 32 | 34 | } | |
| 33 | 35 | ||
| 34 | 36 | /// CI configuration: job sandbox limits and the single-repo redeploy webhook. | |
| ⋯ 97 unchanged lines | |||
| 132 | 134 | pub clone_user: String, | |
| 133 | 135 | } | |
| 134 | 136 | ||
| 137 | + | #[derive(Clone, Debug, Deserialize, Serialize)] | |
| 138 | + | #[serde(default)] | |
| 139 | + | pub struct PeriodicConfig { | |
| 140 | + | /// Interval (seconds) between repository language-detection scans. | |
| 141 | + | /// Defaults to 3600 (1 hour). | |
| 142 | + | pub language_detection_interval_secs: u64, | |
| 143 | + | /// Interval (seconds) between repository preview-image extractions from README. | |
| 144 | + | /// Defaults to 3600 (1 hour). | |
| 145 | + | pub preview_image_interval_secs: u64, | |
| 146 | + | /// Interval (seconds) between disk-usage cache refreshes. | |
| 147 | + | /// Defaults to 3600 (1 hour). | |
| 148 | + | pub disk_usage_interval_secs: u64, | |
| 149 | + | } | |
| 150 | + | ||
| 135 | 151 | impl Default for Config { | |
| 136 | 152 | fn default() -> Self { | |
| 137 | 153 | Self { | |
| ⋯ 1 unchanged line | |||
| 139 | 155 | http: HttpConfig::default(), | |
| 140 | 156 | ssh: SshConfig::default(), | |
| 141 | 157 | ci: CiConfig::default(), | |
| 158 | + | periodic: PeriodicConfig::default(), | |
| 142 | 159 | } | |
| 143 | 160 | } | |
| 144 | 161 | } | |
| ⋯ 67 unchanged lines | |||
| 212 | 229 | } | |
| 213 | 230 | } | |
| 214 | 231 | ||
| 232 | + | impl Default for PeriodicConfig { | |
| 233 | + | fn default() -> Self { | |
| 234 | + | Self { | |
| 235 | + | language_detection_interval_secs: 3600, | |
| 236 | + | preview_image_interval_secs: 3600, | |
| 237 | + | disk_usage_interval_secs: 3600, | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 215 | 242 | impl Config { | |
| 216 | 243 | /// Load configuration from a TOML file. Missing fields fall back to defaults. | |
| 217 | 244 | pub fn load(path: impl AsRef<Path>) -> Result<Self> { | |
| ⋯ 99 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+32 −6
| ⋯ 4 unchanged lines | |||
| 5 | 5 | use crate::{ | |
| 6 | 6 | error::Result, | |
| 7 | 7 | models::{ | |
| 8 | + | AdminCache, | |
| 8 | 9 | ApiToken, | |
| 9 | 10 | Attachment, | |
| 10 | 11 | CiArtifact, | |
| ⋯ 32 unchanged lines | |||
| 43 | 44 | Issue, | |
| 44 | 45 | IssueComment, | |
| 45 | 46 | Attachment, | |
| 46 | - | ApiToken | |
| 47 | + | ApiToken, | |
| 48 | + | AdminCache | |
| 47 | 49 | )) | |
| 48 | 50 | .connect(&url) | |
| 49 | 51 | .await?; | |
| ⋯ 23 unchanged lines | |||
| 73 | 75 | API_TOKENS_DDL, | |
| 74 | 76 | r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#, | |
| 75 | 77 | r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#, | |
| 78 | + | ADMIN_CACHE_DDL, | |
| 76 | 79 | ]; | |
| 77 | 80 | ||
| 78 | 81 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 43 unchanged lines | |||
| 122 | 125 | "scopes" TEXT NOT NULL, | |
| 123 | 126 | "created_at" BIGINT NOT NULL )"#; | |
| 124 | 127 | ||
| 128 | + | const ADMIN_CACHE_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "admin_cache" ( | |
| 129 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 130 | + | "key" TEXT NOT NULL, | |
| 131 | + | "value" TEXT NOT NULL, | |
| 132 | + | "computed_at" BIGINT NOT NULL )"#; | |
| 133 | + | ||
| 125 | 134 | /// Columns added to existing tables after deployment, applied as | |
| 126 | 135 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` | |
| 127 | 136 | /// for columns, so presence is checked via `pragma_table_info`). The model | |
| 128 | 137 | /// must declare the field *last* so fresh and migrated column orders agree. | |
| 129 | 138 | /// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which | |
| 130 | 139 | /// is fine — inserts always provide every field. | |
| 131 | - | const COLUMN_SHIMS: &[(&str, &str, &str)] = &[( | |
| 132 | - | "repositories", | |
| 133 | - | "mirror_url", | |
| 134 | - | r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#, | |
| 135 | - | )]; | |
| 140 | + | const COLUMN_SHIMS: &[(&str, &str, &str)] = &[ | |
| 141 | + | ( | |
| 142 | + | "repositories", | |
| 143 | + | "mirror_url", | |
| 144 | + | r#"ALTER TABLE "repositories" ADD COLUMN "mirror_url" TEXT NOT NULL DEFAULT ''"#, | |
| 145 | + | ), | |
| 146 | + | ( | |
| 147 | + | "repositories", | |
| 148 | + | "preview_image_hash", | |
| 149 | + | r#"ALTER TABLE "repositories" ADD COLUMN "preview_image_hash" TEXT NOT NULL DEFAULT ''"#, | |
| 150 | + | ), | |
| 151 | + | ( | |
| 152 | + | "repositories", | |
| 153 | + | "primary_language", | |
| 154 | + | r#"ALTER TABLE "repositories" ADD COLUMN "primary_language" TEXT NOT NULL DEFAULT ''"#, | |
| 155 | + | ), | |
| 156 | + | ( | |
| 157 | + | "repositories", | |
| 158 | + | "languages_json", | |
| 159 | + | r#"ALTER TABLE "repositories" ADD COLUMN "languages_json" TEXT NOT NULL DEFAULT ''"#, | |
| 160 | + | ), | |
| 161 | + | ]; | |
| 136 | 162 | ||
| 137 | 163 | /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses | |
| 138 | 164 | /// rusqlite directly (already in-tree via Toasty's SQLite driver); every | |
| ⋯ 155 unchanged lines | |||
modifiedcrates/anvil-core/src/error.rs+3 −0
| ⋯ 25 unchanged lines | |||
| 26 | 26 | ||
| 27 | 27 | #[error(transparent)] | |
| 28 | 28 | Io(#[from] std::io::Error), | |
| 29 | + | ||
| 30 | + | #[error("JSON error: {0}")] | |
| 31 | + | Json(#[from] serde_json::Error), | |
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | /// Convenience alias used throughout the crate. | |
| ⋯ 1 unchanged line | |||
addedcrates/anvil-core/src/language.rs+120 −0
| 1 | + | //! Language detection for repositories by file-type counting. | |
| 2 | + | ||
| 3 | + | use std::{ | |
| 4 | + | collections::BTreeMap, | |
| 5 | + | path::Path, | |
| 6 | + | }; | |
| 7 | + | ||
| 8 | + | use crate::error::Result; | |
| 9 | + | ||
| 10 | + | /// Language percentages for a repository. | |
| 11 | + | #[derive(Clone, Debug)] | |
| 12 | + | pub struct LanguageDetection { | |
| 13 | + | /// Primary language by count, or empty if no files. | |
| 14 | + | pub primary: String, | |
| 15 | + | /// JSON array: [{"lang": "Rust", "percent": 75.5}, ...] | |
| 16 | + | pub json: String, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// Map of file extensions to language names. | |
| 20 | + | fn ext_to_language(ext: &str) -> Option<&'static str> { | |
| 21 | + | match ext { | |
| 22 | + | "rs" => Some("Rust"), | |
| 23 | + | "go" => Some("Go"), | |
| 24 | + | "py" => Some("Python"), | |
| 25 | + | "js" | "jsx" => Some("JavaScript"), | |
| 26 | + | "ts" | "tsx" => Some("TypeScript"), | |
| 27 | + | "java" => Some("Java"), | |
| 28 | + | "cpp" | "cc" | "cxx" | "c++" => Some("C++"), | |
| 29 | + | "c" => Some("C"), | |
| 30 | + | "h" | "hpp" => Some("C/C++"), | |
| 31 | + | "cs" => Some("C#"), | |
| 32 | + | "rb" => Some("Ruby"), | |
| 33 | + | "php" => Some("PHP"), | |
| 34 | + | "swift" => Some("Swift"), | |
| 35 | + | "kt" => Some("Kotlin"), | |
| 36 | + | "sh" | "bash" => Some("Shell"), | |
| 37 | + | "yml" | "yaml" => Some("YAML"), | |
| 38 | + | "json" => Some("JSON"), | |
| 39 | + | "toml" => Some("TOML"), | |
| 40 | + | "md" => Some("Markdown"), | |
| 41 | + | "html" | "htm" => Some("HTML"), | |
| 42 | + | "css" | "scss" | "sass" => Some("CSS"), | |
| 43 | + | "sql" => Some("SQL"), | |
| 44 | + | "xml" => Some("XML"), | |
| 45 | + | _ => None, | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /// Detect languages in a repository by walking the file tree and counting by extension. | |
| 50 | + | /// Returns primary language + JSON-encoded percentages. | |
| 51 | + | pub async fn detect(repo_path: &Path) -> Result<LanguageDetection> { | |
| 52 | + | let mut counts: BTreeMap<String, u64> = BTreeMap::new(); | |
| 53 | + | let mut total = 0u64; | |
| 54 | + | ||
| 55 | + | // Walk the repository tree, skipping .git. | |
| 56 | + | if let Ok(entries) = std::fs::read_dir(repo_path) { | |
| 57 | + | walk_dir(&mut counts, &mut total, entries); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | let primary = if total == 0 { | |
| 61 | + | String::new() | |
| 62 | + | } else { | |
| 63 | + | let mut max_lang = String::new(); | |
| 64 | + | let mut max_count = 0u64; | |
| 65 | + | for (lang, count) in &counts { | |
| 66 | + | if *count > max_count { | |
| 67 | + | max_count = *count; | |
| 68 | + | max_lang = lang.clone(); | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | max_lang | |
| 72 | + | }; | |
| 73 | + | ||
| 74 | + | let json = if counts.is_empty() { | |
| 75 | + | "[]".to_string() | |
| 76 | + | } else { | |
| 77 | + | let mut entries: Vec<_> = counts.iter().collect(); | |
| 78 | + | entries.sort_by(|a, b| b.1.cmp(a.1)); | |
| 79 | + | ||
| 80 | + | let percentages: Vec<String> = entries | |
| 81 | + | .iter() | |
| 82 | + | .map(|(lang, count)| { | |
| 83 | + | let percent = (**count as f64 / total as f64) * 100.0; | |
| 84 | + | format!(r#"{{"lang":"{}","percent":{:.1}}}"#, lang, percent) | |
| 85 | + | }) | |
| 86 | + | .collect(); | |
| 87 | + | format!("[{}]", percentages.join(",")) | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | Ok(LanguageDetection { primary, json }) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn walk_dir(counts: &mut BTreeMap<String, u64>, total: &mut u64, entries: std::fs::ReadDir) { | |
| 94 | + | for entry in entries.flatten() { | |
| 95 | + | let Ok(metadata) = entry.metadata() else { | |
| 96 | + | continue; | |
| 97 | + | }; | |
| 98 | + | ||
| 99 | + | let path = entry.path(); | |
| 100 | + | let file_name = entry.file_name(); | |
| 101 | + | let name = file_name.to_string_lossy(); | |
| 102 | + | ||
| 103 | + | // Skip .git and hidden directories. | |
| 104 | + | if name.starts_with('.') { | |
| 105 | + | continue; | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | if metadata.is_dir() { | |
| 109 | + | if let Ok(subentries) = std::fs::read_dir(&path) { | |
| 110 | + | walk_dir(counts, total, subentries); | |
| 111 | + | } | |
| 112 | + | } else if metadata.is_file() | |
| 113 | + | && let Some(ext) = path.extension().and_then(|s| s.to_str()) | |
| 114 | + | && let Some(lang) = ext_to_language(ext) | |
| 115 | + | { | |
| 116 | + | *counts.entry(lang.to_string()).or_insert(0) += 1; | |
| 117 | + | *total += 1; | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } |
modifiedcrates/anvil-core/src/lib.rs+4 −0
| ⋯ 5 unchanged lines | |||
| 6 | 6 | //! `anvil-git`) build on top of it. | |
| 7 | 7 | ||
| 8 | 8 | pub mod access; | |
| 9 | + | pub mod admin_cache; | |
| 9 | 10 | pub mod api_tokens; | |
| 10 | 11 | pub mod attachments; | |
| 11 | 12 | pub mod ci; | |
| ⋯ 1 unchanged line | |||
| 13 | 14 | pub mod db; | |
| 14 | 15 | pub mod error; | |
| 15 | 16 | pub mod issues; | |
| 17 | + | pub mod language; | |
| 16 | 18 | pub mod models; | |
| 19 | + | pub mod periodic; | |
| 20 | + | pub mod preview_images; | |
| 17 | 21 | pub mod repos; | |
| 18 | 22 | pub mod sessions; | |
| 19 | 23 | pub mod ssh_keys; | |
| ⋯ 115 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+20 −0
| ⋯ 39 unchanged lines | |||
| 40 | 40 | /// columns go last so `ALTER TABLE ADD COLUMN` on existing databases | |
| 41 | 41 | /// agrees with the fresh-schema column order. | |
| 42 | 42 | pub mirror_url: String, | |
| 43 | + | /// SHA-256 hash of the preview image extracted from README (empty if none). | |
| 44 | + | pub preview_image_hash: String, | |
| 45 | + | /// Primary language detected in the repository (e.g. "Rust", empty if no files). | |
| 46 | + | pub primary_language: String, | |
| 47 | + | /// JSON array of language percentages: [{"lang": "Rust", "percent": 75.5}, ...]. | |
| 48 | + | pub languages_json: String, | |
| 43 | 49 | } | |
| 44 | 50 | ||
| 45 | 51 | /// A CI run for a pushed commit. | |
| ⋯ 162 unchanged lines | |||
| 208 | 214 | pub content: String, | |
| 209 | 215 | pub created_at: i64, | |
| 210 | 216 | } | |
| 217 | + | ||
| 218 | + | /// Cached admin metrics computed periodically (e.g., disk usage snapshot). | |
| 219 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 220 | + | pub struct AdminCache { | |
| 221 | + | #[key] | |
| 222 | + | #[auto] | |
| 223 | + | pub id: i64, | |
| 224 | + | /// Cache key (e.g., "disk_usage"). | |
| 225 | + | pub key: String, | |
| 226 | + | /// JSON-encoded cached data. | |
| 227 | + | pub value: String, | |
| 228 | + | /// Unix timestamp (seconds) of when this snapshot was taken. | |
| 229 | + | pub computed_at: i64, | |
| 230 | + | } | |
addedcrates/anvil-core/src/periodic.rs+176 −0
| 1 | + | //! Periodic background jobs: repository scanning, disk usage caching, etc. | |
| 2 | + | //! | |
| 3 | + | //! Jobs run on configurable intervals and log errors without affecting the | |
| 4 | + | //! server. The runner is spawned in `anvil-cli/src/main.rs::serve()` alongside | |
| 5 | + | //! the HTTP server and CI worker. | |
| 6 | + | ||
| 7 | + | use std::{ | |
| 8 | + | path::Path, | |
| 9 | + | time::Duration, | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | use async_trait::async_trait; | |
| 13 | + | use tokio::time::interval; | |
| 14 | + | ||
| 15 | + | use crate::{ | |
| 16 | + | App, | |
| 17 | + | Result, | |
| 18 | + | language, | |
| 19 | + | models::Repository, | |
| 20 | + | preview_images, | |
| 21 | + | repos, | |
| 22 | + | usage, | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | /// A periodic task to be run on an interval. | |
| 26 | + | #[async_trait] | |
| 27 | + | pub trait PeriodicJob: Send + Sync { | |
| 28 | + | /// Run the job once. Errors are logged but do not stop the runner. | |
| 29 | + | async fn run(&self, app: &App) -> Result<()>; | |
| 30 | + | ||
| 31 | + | /// Human-readable name for logging. | |
| 32 | + | fn name(&self) -> &str; | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Spawns a background task that runs jobs on their configured intervals. | |
| 36 | + | /// The task runs until the app shuts down. | |
| 37 | + | pub async fn spawn_runner(app: App, jobs: Vec<(Duration, Box<dyn PeriodicJob>)>) { | |
| 38 | + | tokio::spawn(async move { | |
| 39 | + | let mut tasks = Vec::new(); | |
| 40 | + | for (interval_duration, job) in jobs { | |
| 41 | + | let mut ticker = interval(interval_duration); | |
| 42 | + | let app = app.clone(); | |
| 43 | + | tasks.push(tokio::spawn(async move { | |
| 44 | + | loop { | |
| 45 | + | ticker.tick().await; | |
| 46 | + | if let Err(e) = job.run(&app).await { | |
| 47 | + | tracing::error!( | |
| 48 | + | job = job.name(), | |
| 49 | + | error = %e, | |
| 50 | + | "periodic job failed" | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | })); | |
| 55 | + | } | |
| 56 | + | // All tasks are spawned and run forever; if any exits, others continue. | |
| 57 | + | // We could join them here with `futures::future::join_all(tasks).await`, | |
| 58 | + | // but detaching is fine — tasks clean up automatically on shutdown. | |
| 59 | + | }); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Job that detects programming languages in repositories. | |
| 63 | + | pub struct LanguageDetectionJob; | |
| 64 | + | ||
| 65 | + | #[async_trait::async_trait] | |
| 66 | + | impl PeriodicJob for LanguageDetectionJob { | |
| 67 | + | async fn run(&self, app: &App) -> Result<()> { | |
| 68 | + | let repos_dir = app.config.repositories_dir(); | |
| 69 | + | let mut db = app.db.clone(); | |
| 70 | + | let repos_list = Repository::all().exec(&mut db).await?; | |
| 71 | + | let repo_count = repos_list.len(); | |
| 72 | + | ||
| 73 | + | for repo in repos_list { | |
| 74 | + | let repo_path = repos_dir | |
| 75 | + | .join(repo.owner_id.to_string()) | |
| 76 | + | .join(&repo.name) | |
| 77 | + | .join(".git"); | |
| 78 | + | if repo_path.exists() | |
| 79 | + | && let Ok(detection) = language::detect(&repo_path).await | |
| 80 | + | { | |
| 81 | + | let _ = | |
| 82 | + | repos::update_languages(&app.db, repo.id, &detection.primary, &detection.json) | |
| 83 | + | .await; | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | tracing::info!("language detection job completed for {} repos", repo_count); | |
| 87 | + | Ok(()) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | fn name(&self) -> &str { | |
| 91 | + | "language_detection" | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Job that extracts preview images from repository READMEs. | |
| 96 | + | pub struct PreviewImageJob; | |
| 97 | + | ||
| 98 | + | #[async_trait::async_trait] | |
| 99 | + | impl PeriodicJob for PreviewImageJob { | |
| 100 | + | async fn run(&self, app: &App) -> Result<()> { | |
| 101 | + | let repos_dir = app.config.repositories_dir(); | |
| 102 | + | let mut db = app.db.clone(); | |
| 103 | + | let repos_list = Repository::all().exec(&mut db).await?; | |
| 104 | + | ||
| 105 | + | let mut updated = 0; | |
| 106 | + | for repo in repos_list { | |
| 107 | + | let repo_git_path = repos_dir | |
| 108 | + | .join(repo.owner_id.to_string()) | |
| 109 | + | .join(&repo.name) | |
| 110 | + | .join(".git"); | |
| 111 | + | if repo_git_path.exists() { | |
| 112 | + | // Try to extract preview image from README in the repository | |
| 113 | + | if let Some(image_url) = extract_readme_image(&repo_git_path).await { | |
| 114 | + | // TODO: download and store the image, or just reference it | |
| 115 | + | // For now, we'll just store the URL as a hash placeholder | |
| 116 | + | if !image_url.is_empty() { | |
| 117 | + | let _ = repos::update_preview_image(&app.db, repo.id, &image_url).await; | |
| 118 | + | updated += 1; | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | tracing::info!("preview image job completed: {} repos updated", updated); | |
| 124 | + | Ok(()) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | fn name(&self) -> &str { | |
| 128 | + | "preview_images" | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Job that caches disk usage for the admin dashboard. | |
| 133 | + | pub struct DiskUsageCacheJob; | |
| 134 | + | ||
| 135 | + | #[async_trait::async_trait] | |
| 136 | + | impl PeriodicJob for DiskUsageCacheJob { | |
| 137 | + | async fn run(&self, app: &App) -> Result<()> { | |
| 138 | + | // Compute disk usage and cache it | |
| 139 | + | if let Ok(usage_data) = usage::compute(app).await { | |
| 140 | + | let json = serde_json::to_string(&usage_data)?; | |
| 141 | + | // Store in admin_cache table | |
| 142 | + | crate::admin_cache::upsert(&app.db, "disk_usage", &json).await?; | |
| 143 | + | tracing::info!("disk usage cache job completed"); | |
| 144 | + | } | |
| 145 | + | Ok(()) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | fn name(&self) -> &str { | |
| 149 | + | "disk_usage_cache" | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | /// Try to extract the first image URL from a repository's README file. | |
| 154 | + | /// Scans the repository for a README file, reads it, and returns the first image URL found. | |
| 155 | + | async fn extract_readme_image(repo_path: &Path) -> Option<String> { | |
| 156 | + | // Find README file in the repository (case-insensitive) | |
| 157 | + | let readme_names = [ | |
| 158 | + | "README.md", | |
| 159 | + | "readme.md", | |
| 160 | + | "README.MD", | |
| 161 | + | "README.rst", | |
| 162 | + | "README.txt", | |
| 163 | + | ]; | |
| 164 | + | ||
| 165 | + | for readme_name in &readme_names { | |
| 166 | + | let readme_path = repo_path.parent()?.join(readme_name); | |
| 167 | + | if readme_path.exists() | |
| 168 | + | && let Ok(content) = std::fs::read_to_string(&readme_path) | |
| 169 | + | && let Some(url) = preview_images::extract_preview_url(&content) | |
| 170 | + | { | |
| 171 | + | return Some(url); | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | None | |
| 176 | + | } |
addedcrates/anvil-core/src/preview_images.rs+28 −0
| 1 | + | //! Extract preview images from README files. | |
| 2 | + | ||
| 3 | + | use pulldown_cmark::{ | |
| 4 | + | Event, | |
| 5 | + | Parser, | |
| 6 | + | Tag, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | /// Extract the first "real" image from markdown content. | |
| 10 | + | /// Returns the image URL (e.g., `` → `url`), or None if not found. | |
| 11 | + | pub fn extract_preview_url(markdown: &str) -> Option<String> { | |
| 12 | + | let parser = Parser::new(markdown); | |
| 13 | + | ||
| 14 | + | for event in parser { | |
| 15 | + | if let Event::Start(Tag::Image { dest_url, .. }) = event { | |
| 16 | + | let url_str = dest_url.to_string(); | |
| 17 | + | ||
| 18 | + | // Accept any image URL that's not data: or javascript: | |
| 19 | + | if !url_str.is_empty() | |
| 20 | + | && !url_str.starts_with("data:") | |
| 21 | + | && !url_str.starts_with("javascript:") | |
| 22 | + | { | |
| 23 | + | return Some(url_str); | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | None | |
| 28 | + | } |
modifiedcrates/anvil-core/src/repos.rs+49 −0
| ⋯ 21 unchanged lines | |||
| 22 | 22 | pub description: String, | |
| 23 | 23 | pub is_private: bool, | |
| 24 | 24 | pub default_branch: String, | |
| 25 | + | pub primary_language: String, | |
| 26 | + | pub preview_image_hash: String, | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | /// Create a repository owned by `owner`: insert the database row and initialize | |
| ⋯ 24 unchanged lines | |||
| 52 | 54 | default_branch: "main", | |
| 53 | 55 | created_at: crate::now(), | |
| 54 | 56 | mirror_url: "", | |
| 57 | + | preview_image_hash: "", | |
| 58 | + | primary_language: "", | |
| 59 | + | languages_json: "", | |
| 55 | 60 | }) | |
| 56 | 61 | .exec(&mut conn) | |
| 57 | 62 | .await?; | |
| ⋯ 74 unchanged lines | |||
| 132 | 137 | Ok(()) | |
| 133 | 138 | } | |
| 134 | 139 | ||
| 140 | + | /// Update a repository's detected languages and primary language. | |
| 141 | + | pub async fn update_languages( | |
| 142 | + | db: &toasty::Db, | |
| 143 | + | repo_id: i64, | |
| 144 | + | primary_language: &str, | |
| 145 | + | languages_json: &str, | |
| 146 | + | ) -> Result<()> { | |
| 147 | + | let mut conn = db.clone(); | |
| 148 | + | let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id)) | |
| 149 | + | .first() | |
| 150 | + | .exec(&mut conn) | |
| 151 | + | .await? | |
| 152 | + | else { | |
| 153 | + | return Err(Error::NotFound(format!("repository id {repo_id}"))); | |
| 154 | + | }; | |
| 155 | + | let mut conn = db.clone(); | |
| 156 | + | repo.update() | |
| 157 | + | .primary_language(primary_language) | |
| 158 | + | .languages_json(languages_json) | |
| 159 | + | .exec(&mut conn) | |
| 160 | + | .await?; | |
| 161 | + | Ok(()) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | /// Update a repository's preview image hash. | |
| 165 | + | pub async fn update_preview_image(db: &toasty::Db, repo_id: i64, hash: &str) -> Result<()> { | |
| 166 | + | let mut conn = db.clone(); | |
| 167 | + | let Some(mut repo) = Repository::filter(Repository::fields().id().eq(repo_id)) | |
| 168 | + | .first() | |
| 169 | + | .exec(&mut conn) | |
| 170 | + | .await? | |
| 171 | + | else { | |
| 172 | + | return Err(Error::NotFound(format!("repository id {repo_id}"))); | |
| 173 | + | }; | |
| 174 | + | let mut conn = db.clone(); | |
| 175 | + | repo.update() | |
| 176 | + | .preview_image_hash(hash) | |
| 177 | + | .exec(&mut conn) | |
| 178 | + | .await?; | |
| 179 | + | Ok(()) | |
| 180 | + | } | |
| 181 | + | ||
| 135 | 182 | /// Find a repository by owner id and name. | |
| 136 | 183 | pub async fn find(db: &toasty::Db, owner_id: i64, name: &str) -> Result<Option<Repository>> { | |
| 137 | 184 | let mut db = db.clone(); | |
| ⋯ 32 unchanged lines | |||
| 170 | 217 | description: r.description, | |
| 171 | 218 | is_private: r.is_private, | |
| 172 | 219 | default_branch: r.default_branch, | |
| 220 | + | primary_language: r.primary_language, | |
| 221 | + | preview_image_hash: r.preview_image_hash, | |
| 173 | 222 | }) | |
| 174 | 223 | .collect(); | |
| 175 | 224 | out.sort_by(|a, b| (&a.owner, &a.name).cmp(&(&b.owner, &b.name))); | |
| ⋯ 70 unchanged lines | |||
modifiedcrates/anvil-core/src/usage.rs+7 −2
| ⋯ 6 unchanged lines | |||
| 7 | 7 | ||
| 8 | 8 | use std::collections::BTreeMap; | |
| 9 | 9 | ||
| 10 | + | use serde::{ | |
| 11 | + | Deserialize, | |
| 12 | + | Serialize, | |
| 13 | + | }; | |
| 14 | + | ||
| 10 | 15 | use crate::{ | |
| 11 | 16 | App, | |
| 12 | 17 | error::Result, | |
| ⋯ 5 unchanged lines | |||
| 18 | 23 | }; | |
| 19 | 24 | ||
| 20 | 25 | /// One user's disk usage, split by content type (bytes). | |
| 21 | - | #[derive(Clone, Debug, Default)] | |
| 26 | + | #[derive(Clone, Debug, Default, Deserialize, Serialize)] | |
| 22 | 27 | pub struct UserUsage { | |
| 23 | 28 | pub username: String, | |
| 24 | 29 | pub git: u64, | |
| ⋯ 8 unchanged lines | |||
| 33 | 38 | } | |
| 34 | 39 | ||
| 35 | 40 | /// Site-wide disk usage: per-user rows (largest first) plus column totals. | |
| 36 | - | #[derive(Clone, Debug, Default)] | |
| 41 | + | #[derive(Clone, Debug, Default, Deserialize, Serialize)] | |
| 37 | 42 | pub struct Usage { | |
| 38 | 43 | pub per_user: Vec<UserUsage>, | |
| 39 | 44 | pub git: u64, | |
| ⋯ 59 unchanged lines | |||
modifiedcrates/anvil-web/src/admin.rs+21 −4
| ⋯ 33 unchanged lines | |||
| 34 | 34 | ||
| 35 | 35 | /// `GET /-/admin/usage` — site-wide disk usage by user and content type. | |
| 36 | 36 | /// Non-admins (including anonymous) get a 404, so the page's existence isn't | |
| 37 | - | /// leaked. | |
| 37 | + | /// leaked. Tries to use cached value first; computes on-demand if not found. | |
| 38 | 38 | async fn usage_page(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response { | |
| 39 | 39 | if !user.as_ref().is_some_and(|u| u.is_admin) { | |
| 40 | 40 | return not_found("not found"); | |
| 41 | 41 | } | |
| 42 | - | let data = match usage::compute(&app).await { | |
| 43 | - | Ok(d) => d, | |
| 44 | - | Err(e) => return server_error(e), | |
| 42 | + | ||
| 43 | + | // Try to get cached disk usage first | |
| 44 | + | let data = if let Ok(Some(cached)) = anvil_core::admin_cache::get(&app.db, "disk_usage").await { | |
| 45 | + | match serde_json::from_str(&cached.value) { | |
| 46 | + | Ok(d) => d, | |
| 47 | + | Err(_) => { | |
| 48 | + | // Cache corrupted, recompute | |
| 49 | + | match usage::compute(&app).await { | |
| 50 | + | Ok(d) => d, | |
| 51 | + | Err(e) => return server_error(e), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } else { | |
| 56 | + | // No cached value, compute on demand | |
| 57 | + | match usage::compute(&app).await { | |
| 58 | + | Ok(d) => d, | |
| 59 | + | Err(e) => return server_error(e), | |
| 60 | + | } | |
| 45 | 61 | }; | |
| 62 | + | ||
| 46 | 63 | layout("Disk usage", user.as_ref(), render(&data)).into_response() | |
| 47 | 64 | } | |
| 48 | 65 | ||
| ⋯ 36 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+62 −0
| ⋯ 536 unchanged lines | |||
| 537 | 537 | "/" | |
| 538 | 538 | a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) } | |
| 539 | 539 | @if r.is_private { " " span.pill { "private" } } | |
| 540 | + | @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } } | |
| 540 | 541 | } | |
| 541 | 542 | @if !r.description.is_empty() { div.muted { (r.description) } } | |
| 542 | 543 | } | |
| ⋯ 41 unchanged lines | |||
| 584 | 585 | div.name { | |
| 585 | 586 | a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) } | |
| 586 | 587 | @if r.is_private { " " span.pill { "private" } } | |
| 588 | + | @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } } | |
| 587 | 589 | } | |
| 588 | 590 | @if !r.description.is_empty() { div.muted { (r.description) } } | |
| 589 | 591 | } | |
| ⋯ 607 unchanged lines | |||
| 1197 | 1199 | } | |
| 1198 | 1200 | } | |
| 1199 | 1201 | (tree_table(&owner, &repo, &rev, "", &entries, &entry_commits)) | |
| 1202 | + | @if let Some(lang_bar) = render_languages_bar(&meta.languages_json) { | |
| 1203 | + | div.box { | |
| 1204 | + | div.readme-head { "Languages" } | |
| 1205 | + | div style="padding:8px 16px;" { (lang_bar) } | |
| 1206 | + | } | |
| 1207 | + | } | |
| 1200 | 1208 | @if let Some((rendered, name)) = &readme { | |
| 1201 | 1209 | div.box.readme { | |
| 1202 | 1210 | div.readme-head { | |
| ⋯ 810 unchanged lines | |||
| 2013 | 2021 | PreEscaped(out) | |
| 2014 | 2022 | } | |
| 2015 | 2023 | ||
| 2024 | + | /// Render a language breakdown bar showing percentages of each detected language. | |
| 2025 | + | /// Displays as a horizontal bar with each language's proportion. | |
| 2026 | + | pub(crate) fn render_languages_bar(languages_json: &str) -> Option<Markup> { | |
| 2027 | + | if languages_json.is_empty() || languages_json == "[]" { | |
| 2028 | + | return None; | |
| 2029 | + | } | |
| 2030 | + | ||
| 2031 | + | // Parse the JSON array | |
| 2032 | + | let langs: Vec<serde_json::Value> = serde_json::from_str(languages_json).ok()?; | |
| 2033 | + | if langs.is_empty() { | |
| 2034 | + | return None; | |
| 2035 | + | } | |
| 2036 | + | ||
| 2037 | + | // Color palette for languages (simple heuristic) | |
| 2038 | + | let color_for_lang = |lang: &str| -> &'static str { | |
| 2039 | + | match lang { | |
| 2040 | + | "Rust" => "#CE422B", | |
| 2041 | + | "Python" => "#3776AB", | |
| 2042 | + | "JavaScript" => "#F7DF1E", | |
| 2043 | + | "TypeScript" => "#3178C6", | |
| 2044 | + | "Go" => "#00ADD8", | |
| 2045 | + | "Java" => "#007396", | |
| 2046 | + | "C++" => "#00599C", | |
| 2047 | + | "C#" => "#239120", | |
| 2048 | + | "Ruby" => "#CC342D", | |
| 2049 | + | "PHP" => "#777BB4", | |
| 2050 | + | "Markdown" => "#083FA1", | |
| 2051 | + | "HTML" => "#E34C26", | |
| 2052 | + | "CSS" => "#563D7C", | |
| 2053 | + | "SQL" => "#336791", | |
| 2054 | + | _ => "#999999", | |
| 2055 | + | } | |
| 2056 | + | }; | |
| 2057 | + | ||
| 2058 | + | let mut html = String::from( | |
| 2059 | + | r#"<div class="language-bar" style="display:flex;border-radius:4px;overflow:hidden;height:20px;background:#f0f0f0;">"#, | |
| 2060 | + | ); | |
| 2061 | + | for lang_obj in langs { | |
| 2062 | + | if let (Some(lang), Some(percent)) = ( | |
| 2063 | + | lang_obj.get("lang").and_then(|v| v.as_str()), | |
| 2064 | + | lang_obj.get("percent").and_then(|v| v.as_f64()), | |
| 2065 | + | ) { | |
| 2066 | + | let color = color_for_lang(lang); | |
| 2067 | + | html.push_str(&format!( | |
| 2068 | + | r#"<div style="width:{:.1}%;background-color:{};tooltip:'{}';height:100%" title="{}"></div>"#, | |
| 2069 | + | percent, color, lang, lang | |
| 2070 | + | )); | |
| 2071 | + | } | |
| 2072 | + | } | |
| 2073 | + | html.push_str("</div>"); | |
| 2074 | + | ||
| 2075 | + | Some(PreEscaped(html)) | |
| 2076 | + | } | |
| 2077 | + | ||
| 2016 | 2078 | /// How far back the per-entry "latest commit" walk looks. Entries last touched | |
| 2017 | 2079 | /// beyond this many commits just lose the annotation. | |
| 2018 | 2080 | const ENTRY_LOG_WALK: usize = 400; | |
| ⋯ 671 unchanged lines | |||