From 4597869213aa7ec40147ebd433f49be98eb5f969 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 8 Feb 2026 21:17:37 +0300 Subject: [PATCH] fc-server: add 'reproduce build' section to build template Signed-off-by: NotAShelf Change-Id: I2c9f6951e9b6014a32140216367693de6a6a6964 --- crates/server/src/routes/dashboard.rs | 146 ++++++++++++++++++++++++-- crates/server/src/routes/projects.rs | 18 ++-- crates/server/src/routes/search.rs | 18 +++- crates/server/templates/build.html | 16 +++ crates/server/templates/queue.html | 10 +- 5 files changed, 186 insertions(+), 22 deletions(-) diff --git a/crates/server/src/routes/dashboard.rs b/crates/server/src/routes/dashboard.rs index 4f10df6..0c073b0 100644 --- a/crates/server/src/routes/dashboard.rs +++ b/crates/server/src/routes/dashboard.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse, Redirect, Response}, routing::get, }; -use fc_common::models::{Build, Evaluation, BuildStatus, EvaluationStatus, ApiKey, Project, Jobset, BuildStep, BuildProduct, Channel, SystemStatus, RemoteBuilder}; +use fc_common::models::{Build, Evaluation, BuildStatus, EvaluationStatus, ApiKey, Project, Jobset, BuildStep, BuildProduct, Channel, SystemStatus}; use sha2::{Digest, Sha256}; use uuid::Uuid; @@ -34,6 +34,19 @@ struct BuildView { log_url: String, } +/// Enhanced build view for queue page with elapsed time and builder info +struct QueueBuildView { + id: Uuid, + job_name: String, + system: String, + created_at: String, + started_at: String, + elapsed: String, + priority: i32, + builder_name: Option, + queue_pos: i64, +} + struct EvalView { id: Uuid, commit_hash: String, @@ -323,8 +336,8 @@ struct BuildTemplate { #[derive(Template)] #[template(path = "queue.html")] struct QueueTemplate { - pending_builds: Vec, - running_builds: Vec, + pending_builds: Vec, + running_builds: Vec, pending_count: i64, running_count: i64, } @@ -335,11 +348,25 @@ struct ChannelsTemplate { channels: Vec, } +/// Enhanced builder view with load and activity info +struct BuilderView { + id: Uuid, + name: String, + ssh_uri: String, + systems: String, + max_jobs: i32, + enabled: bool, + current_builds: i64, + load_percent: i64, + #[allow(dead_code)] + last_activity: String, +} + #[derive(Template)] #[template(path = "admin.html")] struct AdminTemplate { status: SystemStatus, - builders: Vec, + builders: Vec, api_keys: Vec, is_admin: bool, auth_name: String, @@ -936,12 +963,61 @@ async fn queue_page(State(state): State) -> Html { .await .unwrap_or_default(); + // Build builder ID -> name map + let builders = fc_common::repo::remote_builders::list(&state.pool) + .await + .unwrap_or_default(); + let builder_map: std::collections::HashMap = + builders.into_iter().map(|b| (b.id, b.name)).collect(); + let running_count = running.len() as i64; let pending_count = pending.len() as i64; + // Convert running builds with elapsed time + let running_builds: Vec = running + .iter() + .map(|b| { + let elapsed = if let Some(started) = b.started_at { + let dur = chrono::Utc::now() - started; + format_elapsed(dur.num_seconds()) + } else { + String::new() + }; + let builder_name = b.builder_id.and_then(|id| builder_map.get(&id).cloned()); + QueueBuildView { + id: b.id, + job_name: b.job_name.clone(), + system: b.system.clone().unwrap_or_else(|| "unknown".to_string()), + created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(), + started_at: b.started_at.map(|t| t.format("%H:%M:%S").to_string()).unwrap_or_default(), + elapsed, + priority: b.priority, + builder_name, + queue_pos: 0, + } + }) + .collect(); + + // Convert pending builds with queue position + let pending_builds: Vec = pending + .iter() + .enumerate() + .map(|(idx, b)| QueueBuildView { + id: b.id, + job_name: b.job_name.clone(), + system: b.system.clone().unwrap_or_else(|| "unknown".to_string()), + created_at: b.created_at.format("%Y-%m-%d %H:%M").to_string(), + started_at: String::new(), + elapsed: String::new(), + priority: b.priority, + builder_name: None, + queue_pos: (idx + 1) as i64, + }) + .collect(); + let tmpl = QueueTemplate { - running_builds: running.iter().map(build_view).collect(), - pending_builds: pending.iter().map(build_view).collect(), + running_builds, + pending_builds, running_count, pending_count, }; @@ -952,6 +1028,16 @@ async fn queue_page(State(state): State) -> Html { ) } +fn format_elapsed(secs: i64) -> String { + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + async fn channels_page(State(state): State) -> Html { let channels = fc_common::repo::channels::list_all(&state.pool) .await @@ -1005,10 +1091,56 @@ async fn admin_page( remote_builders: builders_count, channels_count: channels.0, }; - let builders = fc_common::repo::remote_builders::list(pool) + let raw_builders = fc_common::repo::remote_builders::list(pool) .await .unwrap_or_default(); + // Get running builds to calculate builder load + let running_builds = fc_common::repo::builds::list_filtered( + pool, + None, + Some("running"), + None, + None, + 1000, + 0, + ) + .await + .unwrap_or_default(); + + // Count builds per builder + let mut builds_per_builder: std::collections::HashMap = + std::collections::HashMap::new(); + for build in &running_builds { + if let Some(builder_id) = build.builder_id { + *builds_per_builder.entry(builder_id).or_insert(0) += 1; + } + } + + // Convert to BuilderView with load info + let builders: Vec = raw_builders + .into_iter() + .map(|b| { + let current_builds = *builds_per_builder.get(&b.id).unwrap_or(&0); + let load_percent = if b.max_jobs > 0 { + (current_builds * 100) / (b.max_jobs as i64) + } else { + 0 + }; + BuilderView { + id: b.id, + name: b.name, + ssh_uri: b.ssh_uri, + systems: b.systems.join(", "), + max_jobs: b.max_jobs, + enabled: b.enabled, + current_builds, + load_percent, + last_activity: b.created_at.format("%Y-%m-%d").to_string(), + } + }) + .collect(); + // Fetch API keys for admin view let keys = fc_common::repo::api_keys::list(pool) .await diff --git a/crates/server/src/routes/projects.rs b/crates/server/src/routes/projects.rs index 189ad9a..965821f 100644 --- a/crates/server/src/routes/projects.rs +++ b/crates/server/src/routes/projects.rs @@ -128,12 +128,14 @@ async fn list_project_jobsets( #[derive(Debug, Deserialize)] struct CreateJobsetBody { - name: String, - nix_expression: String, - enabled: Option, - flake_mode: Option, - check_interval: Option, - state: Option, + name: String, + nix_expression: String, + enabled: Option, + flake_mode: Option, + check_interval: Option, + branch: Option, + scheduling_shares: Option, + state: Option, } async fn create_project_jobset( @@ -156,8 +158,8 @@ async fn create_project_jobset( enabled: body.enabled, flake_mode: body.flake_mode, check_interval: body.check_interval, - branch: None, - scheduling_shares: None, + branch: body.branch, + scheduling_shares: body.scheduling_shares, state: body.state, }; input diff --git a/crates/server/src/routes/search.rs b/crates/server/src/routes/search.rs index 20befea..75d72ff 100644 --- a/crates/server/src/routes/search.rs +++ b/crates/server/src/routes/search.rs @@ -184,10 +184,20 @@ async fn advanced_search_handler( ) -> Result, ApiError> { // Validate and sanitize let query = params.q.trim(); - if query.len() > 256 { - return Err(ApiError(fc_common::CiError::Validation( - "Search query too long (max 256 characters)".to_string(), - ))); + if query.is_empty() || query.len() > 256 { + // Empty or too long query returns empty results + return Ok(Json(SearchResponse { + projects: vec![], + jobsets: vec![], + evaluations: vec![], + builds: vec![], + total_projects: Some(0), + total_jobsets: Some(0), + total_evaluations: Some(0), + total_builds: Some(0), + limit: Some(params.limit.clamp(1, 100)), + offset: Some(params.offset), + })); } // Clamp limit to reasonable range diff --git a/crates/server/templates/build.html b/crates/server/templates/build.html index ff421cd..c0067b6 100644 --- a/crates/server/templates/build.html +++ b/crates/server/templates/build.html @@ -53,6 +53,22 @@

