fc-server: add 'reproduce build' section to build template

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2c9f6951e9b6014a32140216367693de6a6a6964
This commit is contained in:
raf 2026-02-08 21:17:37 +03:00
commit 4597869213
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
5 changed files with 186 additions and 22 deletions

View file

@ -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<String>,
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<BuildView>,
running_builds: Vec<BuildView>,
pending_builds: Vec<QueueBuildView>,
running_builds: Vec<QueueBuildView>,
pending_count: i64,
running_count: i64,
}
@ -335,11 +348,25 @@ struct ChannelsTemplate {
channels: Vec<Channel>,
}
/// 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<RemoteBuilder>,
builders: Vec<BuilderView>,
api_keys: Vec<ApiKeyView>,
is_admin: bool,
auth_name: String,
@ -936,12 +963,61 @@ async fn queue_page(State(state): State<AppState>) -> Html<String> {
.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<Uuid, String> =
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<QueueBuildView> = 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<QueueBuildView> = 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<AppState>) -> Html<String> {
)
}
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<AppState>) -> Html<String> {
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<Uuid, i64> =
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<BuilderView> = 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

View file

@ -128,12 +128,14 @@ async fn list_project_jobsets(
#[derive(Debug, Deserialize)]
struct CreateJobsetBody {
name: String,
nix_expression: String,
enabled: Option<bool>,
flake_mode: Option<bool>,
check_interval: Option<i32>,
state: Option<fc_common::models::JobsetState>,
name: String,
nix_expression: String,
enabled: Option<bool>,
flake_mode: Option<bool>,
check_interval: Option<i32>,
branch: Option<String>,
scheduling_shares: Option<i32>,
state: Option<fc_common::models::JobsetState>,
}
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

View file

@ -184,10 +184,20 @@ async fn advanced_search_handler(
) -> Result<Json<SearchResponse>, 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

View file

@ -53,6 +53,22 @@
<p><a href="{{ build.log_url }}">View log</a></p>
{% endif %}
<h2>Reproduce This Build</h2>
<div class="card">
<div class="card-body">
<p>To reproduce this build locally, run one of the following commands:</p>
<div class="code-block">
<strong>Using Nix (flakes):</strong>
<pre><code>nix build {{ build.drv_path }}^*</code></pre>
</div>
<div class="code-block">
<strong>Using legacy nix-build:</strong>
<pre><code>nix-build {{ build.drv_path }}</code></pre>
</div>
<p class="text-muted">Note: You may need to add this server as a substituter to avoid rebuilding dependencies.</p>
</div>
</div>
<h2>Build Steps</h2>
{% if steps.is_empty() %}
<div class="empty">No steps recorded.</div>

View file

@ -19,14 +19,16 @@
<div class="table-wrap">
<table>
<thead>
<tr><th>Job</th><th>System</th><th>Started</th></tr>
<tr><th>Job</th><th>System</th><th>Started</th><th>Elapsed</th><th>Builder</th></tr>
</thead>
<tbody>
{% for b in running_builds %}
<tr>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td>{{ b.system }}</td>
<td>{{ b.created_at }}</td>
<td>{{ b.started_at }}</td>
<td>{{ b.elapsed }}</td>
<td>{% match b.builder_name %}{% when Some with (name) %}{{ name }}{% when None %}local{% endmatch %}</td>
</tr>
{% endfor %}
</tbody>
@ -44,13 +46,15 @@
<div class="table-wrap">
<table>
<thead>
<tr><th>Job</th><th>System</th><th>Created</th></tr>
<tr><th>#</th><th>Job</th><th>System</th><th>Priority</th><th>Created</th></tr>
</thead>
<tbody>
{% for b in pending_builds %}
<tr>
<td>{{ b.queue_pos }}</td>
<td><a href="/build/{{ b.id }}">{{ b.job_name }}</a></td>
<td>{{ b.system }}</td>
<td>{{ b.priority }}</td>
<td>{{ b.created_at }}</td>
</tr>
{% endfor %}