anvilsign in

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
156156 version = "0.0.0"
157157 dependencies = [
158158 "argon2 0.5.3",
159+ "async-trait",
159160 "gix",
160161 "hmac 0.12.1",
162+ "pulldown-cmark",
161163 "rusqlite",
162164 "serde",
165+ "serde_json",
163166 "serde_yaml",
164167 "sha2 0.10.9",
165168 "ssh-key",
⋯ 5183 unchanged lines
modifiedCargo.toml+1 −0
⋯ 21 unchanged lines
2222
2323 anyhow = "1"
2424 argon2 = { version = "0.5", features = ["std"] }
25+async-trait = "0.1"
2526 axum = "0.8"
2627 axum-extra = { version = "0.10", features = ["cookie"] }
2728 base64 = "0.22"
⋯ 53 unchanged lines
modifiedTODO.md+5 −0
⋯ 22 unchanged lines
2323 is no repo-delete path yet (only the create-rollback uses it)
2424 - [ ] admin usage: per-repo drill-down, and a cheap cached/periodic variant if
2525 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
2631 - [ ] API tokens: a `write` scope (would need CSRF-exempt write paths) and
2732 `last_used_at` tracking
modifiedcrates/anvil-cli/src/main.rs+20 −0
⋯ 147 unchanged lines
148148 app.ci_tx = Some(ci_tx);
149149 tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx));
150150
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+
151171 if app.config.ssh.enabled {
152172 // Run the HTTP and SSH servers concurrently; if either exits, stop.
153173 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
88 description = "Domain model, persistence, and on-disk repository storage for the anvil git forge."
99
1010 [dependencies]
11+async-trait.workspace = true
1112 gix.workspace = true
1213 toasty.workspace = true
1314 rusqlite.workspace = true
⋯ 2 unchanged lines
1617 sha2.workspace = true
1718 ssh-key.workspace = true
1819 serde.workspace = true
20+serde_json.workspace = true
1921 serde_yaml.workspace = true
2022 toml.workspace = true
2123 thiserror.workspace = true
2224 tracing.workspace = true
2325 tokio.workspace = true
26+pulldown-cmark.workspace = true
2427
2528 [dev-dependencies]
2629 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
2929 pub ssh: SshConfig,
3030 /// Continuous-deployment settings (the single-repo redeploy webhook).
3131 pub ci: CiConfig,
32+ /// Periodic background job settings.
33+ pub periodic: PeriodicConfig,
3234 }
3335
3436 /// CI configuration: job sandbox limits and the single-repo redeploy webhook.
⋯ 97 unchanged lines
132134 pub clone_user: String,
133135 }
134136
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+
135151 impl Default for Config {
136152 fn default() -> Self {
137153 Self {
⋯ 1 unchanged line
139155 http: HttpConfig::default(),
140156 ssh: SshConfig::default(),
141157 ci: CiConfig::default(),
158+ periodic: PeriodicConfig::default(),
142159 }
143160 }
144161 }
⋯ 67 unchanged lines
212229 }
213230 }
214231
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+
215242 impl Config {
216243 /// Load configuration from a TOML file. Missing fields fall back to defaults.
217244 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
⋯ 99 unchanged lines
modifiedcrates/anvil-core/src/db.rs+32 −6
⋯ 4 unchanged lines
55 use crate::{
66 error::Result,
77 models::{
8+ AdminCache,
89 ApiToken,
910 Attachment,
1011 CiArtifact,
⋯ 32 unchanged lines
4344 Issue,
4445 IssueComment,
4546 Attachment,
46- ApiToken
47+ ApiToken,
48+ AdminCache
4749 ))
4850 .connect(&url)
4951 .await?;
⋯ 23 unchanged lines
7375 API_TOKENS_DDL,
7476 r#"CREATE INDEX IF NOT EXISTS "index_api_tokens_by_user_id" ON "api_tokens" ("user_id")"#,
7577 r#"CREATE UNIQUE INDEX IF NOT EXISTS "index_api_tokens_by_token_hash" ON "api_tokens" ("token_hash")"#,
78+ ADMIN_CACHE_DDL,
7679 ];
7780
7881 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 43 unchanged lines
122125 "scopes" TEXT NOT NULL,
123126 "created_at" BIGINT NOT NULL )"#;
124127
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+
125134 /// Columns added to existing tables after deployment, applied as
126135 /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
127136 /// for columns, so presence is checked via `pragma_table_info`). The model
128137 /// must declare the field *last* so fresh and migrated column orders agree.
129138 /// The `DEFAULT` backfills existing rows; Toasty's fresh DDL omits it, which
130139 /// 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+];
136162
137163 /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses
138164 /// 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
2626
2727 #[error(transparent)]
2828 Io(#[from] std::io::Error),
29+
30+ #[error("JSON error: {0}")]
31+ Json(#[from] serde_json::Error),
2932 }
3033
3134 /// 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
66 //! `anvil-git`) build on top of it.
77
88 pub mod access;
9+pub mod admin_cache;
910 pub mod api_tokens;
1011 pub mod attachments;
1112 pub mod ci;
⋯ 1 unchanged line
1314 pub mod db;
1415 pub mod error;
1516 pub mod issues;
17+pub mod language;
1618 pub mod models;
19+pub mod periodic;
20+pub mod preview_images;
1721 pub mod repos;
1822 pub mod sessions;
1923 pub mod ssh_keys;
⋯ 115 unchanged lines
modifiedcrates/anvil-core/src/models.rs+20 −0
⋯ 39 unchanged lines
4040 /// columns go last so `ALTER TABLE ADD COLUMN` on existing databases
4141 /// agrees with the fresh-schema column order.
4242 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,
4349 }
4450
4551 /// A CI run for a pushed commit.
⋯ 162 unchanged lines
208214 pub content: String,
209215 pub created_at: i64,
210216 }
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., `![alt](url)` → `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
2222 pub description: String,
2323 pub is_private: bool,
2424 pub default_branch: String,
25+ pub primary_language: String,
26+ pub preview_image_hash: String,
2527 }
2628
2729 /// Create a repository owned by `owner`: insert the database row and initialize
⋯ 24 unchanged lines
5254 default_branch: "main",
5355 created_at: crate::now(),
5456 mirror_url: "",
57+ preview_image_hash: "",
58+ primary_language: "",
59+ languages_json: "",
5560 })
5661 .exec(&mut conn)
5762 .await?;
⋯ 74 unchanged lines
132137 Ok(())
133138 }
134139
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+
135182 /// Find a repository by owner id and name.
136183 pub async fn find(db: &toasty::Db, owner_id: i64, name: &str) -> Result<Option<Repository>> {
137184 let mut db = db.clone();
⋯ 32 unchanged lines
170217 description: r.description,
171218 is_private: r.is_private,
172219 default_branch: r.default_branch,
220+ primary_language: r.primary_language,
221+ preview_image_hash: r.preview_image_hash,
173222 })
174223 .collect();
175224 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
77
88 use std::collections::BTreeMap;
99
10+use serde::{
11+ Deserialize,
12+ Serialize,
13+};
14+
1015 use crate::{
1116 App,
1217 error::Result,
⋯ 5 unchanged lines
1823 };
1924
2025 /// One user's disk usage, split by content type (bytes).
21-#[derive(Clone, Debug, Default)]
26+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
2227 pub struct UserUsage {
2328 pub username: String,
2429 pub git: u64,
⋯ 8 unchanged lines
3338 }
3439
3540 /// Site-wide disk usage: per-user rows (largest first) plus column totals.
36-#[derive(Clone, Debug, Default)]
41+#[derive(Clone, Debug, Default, Deserialize, Serialize)]
3742 pub struct Usage {
3843 pub per_user: Vec<UserUsage>,
3944 pub git: u64,
⋯ 59 unchanged lines
modifiedcrates/anvil-web/src/admin.rs+21 −4
⋯ 33 unchanged lines
3434
3535 /// `GET /-/admin/usage` — site-wide disk usage by user and content type.
3636 /// 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.
3838 async fn usage_page(State(app): State<App>, CurrentUser(user): CurrentUser) -> Response {
3939 if !user.as_ref().is_some_and(|u| u.is_admin) {
4040 return not_found("not found");
4141 }
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+ }
4561 };
62+
4663 layout("Disk usage", user.as_ref(), render(&data)).into_response()
4764 }
4865
⋯ 36 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+62 −0
⋯ 536 unchanged lines
537537 "/"
538538 a href=(format!("/{}/{}", r.owner, r.name)) { (r.name) }
539539 @if r.is_private { " " span.pill { "private" } }
540+ @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } }
540541 }
541542 @if !r.description.is_empty() { div.muted { (r.description) } }
542543 }
⋯ 41 unchanged lines
584585 div.name {
585586 a href=(format!("/{}/{}", owner.username, r.name)) { (r.name) }
586587 @if r.is_private { " " span.pill { "private" } }
588+ @if !r.primary_language.is_empty() { " " span.pill.language { (r.primary_language) } }
587589 }
588590 @if !r.description.is_empty() { div.muted { (r.description) } }
589591 }
⋯ 607 unchanged lines
11971199 }
11981200 }
11991201 (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+ }
12001208 @if let Some((rendered, name)) = &readme {
12011209 div.box.readme {
12021210 div.readme-head {
⋯ 810 unchanged lines
20132021 PreEscaped(out)
20142022 }
20152023
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+
20162078 /// How far back the per-entry "latest commit" walk looks. Entries last touched
20172079 /// beyond this many commits just lose the annotation.
20182080 const ENTRY_LOG_WALK: usize = 400;
⋯ 671 unchanged lines