View log

{% endif %} +

Reproduce This Build

+
+
+

To reproduce this build locally, run one of the following commands:

+
+ Using Nix (flakes): +
nix build {{ build.drv_path }}^*
+
+
+ Using legacy nix-build: +
nix-build {{ build.drv_path }}
+
+

Note: You may need to add this server as a substituter to avoid rebuilding dependencies.

+
+
+

Build Steps

{% if steps.is_empty() %}
No steps recorded.
diff --git a/crates/server/templates/queue.html b/crates/server/templates/queue.html index 8e3af12..c0b9132 100644 --- a/crates/server/templates/queue.html +++ b/crates/server/templates/queue.html @@ -19,14 +19,16 @@
- + {% for b in running_builds %} - + + + {% endfor %} @@ -44,13 +46,15 @@
JobSystemStarted
JobSystemStartedElapsedBuilder
{{ b.job_name }} {{ b.system }}{{ b.created_at }}{{ b.started_at }}{{ b.elapsed }}{% match b.builder_name %}{% when Some with (name) %}{{ name }}{% when None %}local{% endmatch %}
- + {% for b in pending_builds %} + + {% endfor %}
JobSystemCreated
#JobSystemPriorityCreated
{{ b.queue_pos }} {{ b.job_name }} {{ b.system }}{{ b.priority }} {{ b.created_at }}