diff --git a/Cargo.lock b/Cargo.lock index b4f99019..98df66fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,6 +3360,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "inventory" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" +dependencies = [ + "rustversion", +] + [[package]] name = "io-close" version = "0.3.7" @@ -5514,6 +5523,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7802e424033acc38249ada210b380d9846243b2830e097163780569990f8dfe" dependencies = [ "futures-util", + "inventory", "serde", "tokio", "tokio-tungstenite", @@ -5612,6 +5622,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ccfde441afe9559296586891317bc8bd332bed8bfb4daaf78578d2f4f5f08ae" dependencies = [ "heck", + "inventory", "serde", "serde_json", "topcoat-asset", @@ -5676,6 +5687,7 @@ dependencies = [ "http-body-util", "hyper", "hyper-util", + "inventory", "ipnet", "matchit 0.9.2", "percent-encoding", diff --git a/Cargo.toml b/Cargo.toml index 18e63549..fd15656f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,6 +125,7 @@ tokio-util = { version = "0.7.19", features = [ "io", "compat" ] } toml = "1.1.6" topcoat = { version = "0.10.0", default-features = false, features = [ "asset", + "discover", "router", "runtime", "serve", diff --git a/crates/server/src/cli.rs b/crates/server/src/cli.rs index b34b01af..e6cca401 100644 --- a/crates/server/src/cli.rs +++ b/crates/server/src/cli.rs @@ -4,6 +4,7 @@ use std::{ffi::OsString, net::SocketAddr, path::PathBuf, sync::Arc}; use circus_common::Database; use circus_config::Config; use clap::Parser; +use color_eyre::eyre::WrapErr; use tokio::net::TcpListener; use crate::{ @@ -207,7 +208,10 @@ where // Drain in-memory cache-serving counters into the cache_traffic table. state.spawn_cache_traffic_flush(); - let app = routes::router(state, &config); + let app = routes::router(state, &config).wrap_err( + "loading dashboard assets, bundle them with --bundle-assets or set \ + ui.dashboard = false", + )?; let bind_addr = format!("{host}:{port}"); tracing::info!( diff --git a/crates/server/src/routes/dashboard/admin.rs b/crates/server/src/routes/dashboard/admin.rs index 750d49a5..75dc3b82 100644 --- a/crates/server/src/routes/dashboard/admin.rs +++ b/crates/server/src/routes/dashboard/admin.rs @@ -28,18 +28,15 @@ use super::{ shared::{ ApiKeyView, DashboardContext, - DashboardPage, LinkedIdentityView, PageError, Pagination, RenderExt, UserView, - enforce_page_access, }, templates::{ AdminTemplate, AgentView, - NewsTemplate, NotificationTaskView, NotificationsTemplate, PinnedOutputView, @@ -707,26 +704,6 @@ pub(super) async fn user_unlink( Redirect::to("/users").into_response() } -/// Render the news page at `/news`: list of recent announcements plus, -/// for admins, the form to publish a new one. -pub(super) async fn news_page( - State(state): State, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::News)?; - let items = circus_common::repo::news::list(&state.pool, 50, 0) - .await - .unwrap_or_default(); - let tmpl = NewsTemplate { - ui: ui_config(&state), - items, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - }; - tmpl.render_html_or_500() -} - #[derive(serde::Deserialize)] pub(super) struct NewsCreateForm { title: String, @@ -782,6 +759,28 @@ pub(super) async fn news_delete( Redirect::to("/news").into_response() } +/// `POST /starred/{id}/delete` removes a job from the viewer's own stars. +pub(super) async fn starred_delete( + State(state): State, + Path(id): Path, + ctx: DashboardContext, + Form(form): Form, +) -> Response { + let Some(user_id) = ctx.viewer_user_id else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + if let Err(e) = ctx.check_csrf(&form.csrf_token) { + return e.into_response(); + } + if let Err(e) = + circus_common::repo::starred_jobs::delete_for_user(&state.pool, user_id, id) + .await + { + tracing::warn!(id = %id, "Failed to unstar job: {e}"); + } + Redirect::to("/starred").into_response() +} + /// Form payload for `POST /project/{id}/notifications`: the kind of /// notification (webhook, email, ...) and a JSON blob holding the /// kind-specific configuration. diff --git a/crates/server/src/routes/dashboard/auth.rs b/crates/server/src/routes/dashboard/auth.rs index 82900966..cf2d5f85 100644 --- a/crates/server/src/routes/dashboard/auth.rs +++ b/crates/server/src/routes/dashboard/auth.rs @@ -5,13 +5,12 @@ //! `Secure` flag policy. Logout drops both legacy cookie names so users //! coming off an older session are fully cleaned up. -use askama::Template; use axum::{ Extension, Form, - extract::{Path, Query, State}, + extract::{Path, State}, http::StatusCode, - response::{Html, IntoResponse, Redirect, Response}, + response::{IntoResponse, Redirect, Response}, }; use circus_common::models::User; use data_encoding::HEXLOWER; @@ -22,14 +21,8 @@ use crate::{ routes::{ dashboard::{ admin::CsrfOnlyForm, - shared::{DashboardContext, RenderExt, UiTemplateConfig}, - templates::{ - AccountLinkTemplate, - AccountProvider, - AccountTemplate, - LinkStatus, - LoginTemplate, - }, + shared::DashboardContext, + views::account::SignInError, }, oidc, }, @@ -45,36 +38,6 @@ use crate::{ state::AppState, }; -#[derive(serde::Deserialize)] -pub(super) struct LoginQuery { - next: Option, - /// Show the page even when it would hand straight off to a provider, so an - /// API key can still be entered. - local: Option, -} - -pub(super) async fn login_page( - State(state): State, - Query(query): Query, -) -> Response { - let tmpl = - LoginTemplate::new(&state.config, None).with_next(query.next.as_deref()); - - if let [provider] = tmpl.providers.as_slice() - && !state.config.server.password_login - && query.local.is_none() - { - return Redirect::to(&provider.href).into_response(); - } - - Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ) - .into_response() -} - #[derive(serde::Deserialize)] pub(super) struct LoginForm { username: Option, @@ -95,20 +58,7 @@ pub(super) async fn login_action( (form.username.as_ref(), form.password.as_ref()) { if !state.config.server.password_login { - let tmpl = LoginTemplate::new( - &state.config, - Some("Password sign-in is disabled".into()), - ) - .with_next(form.next.as_deref()); - return ( - StatusCode::FORBIDDEN, - Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ), - ) - .into_response(); + return failed(SignInError::PasswordDisabled, &form); } let creds = circus_common::models::LoginCredentials { @@ -161,35 +111,14 @@ pub(super) async fn login_action( ) .await; - let tmpl = LoginTemplate::new( - &state.config, - Some("Invalid username or password".into()), - ) - .with_next(form.next.as_deref()); - return ( - StatusCode::UNAUTHORIZED, - Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ), - ) - .into_response(); + return failed(SignInError::InvalidPassword, &form); } // Fall back to API key authentication if let Some(token) = form.api_key.as_ref() { let token = token.trim(); if token.is_empty() { - let tmpl = - LoginTemplate::new(&state.config, Some("API key is required".into())) - .with_next(form.next.as_deref()); - return Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ) - .into_response(); + return failed(SignInError::MissingKey, &form); } let mut hasher = Sha256::new(); @@ -237,31 +166,17 @@ pub(super) async fn login_action( ) .await; - let tmpl = - LoginTemplate::new(&state.config, Some("Invalid API key".into())) - .with_next(form.next.as_deref()); - Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ) - .into_response() + failed(SignInError::InvalidKey, &form) } } else { - let tmpl = LoginTemplate::new( - &state.config, - Some("Please provide either username/password or API key".into()), - ) - .with_next(form.next.as_deref()); - Html( - tmpl - .render() - .unwrap_or_else(|e| format!("Template error: {e}")), - ) - .into_response() + failed(SignInError::MissingCredentials, &form) } } +fn failed(error: SignInError, form: &LoginForm) -> Response { + Redirect::to(&error.login_href(form.next.as_deref())).into_response() +} + pub(super) async fn logout_action( State(state): State, request: axum::extract::Request, @@ -307,95 +222,6 @@ pub(super) async fn logout_action( .into_response() } -pub(super) async fn account_page( - State(state): State, - ctx: DashboardContext, - user: Option>, -) -> Response { - let Some(Extension(user)) = user else { - return Redirect::to("/login?next=/account").into_response(); - }; - - let linked = match circus_common::repo::users::linked_providers( - &state.pool, - user.id, - ) - .await - { - Ok(linked) => linked, - Err(e) => { - tracing::error!(user_id = %user.id, "failed to list linked identities: {e}"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - }, - }; - let native_issuer = match circus_common::repo::users::native_external_id( - &state.pool, - user.id, - ) - .await - { - Ok(external_id) => { - external_id - .and_then(|id| id.rsplit_once('#').map(|(issuer, _)| issuer.to_owned())) - }, - Err(e) => { - tracing::error!(user_id = %user.id, "failed to read account identity: {e}"); - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); - }, - }; - let providers = state - .config - .oauth - .oidc - .iter() - .map(|(name, provider)| { - let native = native_issuer.as_deref().is_some_and(|issuer| { - issuer.trim_end_matches('/') - == provider.issuer_url.trim_end_matches('/') - }); - - AccountProvider { - name: name.clone(), - label: provider.display_name.clone(), - status: if native { - LinkStatus::Native - } else if linked.contains(name) { - LinkStatus::Linked - } else { - LinkStatus::Unlinked - }, - } - }) - .collect(); - - AccountTemplate { - ui: UiTemplateConfig::from_config(&state.config.ui), - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - role: user.role.to_string(), - has_password: state.config.server.password_login - && user.password_hash.is_some(), - username: user.username, - providers, - } - .render_html_or_500() - .into_response() -} - -pub(super) async fn account_link_page( - State(state): State, - Path(provider): Path, - ctx: DashboardContext, - user: Option>, -) -> Response { - let Some(Extension(user)) = user else { - return Redirect::to("/login").into_response(); - }; - - render_link_page(&state, &ctx, &user, &provider, None) -} - #[derive(serde::Deserialize)] pub(super) struct LinkForm { csrf_token: String, @@ -419,6 +245,10 @@ pub(super) async fn account_link( return e.into_response(); } + if !state.config.oauth.oidc.contains_key(&provider) { + return StatusCode::NOT_FOUND.into_response(); + } + if let Some(hash) = &user.password_hash { let password = form.password.as_deref().unwrap_or_default(); @@ -436,13 +266,10 @@ pub(super) async fn account_link( ) .await; - return render_link_page( - &state, - &ctx, - &user, - &provider, - Some("Incorrect password"), - ); + return Redirect::to(&format!( + "/account/link/{provider}?error=incorrect-password" + )) + .into_response(); }, Err(e) => { tracing::error!(user_id = %user.id, "failed to verify password: {e}"); @@ -454,38 +281,6 @@ pub(super) async fn account_link( oidc::start_link(&state, &provider, user.id).await } -fn render_link_page( - state: &AppState, - ctx: &DashboardContext, - user: &User, - provider: &str, - error: Option<&str>, -) -> Response { - let Some(config) = state.config.oauth.oidc.get(provider) else { - return StatusCode::NOT_FOUND.into_response(); - }; - - let page = AccountLinkTemplate { - ui: UiTemplateConfig::from_config(&state.config.ui), - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - username: user.username.clone(), - name: provider.to_owned(), - label: config.display_name.clone(), - has_password: user.password_hash.is_some(), - error: error.map(str::to_owned), - } - .render_html_or_500(); - let status = if error.is_some() { - StatusCode::UNAUTHORIZED - } else { - StatusCode::OK - }; - - (status, page).into_response() -} - pub(super) async fn account_unlink( State(state): State, Path(provider): Path, diff --git a/crates/server/src/routes/dashboard/components.rs b/crates/server/src/routes/dashboard/components.rs new file mode 100644 index 00000000..577ca80f --- /dev/null +++ b/crates/server/src/routes/dashboard/components.rs @@ -0,0 +1,98 @@ +//! Small views shared by every Topcoat page. + +use jiff::{Timestamp, tz::TimeZone}; +use serde::de::{ + DeserializeOwned, + IntoDeserializer, + value::Error as ValueError, +}; +use topcoat::{ + Result, + context::Cx, + runtime::{expr, signal}, + view::{View, component, view}, +}; + +/// A result code from a redirect's query, ignoring codes a stale link carries. +pub fn result_code(code: Option<&str>) -> Option { + code.and_then(|code| { + T::deserialize(IntoDeserializer::::into_deserializer(code)).ok() + }) +} + +/// UTC until the runtime loads. Text expressions skip their first browser +/// run, so the local time goes through an ``'s bound `value`. `zone` +/// names the viewer's timezone where the exact moment matters. +#[component] +pub async fn local_time( + cx: &Cx, + at: Timestamp, + zone: bool, +) -> Result { + let browser = signal(cx, || true); + let millis = at.as_millisecond(); + let this_year = at.to_zoned(TimeZone::UTC).year() + == Timestamp::now().to_zoned(TimeZone::UTC).year(); + let utc = if this_year { + at.strftime("%b %-d, %H:%M UTC") + } else { + at.strftime("%b %-d, %Y, %H:%M UTC") + } + .to_string(); + let full_utc = at.strftime("%A, %B %-d, %Y at %H:%M:%S UTC").to_string(); + let local = expr!({ + let _bound = browser.get(); + raw!( + "(() => { const at = new Date(Number(${millis})); const opts = {month: \ + 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'}; if \ + (at.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; \ + if (${zone}) opts.timeZoneName = 'short'; return \ + at.toLocaleString(undefined, opts); })()", + utc.clone() + ) + }); + let full = expr!({ + let _bound = browser.get(); + raw!( + "new Date(Number(${millis})).toLocaleString(undefined, {dateStyle: \ + 'full', timeStyle: 'long'})", + full_utc.clone() + ) + }); + + Ok(view! { + + }) +} + +#[component] +pub async fn confirm_button( + prompt: &str, + class: &str, + label: &str, +) -> Result { + Ok(view! { + + }) +} + +#[component] +pub async fn copy_button(text: &str) -> Result { + Ok(view! { + + }) +} diff --git a/crates/server/src/routes/dashboard/layout.rs b/crates/server/src/routes/dashboard/layout.rs index f9caccb1..0711c0c7 100644 --- a/crates/server/src/routes/dashboard/layout.rs +++ b/crates/server/src/routes/dashboard/layout.rs @@ -1,6 +1,7 @@ //! The dashboard shell and access checks shared by every Topcoat page. use axum::http::Extensions; +use circus_common::models::User; use circus_config::PageAccessLevel; use topcoat::{ Result, @@ -109,9 +110,17 @@ fn allowed(cx: &Cx, viewer: &DashboardContext, page: DashboardPage) -> bool { } async fn session(cx: &Cx) -> DashboardContext { + signed_in(cx).await.0 +} + +/// The viewer and their user row, for pages outside the page access table. +pub async fn signed_in(cx: &Cx) -> (DashboardContext, Option) { let state = app_context::(cx); let session = session_extensions(state, request::headers(cx)).await; - DashboardContext::from_extensions(&session) + ( + DashboardContext::from_extensions(&session), + session.get::().cloned(), + ) } fn login_href(cx: &Cx) -> String { @@ -175,6 +184,10 @@ pub async fn document( cx: &Cx, title: &str, viewer: &DashboardContext, + /// Leaves out the sign-in controls, for the login page itself. + #[default] + hide_auth: bool, + #[default(true)] topbar: bool, child: Child<'_>, ) -> Result { let state = app_context::(cx); @@ -188,7 +201,9 @@ pub async fn document(
sidebar(ui: &ui, viewer: viewer)
- topbar(viewer: viewer) + if topbar { + header(viewer: viewer, hide_auth: hide_auth) + }
(child)
@@ -271,7 +286,11 @@ async fn sidebar( } #[component] -async fn topbar(cx: &Cx, viewer: &DashboardContext) -> Result { +async fn header( + cx: &Cx, + viewer: &DashboardContext, + hide_auth: bool, +) -> Result { let login = login_href(cx); Ok(view! { @@ -291,13 +310,15 @@ async fn topbar(cx: &Cx, viewer: &DashboardContext) -> Result { diff --git a/crates/server/src/routes/dashboard/live.rs b/crates/server/src/routes/dashboard/live.rs index 5b13c8c5..efaeabf1 100644 --- a/crates/server/src/routes/dashboard/live.rs +++ b/crates/server/src/routes/dashboard/live.rs @@ -8,14 +8,20 @@ use topcoat::{ Result, asset::{AssetBundle, RouterBuilderAssetExt}, context::{Cx, app_context}, - router::{Router, page, query_params, tower::TowerService}, + router::{ + Router, + RouterBuilderDiscoverExt, + page, + query_params, + tower::TowerService, + }, runtime::{RouterBuilderRuntimeExt, connected, shard}, view::{Child, View, component, emit, live, view}, }; use uuid::Uuid; use super::{ - layout::{document, root, shard_viewer, viewer}, + layout::{document, shard_viewer, viewer}, pages::{Queue, QueueFilter, elapsed_since, load_queue}, shared::{DashboardContext, DashboardPage}, }; @@ -100,9 +106,7 @@ pub fn service(state: AppState, assets: AssetBundle) -> TowerService { let router = Router::builder() .app_context(state) .app_context(BuildsChanged(receiver)) - .layout(root) - .page(queue) - .route(queue_tables) + .discover() .assets(assets) .runtime() .build(); diff --git a/crates/server/src/routes/dashboard/mod.rs b/crates/server/src/routes/dashboard/mod.rs index 9e4a06a9..3fb74ea4 100644 --- a/crates/server/src/routes/dashboard/mod.rs +++ b/crates/server/src/routes/dashboard/mod.rs @@ -12,8 +12,9 @@ use axum::{ Router, - routing::{get, post}, + routing::{get, get_service, post}, }; +use topcoat::router::tower::TowerService; use crate::state::AppState; @@ -21,24 +22,29 @@ mod admin; pub mod assets; mod auth; mod build_log; +mod components; mod layout; pub mod live; mod pages; mod preview; mod shared; pub(crate) mod templates; +pub(crate) mod views; -pub fn router() -> Router { +/// Topcoat pages route their GETs to `live`, which also serves the runtime +/// under `/_topcoat`. +pub fn router(live: TowerService) -> Router { Router::new() - .route("/login", get(auth::login_page).post(auth::login_action)) + .route("/queue", get_service(live.clone())) + .route("/login", get_service(live.clone()).post(auth::login_action)) .route("/logout", post(auth::logout_action)) - .route("/account", get(auth::account_page)) + .route("/account", get_service(live.clone())) .route( "/account/link/{provider}", - get(auth::account_link_page).post(auth::account_link), + get_service(live.clone()).post(auth::account_link), ) .route("/account/unlink/{provider}", post(auth::account_unlink)) - .route("/", get(pages::home)) + .route("/", get_service(live.clone())) .route("/projects", get(pages::projects_page)) .route("/projects/new", get(pages::project_setup_page)) .route("/project/{id}", get(pages::project_page)) @@ -50,35 +56,37 @@ pub fn router() -> Router { "/project/{id}/notifications/{config_id}/delete", post(admin::notifications_delete), ) - .route("/jobset/{id}", get(pages::jobset_page)) - .route("/jobset/{id}/jobs", get(pages::jobset_jobs_page)) + .route("/jobset/{id}", get_service(live.clone())) + .route("/jobset/{id}/jobs", get_service(live.clone())) .route("/jobset/{id}/delete", post(admin::jobset_delete)) - .route("/evaluations", get(pages::evaluations_page)) - .route("/evaluation/{id}", get(pages::evaluation_page)) + .route("/evaluations", get_service(live.clone())) + .route("/evaluation/{id}", get_service(live.clone())) .route( "/evaluation/{id}/visibility", post(admin::evaluation_visibility), ) .route("/evaluation/{id}/cancel", post(admin::evaluation_cancel)) .route("/evaluation/{id}/restart", post(admin::evaluation_restart)) - .route("/builds", get(pages::builds_page)) - .route("/build/{id}", get(pages::build_page)) - .route("/build/{id}/log", get(pages::build_log)) + .route("/builds", get_service(live.clone())) + .route("/build/{id}", get_service(live.clone())) + .route("/build/{id}/log", get_service(live.clone())) .route("/build/{id}/bump", post(admin::queue_bump)) - .route("/channels", get(pages::channels_page)) - .route("/channel/{id}", get(pages::channel_page)) - .route("/news", get(admin::news_page).post(admin::news_create)) + .route("/channels", get_service(live.clone())) + .route("/channel/{id}", get_service(live.clone())) + .route("/news", get_service(live.clone()).post(admin::news_create)) .route("/news/{id}/delete", post(admin::news_delete)) .route("/admin", get(admin::admin_page)) .route("/admin/store-gc", post(admin::store_gc)) .route("/users", get(admin::users_page)) .route("/users/{id}/unlink/{provider}", post(admin::user_unlink)) - .route("/starred", get(pages::starred_page)) + .route("/starred", get_service(live.clone())) + .route("/starred/{id}/delete", post(admin::starred_delete)) .route("/metrics", get(pages::metrics_page)) - .route("/caches", get(pages::caches_page)) + .route("/caches", get_service(live.clone())) .route("/caches/{name}", get(pages::cache_detail_page)) .route("/caches/{name}/gc", post(admin::cache_gc)) - .route("/caches/{name}/nars", get(pages::cache_nars_page)) + .route("/caches/{name}/nars", get_service(live.clone())) + .route_service("/_topcoat/{*rest}", live) } pub fn preview_router() -> Router { diff --git a/crates/server/src/routes/dashboard/pages.rs b/crates/server/src/routes/dashboard/pages.rs index 573a653e..04bf45ed 100644 --- a/crates/server/src/routes/dashboard/pages.rs +++ b/crates/server/src/routes/dashboard/pages.rs @@ -5,95 +5,61 @@ //! These handlers do not mutate server state; they only render templates. //! Mutating admin actions live in `super::admin`. -use std::{ - cmp::Reverse, - collections::{BTreeMap, BTreeSet, HashMap}, - path::Path as StdPath, -}; +use std::{cmp::Reverse, collections::BTreeSet}; use axum::{ extract::{Path, Query, State}, - http::StatusCode, - response::{Html, IntoResponse, Response}, + response::Html, }; -use circus_common::models::{Build, BuildStatus}; -use tokio::fs; +use circus_common::models::BuildStatus; use uuid::Uuid; use super::{ - build_log::parse_build_log, shared::{ - BrokeInView, - BuildView, DashboardContext, DashboardPage, - EvalSummaryView, - EvalView, - JobStatusCell, - JobStatusColumn, - JobStatusRow, PageError, Pagination, - ProjectSummaryView, - QueueSystemView, RenderExt, - WorkerSummaryView, - build_view, - build_view_with_context, - commit_url, enforce_page_access, - eval_badge, - eval_progress, - eval_running_since, eval_view, - eval_view_with_context, - format_duration, not_found, repository_page_url, status_badge, }, - templates::{ - BuildLogTemplate, - BuildTemplate, - BuildsTemplate, - EvaluationTemplate, - EvaluationsTemplate, - HomeTemplate, - JobsetJobsTemplate, - JobsetTemplate, - ProjectTemplate, - ProjectsTemplate, - UiTemplateConfig, - }, + templates::{ProjectTemplate, ProjectsTemplate, UiTemplateConfig}, }; use crate::{operator, state::AppState}; mod caches; mod queue; mod secondary; -pub(super) use caches::{cache_detail_page, cache_nars_page, caches_page}; +pub(super) use caches::cache_detail_page; pub(super) use queue::{Queue, QueueFilter, load as load_queue}; -pub(super) use secondary::{ - channel_page, - channels_page, - metrics_page, - project_setup_page, - starred_page, -}; +pub(super) use secondary::{metrics_page, project_setup_page}; fn ui_config(state: &AppState) -> UiTemplateConfig { UiTemplateConfig::from_config(&state.config.ui) } -fn is_job_name(name: &str) -> bool { +pub(super) fn is_job_name(name: &str) -> bool { !name.starts_with(circus_common::models::DEPENDENCY_JOB_PREFIX) } -fn is_failed_status(status: BuildStatus) -> bool { +/// A dependency's derivation name, without the `drv:` prefix and store hash. +pub(super) fn derivation_name(job_name: &str) -> &str { + job_name + .strip_prefix(circus_common::models::DEPENDENCY_JOB_PREFIX) + .map_or(job_name, |drv| { + drv.split_once('-').map_or(drv, |(_, name)| name) + }) +} + +pub(super) fn is_failed_status(status: BuildStatus) -> bool { status_badge(status).1 == "failed" } -const fn is_failed_derivation_status(status: BuildStatus) -> bool { +pub(super) const fn is_failed_derivation_status(status: BuildStatus) -> bool { matches!( status, BuildStatus::Failed @@ -107,7 +73,7 @@ const fn is_failed_derivation_status(status: BuildStatus) -> bool { ) } -fn dashboard_system_filters( +pub(super) fn dashboard_system_filters( overview: &operator::OperatorOverview, ) -> Vec { let mut systems = BTreeSet::new(); @@ -144,67 +110,6 @@ pub(super) struct PageParams { pub(super) offset: Option, } -#[derive(serde::Deserialize)] -pub(super) struct BuildFilterParams { - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - status: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - system: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - job_name: Option, - limit: Option, - offset: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct EvalFilterParams { - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - project: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - jobset: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - commit: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - status: Option, - limit: Option, - offset: Option, -} - -#[derive(serde::Deserialize)] -pub(super) struct JobsetJobsParams { - show_inactive: Option, -} - -impl JobsetJobsParams { - fn show_inactive(&self) -> bool { - self - .show_inactive - .as_deref() - .is_some_and(|v| matches!(v, "1" | "true" | "yes" | "on")) - } -} - pub(super) fn format_elapsed(secs: i64) -> String { if secs < 60 { format!("{secs}s") @@ -215,72 +120,6 @@ pub(super) fn format_elapsed(secs: i64) -> String { } } -/// Render the dashboard landing page at `/`: aggregate build stats, -/// recent builds and evaluations, project summaries with last-eval -/// status, and announcements. -pub(super) async fn home( - State(state): State, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Home)?; - let include_hidden = ctx.is_admin; - let overview = operator::overview(&state, include_hidden) - .await - .map_err(IntoResponse::into_response)?; - let evals = circus_common::repo::evaluations::list_filtered_with_visibility( - &state.pool, - None, - None, - 5, - 0, - include_hidden, - ) - .await - .unwrap_or_default(); - let announcements = circus_common::repo::news::list(&state.pool, 3, 0) - .await - .unwrap_or_default(); - - let tmpl = HomeTemplate { - ui: ui_config(&state), - total_builds: overview.total_builds, - completed_builds: overview.completed_builds, - failed_builds: overview.failed_builds, - running_builds: overview.running_builds, - pending_builds: overview.pending_builds, - recent_builds: overview.recent_builds.iter().map(BuildView::from).collect(), - failed_builds_list: overview - .failed_builds_list - .iter() - .map(BuildView::from) - .collect(), - recent_evals: evals.iter().map(eval_view).collect(), - projects: overview - .projects - .iter() - .map(ProjectSummaryView::from) - .collect(), - queue_by_system: overview - .queue_by_system - .iter() - .map(QueueSystemView::from) - .collect(), - workers: overview - .workers - .iter() - .map(WorkerSummaryView::from) - .collect(), - system_filters: dashboard_system_filters(&overview), - worker_online: overview.worker_online, - worker_total: overview.worker_total, - refreshed_at: overview.refreshed_at, - announcements, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - }; - tmpl.render_html_or_500() -} - /// Render the paginated project list at `/projects`. pub(super) async fn projects_page( State(state): State, @@ -366,698 +205,6 @@ pub(super) async fn project_page( tmpl.render_html_or_500() } -pub(super) async fn jobset_page( - State(state): State, - Path(id): Path, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Jobset)?; - let include_hidden = ctx.is_admin; - let Ok(jobset) = circus_common::repo::jobsets::get(&state.pool, id).await - else { - return Err(not_found("Jobset")); - }; - let Ok(project) = - circus_common::repo::projects::get(&state.pool, jobset.project_id).await - else { - return Err(not_found("Project")); - }; - - let evals = circus_common::repo::evaluations::list_filtered_with_visibility( - &state.pool, - Some(id), - None, - 20, - 0, - include_hidden, - ) - .await - .unwrap_or_default(); - - let eval_ids: Vec = evals.iter().map(|e| e.id).collect(); - let builds = circus_common::repo::builds::list_for_jobset_evaluations( - &state.pool, - id, - &eval_ids, - ) - .await - .unwrap_or_default(); - - let mut builds_by_eval: HashMap> = HashMap::new(); - for b in builds.iter().filter(|build| is_job_name(&build.job_name)) { - builds_by_eval.entry(b.evaluation_id).or_default().push(b); - } - - let mut summaries = Vec::new(); - for e in &evals { - let (text, class) = eval_badge(&e.status); - let short = if e.commit_hash.len() > 12 { - e.commit_hash[..12].to_string() - } else { - e.commit_hash.clone() - }; - - let eval_builds = - builds_by_eval.get(&e.id).map_or_else(|| &[], Vec::as_slice); - let succeeded = eval_builds - .iter() - .filter(|b| b.status == BuildStatus::Succeeded) - .count() as i64; - let failed = eval_builds - .iter() - .filter(|b| { - matches!( - b.status, - BuildStatus::Failed - | BuildStatus::DependencyFailed - | BuildStatus::FailedWithOutput - | BuildStatus::Timeout - | BuildStatus::CachedFailure - | BuildStatus::LogLimitExceeded - | BuildStatus::NarSizeLimitExceeded - | BuildStatus::NonDeterministic - ) - }) - .count() as i64; - let pending = eval_builds - .iter() - .filter(|b| b.status == BuildStatus::Pending) - .count() as i64; - - summaries.push(EvalSummaryView { - id: e.id, - commit_short: short, - commit_subject: e.commit_subject.clone().unwrap_or_default(), - status_text: text, - status_class: class, - time: e.evaluation_time.strftime("%Y-%m-%d %H:%M UTC").to_string(), - time_iso: e.evaluation_time.to_string(), - duration: format_duration(e.started_at.as_ref(), e.finished_at.as_ref()), - running_since: eval_running_since(e), - progress: eval_progress(e), - succeeded, - failed, - pending, - hidden: e.hidden, - }); - } - - let tmpl = JobsetTemplate { - ui: ui_config(&state), - project_mutable: crate::routes::declarative::project_is_mutable( - &state, &project, - ), - project, - jobset, - eval_summaries: summaries, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - }; - tmpl.render_html_or_500() -} - -pub(super) async fn jobset_jobs_page( - State(state): State, - Path(id): Path, - Query(params): Query, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::JobsetJobs)?; - let include_hidden = ctx.is_admin; - let Ok(jobset) = circus_common::repo::jobsets::get(&state.pool, id).await - else { - return Err(not_found("Jobset")); - }; - let Ok(project) = - circus_common::repo::projects::get(&state.pool, jobset.project_id).await - else { - return Err(not_found("Project")); - }; - - let evals = circus_common::repo::evaluations::list_filtered_with_visibility( - &state.pool, - Some(id), - None, - 20, - 0, - include_hidden, - ) - .await - .unwrap_or_default(); - let eval_ids: Vec = evals.iter().map(|e| e.id).collect(); - let latest_eval_id = eval_ids.first().copied(); - let builds = circus_common::repo::builds::list_for_jobset_evaluations( - &state.pool, - id, - &eval_ids, - ) - .await - .unwrap_or_default(); - - let columns: Vec = evals - .iter() - .map(|e| { - let commit_short = if e.commit_hash.len() > 12 { - e.commit_hash[..12].to_string() - } else { - e.commit_hash.clone() - }; - let hidden_suffix = if e.hidden { " (hidden)" } else { "" }; - JobStatusColumn { - eval_id: e.id, - label: e.evaluation_time.strftime("%m-%d %H:%M").to_string(), - title: format!("{commit_short}{hidden_suffix}"), - } - }) - .collect(); - - let mut builds_by_job: BTreeMap> = - BTreeMap::new(); - for build in builds - .into_iter() - .filter(|build| is_job_name(&build.job_name)) - { - builds_by_job - .entry(build.job_name.clone()) - .or_default() - .insert(build.evaluation_id, build); - } - - let show_inactive = params.show_inactive(); - let mut rows = Vec::new(); - for (job_name, by_eval) in builds_by_job { - let is_active = - latest_eval_id.is_some_and(|eval_id| by_eval.contains_key(&eval_id)); - if !show_inactive && !is_active { - continue; - } - let cells = columns - .iter() - .map(|column| { - by_eval.get(&column.eval_id).map_or_else( - || { - JobStatusCell { - href: String::new(), - status_text: "-".to_string(), - status_class: "skipped".to_string(), - } - }, - |build| { - let (status_text, status_class) = status_badge(build.status); - JobStatusCell { - href: format!("/build/{}", build.id), - status_text, - status_class, - } - }, - ) - }) - .collect(); - rows.push(JobStatusRow { - job_name, - is_active, - cells, - }); - } - - let tmpl = JobsetJobsTemplate { - ui: ui_config(&state), - project, - jobset, - columns, - rows, - show_inactive, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - }; - tmpl.render_html_or_500() -} - -/// Render the paginated evaluation list at `/evaluations`, enriched with -/// the owning project and jobset names. Hidden evaluations are included -/// only for admins. -pub(super) async fn evaluations_page( - State(state): State, - Query(params): Query, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Evaluations)?; - let limit = params.limit.unwrap_or(50).clamp(1, 200); - let offset = params.offset.unwrap_or(0).max(0); - let filter = circus_common::repo::evaluations::EvaluationListFilter { - project: params.project.as_deref(), - jobset: params.jobset.as_deref(), - commit: params.commit.as_deref(), - status: params.status.as_deref(), - include_hidden: ctx.is_admin, - }; - let items = circus_common::repo::evaluations::list_page_filtered( - &state.pool, - filter, - limit, - offset, - ) - .await - .unwrap_or_default(); - let total = - circus_common::repo::evaluations::count_page_filtered(&state.pool, filter) - .await - .unwrap_or(0); - - // Enrich evaluations with jobset/project names - let mut enriched = Vec::new(); - for e in &items { - let (jname, project) = - match circus_common::repo::jobsets::get(&state.pool, e.jobset_id).await { - Ok(js) => { - let project = - circus_common::repo::projects::get(&state.pool, js.project_id) - .await - .ok(); - (js.name, project) - }, - Err(_) => ("-".to_string(), None), - }; - let (pname, repository_url) = project.map_or_else( - || ("-".to_string(), None), - |project| (project.name, Some(project.repository_url)), - ); - enriched.push(eval_view_with_context( - e, - &jname, - &pname, - repository_url.as_deref(), - )); - } - - let pagination = Pagination::new(total, offset, limit); - let tmpl = EvaluationsTemplate { - ui: ui_config(&state), - evals: enriched, - filter_project: params.project.unwrap_or_default(), - filter_jobset: params.jobset.unwrap_or_default(), - filter_commit: params.commit.unwrap_or_default(), - filter_status: params.status.unwrap_or_default(), - limit, - has_prev: pagination.has_prev, - has_next: pagination.has_next, - prev_offset: pagination.prev_offset, - next_offset: pagination.next_offset, - page: pagination.page, - total_pages: pagination.total_pages, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - }; - tmpl.render_html_or_500() -} - -pub(super) async fn evaluation_page( - State(state): State, - Path(id): Path, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Evaluation)?; - let include_hidden = ctx.is_admin; - let Ok(eval) = circus_common::repo::evaluations::get_visible( - &state.pool, - id, - include_hidden, - ) - .await - else { - return Err(not_found("Evaluation")); - }; - - let Ok(jobset) = - circus_common::repo::jobsets::get(&state.pool, eval.jobset_id).await - else { - return Err(not_found("Jobset")); - }; - let Ok(project) = - circus_common::repo::projects::get(&state.pool, jobset.project_id).await - else { - return Err(not_found("Project")); - }; - - let builds = - circus_common::repo::builds::list_for_evaluation(&state.pool, id) - .await - .unwrap_or_default(); - - let top_level_builds = builds - .iter() - .filter(|build| is_job_name(&build.job_name)) - .collect::>(); - let failed_derivations = builds - .iter() - .filter(|build| is_failed_derivation_status(build.status)) - .map(build_view) - .collect(); - - let succeeded = top_level_builds - .iter() - .filter(|b| b.status == BuildStatus::Succeeded) - .count() as i64; - let failed = top_level_builds - .iter() - .filter(|b| is_failed_status(b.status)) - .count() as i64; - let running = top_level_builds - .iter() - .filter(|b| b.status == BuildStatus::Running) - .count() as i64; - let pending = top_level_builds - .iter() - .filter(|b| b.status == BuildStatus::Pending) - .count() as i64; - - let tmpl = EvaluationTemplate { - ui: ui_config(&state), - eval: EvalView { - commit_url: commit_url(&project.repository_url, &eval.commit_hash), - ..eval_view(&eval) - }, - builds: top_level_builds.into_iter().map(build_view).collect(), - failed_derivations, - project_name: project.name, - project_id: project.id, - jobset_name: jobset.name, - jobset_id: jobset.id, - succeeded_count: succeeded, - failed_count: failed, - running_count: running, - pending_count: pending, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - }; - tmpl.render_html_or_500() -} - -/// Render the filterable build listing at `/builds`. Each row is -/// enriched with its owning project and jobset; the filter form drives -/// pagination via `BuildFilterParams`. -pub(super) async fn builds_page( - State(state): State, - Query(params): Query, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Builds)?; - let limit = params.limit.unwrap_or(50).clamp(1, 200); - let offset = params.offset.unwrap_or(0).max(0); - let items = circus_common::repo::builds::list_filtered( - &state.pool, - None, - params.status.as_deref(), - params.system.as_deref(), - params.job_name.as_deref(), - limit, - offset, - ) - .await - .unwrap_or_default(); - let total = circus_common::repo::builds::count_filtered( - &state.pool, - None, - params.status.as_deref(), - params.system.as_deref(), - params.job_name.as_deref(), - ) - .await - .unwrap_or(0); - - let pagination = Pagination::new(total, offset, limit); - - let mut context_by_eval = HashMap::new(); - for item in &items { - if context_by_eval.contains_key(&item.evaluation_id) { - continue; - } - let context = match circus_common::repo::evaluations::get( - &state.pool, - item.evaluation_id, - ) - .await - { - Ok(eval) => { - match circus_common::repo::jobsets::get(&state.pool, eval.jobset_id) - .await - { - Ok(jobset) => { - match circus_common::repo::projects::get( - &state.pool, - jobset.project_id, - ) - .await - { - Ok(project) => { - Some((project.id, project.name, jobset.id, jobset.name)) - }, - Err(_) => None, - } - }, - Err(_) => None, - } - }, - Err(_) => None, - }; - if let Some(context) = context { - context_by_eval.insert(item.evaluation_id, context); - } - } - - let tmpl = BuildsTemplate { - ui: ui_config(&state), - builds: items - .iter() - .map(|item| { - context_by_eval.get(&item.evaluation_id).map_or_else( - || build_view(item), - |(project_id, project_name, jobset_id, jobset_name)| { - build_view_with_context( - item, - *project_id, - project_name, - *jobset_id, - jobset_name, - ) - }, - ) - }) - .collect(), - limit, - has_prev: pagination.has_prev, - has_next: pagination.has_next, - prev_offset: pagination.prev_offset, - next_offset: pagination.next_offset, - page: pagination.page, - total_pages: pagination.total_pages, - filter_status: params.status.unwrap_or_default(), - filter_system: params.system.unwrap_or_default(), - filter_job: params.job_name.unwrap_or_default(), - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - }; - tmpl.render_html_or_500() -} - -pub(super) async fn build_page( - State(state): State, - Path(id): Path, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Build)?; - let Ok(build) = circus_common::repo::builds::get(&state.pool, id).await - else { - return Err(not_found("Build")); - }; - - let Ok(eval) = - circus_common::repo::evaluations::get(&state.pool, build.evaluation_id) - .await - else { - return Err(not_found("Evaluation")); - }; - let Ok(jobset) = - circus_common::repo::jobsets::get(&state.pool, eval.jobset_id).await - else { - return Err(not_found("Jobset")); - }; - let Ok(project) = - circus_common::repo::projects::get(&state.pool, jobset.project_id).await - else { - return Err(not_found("Project")); - }; - - let eval_commit_short = if eval.commit_hash.len() > 12 { - eval.commit_hash[..12].to_string() - } else { - eval.commit_hash.clone() - }; - - let products = - circus_common::repo::build_products::list_for_build(&state.pool, id) - .await - .unwrap_or_default(); - let dependencies = - circus_common::repo::build_dependencies::list_dependency_builds( - &state.pool, - id, - ) - .await - .unwrap_or_default() - .iter() - .map(build_view) - .collect(); - let dependents = - circus_common::repo::build_dependencies::list_dependent_builds( - &state.pool, - id, - ) - .await - .unwrap_or_default() - .iter() - .map(build_view) - .collect(); - - let broke_in = if is_failed_status(build.status) { - circus_common::repo::builds::broke_in(&state.pool, id) - .await - .unwrap_or_else(|error| { - tracing::warn!(build_id = %id, "Failed to find where the job broke: {error}"); - None - }) - .map(|found| { - BrokeInView { - build_id: found.build_id, - commit_short: found.commit_hash.chars().take(12).collect(), - commit_subject: found.commit_subject.unwrap_or_default(), - last_success_build_id: found.last_success_build_id, - last_success_short: found - .last_success_commit - .chars() - .take(12) - .collect(), - } - }) - } else { - None - }; - - // Resolve who ran the build - let builder_label = if let Some(machine_id) = build.agent_machine_id { - circus_common::repo::builder_sessions::get(&state.pool, machine_id) - .await - .map_or_else(|_| "local".to_string(), |s| s.name) - } else { - "local".to_string() - }; - - let tmpl = BuildTemplate { - ui: ui_config(&state), - build: build_view(&build), - builder_label, - products, - dependencies, - dependents, - broke_in, - eval_id: eval.id, - eval_commit_short, - jobset_id: jobset.id, - jobset_name: jobset.name, - project_id: project.id, - project_name: project.name, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - }; - tmpl.render_html_or_500() -} - -/// Render a build's full log as a structured dashboard page. -pub(super) async fn build_log( - State(state): State, - Path(id): Path, - ctx: DashboardContext, -) -> Result { - enforce_page_access(&state.config, &ctx, DashboardPage::Build)?; - - let Ok(build) = circus_common::repo::builds::get(&state.pool, id).await - else { - return Ok((StatusCode::NOT_FOUND, "Build not found").into_response()); - }; - if circus_common::repo::evaluations::get_visible( - &state.pool, - build.evaluation_id, - ctx.is_admin, - ) - .await - .is_err() - { - return Ok((StatusCode::NOT_FOUND, "Build not found").into_response()); - } - - let Ok(eval) = - circus_common::repo::evaluations::get(&state.pool, build.evaluation_id) - .await - else { - return Ok((StatusCode::NOT_FOUND, "Evaluation not found").into_response()); - }; - let Ok(jobset) = - circus_common::repo::jobsets::get(&state.pool, eval.jobset_id).await - else { - return Ok((StatusCode::NOT_FOUND, "Jobset not found").into_response()); - }; - let Ok(project) = - circus_common::repo::projects::get(&state.pool, jobset.project_id).await - else { - return Ok((StatusCode::NOT_FOUND, "Project not found").into_response()); - }; - - let Some(path) = build.log_path.as_deref().filter(|p| !p.is_empty()) else { - return Ok( - (StatusCode::NOT_FOUND, "No log for this build").into_response(), - ); - }; - let Some(path) = crate::routes::canonical_log_file( - &state.config.logs.log_dir, - StdPath::new(path), - ) - .await - else { - return Ok( - (StatusCode::NOT_FOUND, "Log file is unavailable").into_response(), - ); - }; - - let Ok(raw) = fs::read_to_string(path).await else { - return Ok( - (StatusCode::NOT_FOUND, "Log file is unavailable").into_response(), - ); - }; - - let eval_commit_short = if eval.commit_hash.len() > 12 { - eval.commit_hash[..12].to_string() - } else { - eval.commit_hash.clone() - }; - let tmpl = BuildLogTemplate { - ui: ui_config(&state), - build: build_view(&build), - log: parse_build_log(&raw), - eval_id: eval.id, - eval_commit_short, - jobset_id: jobset.id, - jobset_name: jobset.name, - project_id: project.id, - project_name: project.name, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - }; - tmpl.render_html_or_500().map(IntoResponse::into_response) -} - pub(super) fn elapsed_since(started: i64) -> String { format_elapsed((jiff::Timestamp::now().as_second() - started).max(0)) } @@ -1066,28 +213,7 @@ pub(super) fn elapsed_since(started: i64) -> String { mod tests { use circus_common::models::BuildStatus; - use super::{ - BuildFilterParams, - is_failed_derivation_status, - is_failed_status, - is_job_name, - }; - - #[test] - fn blank_filter_params_deserialize_to_none() { - let params = serde_urlencoded::from_str::( - "offset=50&limit=50&status=&system=&job_name=", - ) - .expect("deserialize query"); - assert_eq!(params.status, None); - assert_eq!(params.system, None); - assert_eq!(params.job_name, None); - assert_eq!(params.offset, Some(50)); - - let kept = serde_urlencoded::from_str::("status=failed") - .expect("deserialize query"); - assert_eq!(kept.status.as_deref(), Some("failed")); - } + use super::{is_failed_derivation_status, is_failed_status, is_job_name}; #[test] fn job_lists_exclude_synthetic_dependency_names() { diff --git a/crates/server/src/routes/dashboard/pages/caches.rs b/crates/server/src/routes/dashboard/pages/caches.rs index 18bd5865..19bf9f06 100644 --- a/crates/server/src/routes/dashboard/pages/caches.rs +++ b/crates/server/src/routes/dashboard/pages/caches.rs @@ -2,33 +2,18 @@ use axum::{ extract::{Path, Query, State}, response::{Html, IntoResponse, Response}, }; -use circus_common::{ - models::SortDirection, - repo::narinfo_cache::{NarSort, NarSortColumn}, -}; use super::{ super::{ shared::{ - CacheNarsParams, DashboardContext, DashboardPage, PageError, - Pagination, RenderExt, enforce_page_access, format_bytes, - format_exact_bytes, - store_path_hash, - }, - templates::{ - CacheDetailTemplate, - CacheNarsTemplate, - CacheRowView, - CachesTemplate, - NarRowView, - SortHeaderView, }, + templates::CacheDetailTemplate, }, ui_config, }; @@ -38,68 +23,6 @@ fn cache_db_err(error: circus_common::CiError) -> Response { crate::error::ApiError(error).into_response() } -fn fmt_opt_ts(ts: Option) -> String { - ts.map_or_else( - || "-".to_owned(), - |t| t.strftime("%Y-%m-%d %H:%M UTC").to_string(), - ) -} - -pub(in crate::routes::dashboard) async fn caches_page( - State(state): State, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Caches)?; - let refs = crate::cache_overview::list_cache_refs(&state) - .await - .map_err(IntoResponse::into_response)?; - - // The unscoped (global) summary already covers every NAR; summing the - // per-cache rows would double count project-scoped entries. - let totals = - circus_common::repo::narinfo_cache::storage_summary(&state.pool, None) - .await - .map_err(cache_db_err)?; - - let mut caches = Vec::with_capacity(refs.len()); - for cache in refs { - let storage = circus_common::repo::narinfo_cache::storage_summary( - &state.pool, - cache.scope, - ) - .await - .map_err(cache_db_err)?; - let (requests_per_hour, _bytes) = - circus_common::repo::cache_traffic::traffic_last_hour( - &state.pool, - &cache.name, - ) - .await - .map_err(cache_db_err)?; - - caches.push(CacheRowView { - detail_href: format!("/caches/{}", cache.name), - scope_label: cache.scope_label().to_owned(), - name: cache.name, - active: cache.active, - nar_count: storage.nar_count, - compressed: format_bytes(storage.compressed_bytes), - requests_per_hour, - }); - } - - CachesTemplate { - ui: ui_config(&state), - is_admin: ctx.is_admin, - auth_name: ctx.auth_name, - total_nars: totals.nar_count, - total_compressed: format_bytes(totals.compressed_bytes), - total_uncompressed: format_bytes(totals.uncompressed_bytes), - caches, - } - .render_html_or_500() -} - /// Result banner state for a completed `POST /caches/{name}/gc` redirect. #[derive(Default, serde::Deserialize)] pub(in crate::routes::dashboard) struct CacheGcNoticeParams { @@ -209,212 +132,3 @@ pub(in crate::routes::dashboard) async fn cache_detail_page( } .render_html_or_500() } - -/// Each header click steps its column through ascending, descending, and back -/// to the default newest-first order. -fn nar_sort_headers( - detail_href: &str, - params: &CacheNarsParams, - active: Option, -) -> Vec { - NarSortColumn::ALL - .into_iter() - .map(|column| { - let active_dir = active - .filter(|sort| sort.column == column) - .map(|sort| sort.direction); - let next_dir = match active_dir { - None => Some(SortDirection::Asc), - Some(SortDirection::Asc) => Some(SortDirection::Desc), - Some(SortDirection::Desc) => None, - }; - - let next_sort = next_dir.map(|direction| NarSort { column, direction }); - - SortHeaderView { - key: column.as_str().to_owned(), - label: nar_sort_label(column).to_owned(), - href: nars_href(detail_href, params, next_sort, None), - default_dir: SortDirection::Asc.as_str().to_owned(), - active: active_dir.is_some(), - indicator: active_dir.map_or("", SortDirection::as_str).to_owned(), - aria_sort: match active_dir { - None => "none", - Some(SortDirection::Asc) => "ascending", - Some(SortDirection::Desc) => "descending", - } - .to_owned(), - } - }) - .collect() -} - -/// `page` is an `(offset, limit)` pair, and `None` starts from the first page. -fn nars_href( - detail_href: &str, - params: &CacheNarsParams, - sort: Option, - page: Option<(i64, i64)>, -) -> String { - let mut query = url::form_urlencoded::Serializer::new(String::new()); - if let Some(hash) = ¶ms.hash { - query.append_pair("hash", hash); - } - if let Some(package) = ¶ms.package { - query.append_pair("package", package); - } - if let Some(active) = sort { - query.append_pair("sort", active.column.as_str()); - query.append_pair("dir", active.direction.as_str()); - } - if let Some((offset, limit)) = page { - query.append_pair("offset", &offset.to_string()); - query.append_pair("limit", &limit.to_string()); - } - format!("{detail_href}/nars?{}", query.finish()) -} - -const fn nar_sort_label(column: NarSortColumn) -> &'static str { - match column { - NarSortColumn::Hash => "Hash", - NarSortColumn::Package => "Package", - NarSortColumn::NarSize => "NAR size", - NarSortColumn::Compressed => "Compressed", - NarSortColumn::Created => "Created", - NarSortColumn::LastFetched => "Last fetched", - } -} - -pub(in crate::routes::dashboard) async fn cache_nars_page( - State(state): State, - ctx: DashboardContext, - Path(name): Path, - Query(params): Query, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::CacheNars)?; - let Some(cache) = crate::cache_overview::resolve_cache_ref(&state, &name) - .await - .map_err(IntoResponse::into_response)? - else { - return Err(super::super::shared::not_found("Cache")); - }; - - let limit = params.limit.unwrap_or(50).clamp(1, 200); - let offset = params.offset.unwrap_or(0).max(0); - let hash = params.hash.clone(); - let package = params.package.clone(); - let sort = params.sort.map(|column| { - NarSort { - column, - direction: params.dir.unwrap_or(SortDirection::Asc), - } - }); - - let items = circus_common::repo::narinfo_cache::list_filtered( - &state.pool, - cache.scope, - hash.as_deref(), - package.as_deref(), - sort, - limit, - offset, - ) - .await - .map_err(cache_db_err)?; - let total = circus_common::repo::narinfo_cache::count_filtered( - &state.pool, - cache.scope, - hash.as_deref(), - package.as_deref(), - ) - .await - .map_err(cache_db_err)?; - let summary = circus_common::repo::narinfo_cache::storage_summary( - &state.pool, - cache.scope, - ) - .await - .map_err(cache_db_err)?; - let (last_uploaded, oldest_fetched) = - circus_common::repo::narinfo_cache::storage_extremes( - &state.pool, - cache.scope, - ) - .await - .map_err(cache_db_err)?; - - let nars = items - .into_iter() - .map(|it| { - NarRowView { - hash: store_path_hash(&it.store_path), - package: it.package_name, - nar_size: format_bytes(it.nar_size), - nar_bytes: format_exact_bytes(it.nar_size), - compressed: it - .file_size - .map_or_else(|| "-".to_owned(), format_bytes), - compressed_bytes: it - .file_size - .map(format_exact_bytes) - .unwrap_or_default(), - created_at: it - .created_at - .strftime("%Y-%m-%d %H:%M UTC") - .to_string(), - created_iso: it.created_at.to_string(), - last_fetched: it.last_fetched_at.map_or_else( - || "Never".to_owned(), - |t| t.strftime("%Y-%m-%d %H:%M UTC").to_string(), - ), - last_fetched_iso: it - .last_fetched_at - .map(|t| t.to_string()) - .unwrap_or_default(), - store_path: it.store_path, - } - }) - .collect(); - - let pagination = Pagination::new(total, offset, limit); - let detail_href = format!("/caches/{}", cache.name); - let page_href = |page_offset| { - nars_href(&detail_href, ¶ms, sort, Some((page_offset, limit))) - }; - let prev_href = page_href(pagination.prev_offset); - let next_href = page_href(pagination.next_offset); - let sort_headers = nar_sort_headers(&detail_href, ¶ms, sort); - - CacheNarsTemplate { - ui: ui_config(&state), - is_admin: ctx.is_admin, - auth_name: ctx.auth_name, - sort_headers, - sort_key: sort.map_or("", |active| active.column.as_str()).to_owned(), - sort_dir: sort - .map_or("", |active| active.direction.as_str()) - .to_owned(), - detail_href, - scope_label: cache.scope_label().to_owned(), - name: cache.name, - filter_hash: params.hash.unwrap_or_default(), - filter_package: params.package.unwrap_or_default(), - total_nars: summary.nar_count, - nar_size: format_bytes(summary.uncompressed_bytes), - file_size: format_bytes(summary.compressed_bytes), - last_uploaded: fmt_opt_ts(last_uploaded), - last_uploaded_iso: last_uploaded.map(|t| t.to_string()).unwrap_or_default(), - oldest_fetched: fmt_opt_ts(oldest_fetched), - oldest_fetched_iso: oldest_fetched - .map(|t| t.to_string()) - .unwrap_or_default(), - nars, - page: pagination.page, - total_pages: pagination.total_pages, - has_prev: pagination.has_prev, - has_next: pagination.has_next, - prev_href, - next_href, - } - .render_html_or_500() -} diff --git a/crates/server/src/routes/dashboard/pages/secondary.rs b/crates/server/src/routes/dashboard/pages/secondary.rs index 2d573d75..7826b463 100644 --- a/crates/server/src/routes/dashboard/pages/secondary.rs +++ b/crates/server/src/routes/dashboard/pages/secondary.rs @@ -1,9 +1,7 @@ use axum::{ - extract::{Path, State}, + extract::State, response::{Html, Redirect}, }; -use circus_common::models::BuildStatus; -use uuid::Uuid; use super::{ super::{ @@ -12,226 +10,14 @@ use super::{ DashboardPage, PageError, RenderExt, - StarredJobView, - build_view, enforce_page_access, - not_found, - status_badge, - }, - templates::{ - ChannelTemplate, - ChannelView, - ChannelsTemplate, - MetricsTemplate, - ProjectSetupTemplate, - StarredTemplate, }, + templates::{MetricsTemplate, ProjectSetupTemplate}, }, ui_config, }; use crate::state::AppState; -pub(in crate::routes::dashboard) async fn channels_page( - State(state): State, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Channels)?; - let channels = circus_common::repo::channels::list_all(&state.pool) - .await - .unwrap_or_default(); - - let channel_views = channels - .into_iter() - .map(|channel| { - let has_eval = channel.current_evaluation_id.is_some(); - ChannelView { - id: channel.id, - name: channel.name, - current_evaluation_id: channel.current_evaluation_id, - updated_at: channel - .updated_at - .strftime("%Y-%m-%d %H:%M UTC") - .to_string(), - status_text: if has_eval { - "Active".into() - } else { - "Pending".into() - }, - status_class: if has_eval { - "completed".into() - } else { - "pending".into() - }, - job_count: 0, - } - }) - .collect(); - - ChannelsTemplate { - ui: ui_config(&state), - channels: channel_views, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - } - .render_html_or_500() -} - -pub(in crate::routes::dashboard) async fn channel_page( - State(state): State, - Path(id): Path, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Channel)?; - let Ok(channel) = circus_common::repo::channels::get(&state.pool, id).await - else { - return Err(not_found("Channel")); - }; - - let builds = if let Some(eval_id) = channel.current_evaluation_id { - circus_common::repo::builds::list_for_evaluation(&state.pool, eval_id) - .await - .unwrap_or_default() - } else { - Vec::new() - }; - - let succeeded_count = builds - .iter() - .filter(|b| b.status == BuildStatus::Succeeded) - .count() as i64; - let failed_count = builds - .iter() - .filter(|b| { - matches!( - b.status, - BuildStatus::Failed - | BuildStatus::FailedWithOutput - | BuildStatus::Timeout - | BuildStatus::DependencyFailed - | BuildStatus::Aborted - ) - }) - .count() as i64; - let pending_count = builds - .iter() - .filter(|b| matches!(b.status, BuildStatus::Pending | BuildStatus::Running)) - .count() as i64; - - ChannelTemplate { - ui: ui_config(&state), - channel, - builds: builds.iter().map(build_view).collect(), - succeeded_count, - failed_count, - pending_count, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - } - .render_html_or_500() -} - -pub(in crate::routes::dashboard) async fn starred_page( - State(state): State, - ctx: DashboardContext, -) -> Result, PageError> { - enforce_page_access(&state.config, &ctx, DashboardPage::Starred)?; - let viewer_user_id = ctx.viewer_user_id; - let is_logged_in = viewer_user_id.is_some(); - - let starred_jobs = if let Some(uid) = viewer_user_id { - let starred = circus_common::repo::starred_jobs::list_for_user( - &state.pool, - uid, - 100, - 0, - ) - .await - .unwrap_or_default(); - - let mut views = Vec::new(); - for s in starred { - let project_name = - circus_common::repo::projects::get(&state.pool, s.project_id) - .await - .map_or_else(|_| "-".to_string(), |p| p.name); - - let jobset_name = if let Some(js_id) = s.jobset_id { - circus_common::repo::jobsets::get(&state.pool, js_id) - .await - .map_or_else(|_| "-".to_string(), |j| j.name) - } else { - "-".to_string() - }; - - let (status_text, status_class, latest_build_id) = - if let Some(js_id) = s.jobset_id { - let evals = - circus_common::repo::evaluations::list_filtered_with_visibility( - &state.pool, - Some(js_id), - None, - 1, - 0, - ctx.is_admin, - ) - .await - .unwrap_or_default(); - - let builds = if let Some(eval) = evals.first() { - circus_common::repo::builds::list_filtered( - &state.pool, - Some(eval.id), - None, - None, - Some(&s.job_name), - 1, - 0, - ) - .await - .unwrap_or_default() - } else { - Vec::new() - }; - - builds.first().map_or_else( - || ("No builds".to_string(), "pending".to_string(), None), - |build| { - let (text, class) = status_badge(build.status); - (text, class, Some(build.id)) - }, - ) - } else { - ("No builds".to_string(), "pending".to_string(), None) - }; - - views.push(StarredJobView { - id: s.id, - project_id: s.project_id, - project_name, - jobset_id: s.jobset_id, - jobset_name, - job_name: s.job_name, - status_text, - status_class, - latest_build_id, - }); - } - views - } else { - Vec::new() - }; - - StarredTemplate { - ui: ui_config(&state), - starred_jobs, - is_logged_in, - is_admin: ctx.is_admin, - auth_name: ctx.auth_name.clone(), - csrf_token: ctx.csrf_token.clone(), - } - .render_html_or_500() -} - pub(in crate::routes::dashboard) async fn metrics_page( State(state): State, ctx: DashboardContext, diff --git a/crates/server/src/routes/dashboard/preview/fixtures.rs b/crates/server/src/routes/dashboard/preview/fixtures.rs index 5fecbc19..d67de671 100644 --- a/crates/server/src/routes/dashboard/preview/fixtures.rs +++ b/crates/server/src/routes/dashboard/preview/fixtures.rs @@ -1,10 +1,8 @@ use circus_common::models::{ BinaryCacheUpstreams, - Channel, Jobset, JobsetState, JobsetTriggerMode, - NewsItem, Project, }; use circus_config::UiConfig; @@ -12,20 +10,8 @@ use jiff::{SignedDuration, Timestamp}; use uuid::Uuid; use super::super::{ - build_log::parse_build_log, - shared::{ - BuildErrorLine, - BuildView, - EvalProgressView, - EvalSummaryView, - EvalView, - JobStatusCell, - JobStatusColumn, - JobStatusRow, - ProjectSummaryView, - short_uuid, - }, - templates::{BuildLogTemplate, UiTemplateConfig}, + shared::{EvalProgressView, EvalView}, + templates::UiTemplateConfig, }; pub(super) const fn id(n: u128) -> Uuid { @@ -87,165 +73,6 @@ pub(super) fn jobset_fixture() -> Jobset { } } -pub(super) fn channel_fixture() -> Channel { - Channel { - id: id(5), - project_id: id(1), - name: "latest".into(), - jobset_id: id(2), - current_evaluation_id: Some(id(3)), - created_at: Timestamp::now() - - SignedDuration::from_hours(24 * 7), - updated_at: Timestamp::now() - SignedDuration::from_mins(2), - } -} - -pub(super) fn news_items() -> Vec { - vec![NewsItem { - id: id(21), - title: "Preview fixtures updated".into(), - content: "Frontend previews are served from xtask without a VM.".into(), - created_by: Some(id(51)), - created_at: Timestamp::now() - SignedDuration::from_hours(2), - }] -} - -pub(super) fn build_view( - n: u128, - job: &str, - status: &str, - class: &str, -) -> BuildView { - let build_id = id(n); - let system = job.split('.').nth(1).unwrap_or("x86_64-linux"); - BuildView { - id: build_id, - id_short: short_uuid(build_id), - job_name: job.into(), - project_id: Some(id(1)), - project_name: "circus".into(), - jobset_id: Some(id(2)), - jobset_name: "packages".into(), - status_text: status.into(), - status_class: class.into(), - system: system.into(), - created_at: "2026-06-18 11:45".into(), - started_at: "2026-06-18 11:46".into(), - completed_at: if class == "running" { - String::new() - } else { - "2026-06-18 11:49".into() - }, - duration: "3m 12s".into(), - started_epoch: if class == "running" { - Some(Timestamp::now().as_second() - 90) - } else { - None - }, - priority: 100, - is_aggregate: false, - signed: true, - drv_path: format!( - "/nix/store/8s69x68y-{}.drv", - job.rsplit('.').next().unwrap_or(job) - ), - output_path: format!( - "/nix/store/lq2n7cav-{}", - job.rsplit('.').next().unwrap_or(job) - ), - error_message: if class == "failed" { - "error: builder failed with exit code 1".into() - } else { - String::new() - }, - error_lines: if class == "failed" { - vec![BuildErrorLine { - text: "builder failed with exit code 1".into(), - level: "error", - }] - } else { - Vec::new() - }, - has_log: true, - } -} - -struct PreviewBuildFixture { - n: u128, - job: &'static str, - status: &'static str, - class: &'static str, - raw_log: Option<&'static str>, -} - -impl PreviewBuildFixture { - fn build(&self) -> BuildView { - build_view(self.n, self.job, self.status, self.class) - } -} - -const PREVIEW_BUILDS: &[PreviewBuildFixture] = &[ - PreviewBuildFixture { - n: 4, - job: "packages.x86_64-linux.circus-server", - status: "Succeeded", - class: "completed", - raw_log: Some(PREVIEW_SUCCEEDED_BUILD_LOG), - }, - PreviewBuildFixture { - n: 5, - job: "packages.aarch64-linux.circus-server", - status: "Failed", - class: "failed", - raw_log: Some(PREVIEW_FAILED_BUILD_LOG), - }, - PreviewBuildFixture { - n: 6, - job: "checks.x86_64-linux.integration", - status: "Running", - class: "running", - raw_log: Some(PREVIEW_RUNNING_BUILD_LOG), - }, -]; - -fn build_fixture_by_id(build_id: Uuid) -> Option<&'static PreviewBuildFixture> { - PREVIEW_BUILDS - .iter() - .find(|fixture| id(fixture.n) == build_id) -} - -pub(super) fn builds_fixture() -> Vec { - PREVIEW_BUILDS - .iter() - .map(PreviewBuildFixture::build) - .collect() -} - -pub(super) fn build_log_template(build_id: Uuid) -> Option { - let fixture = build_fixture_by_id(build_id)?; - let raw_log = fixture.raw_log?; - Some(BuildLogTemplate { - ui: ui(), - build: fixture.build(), - log: parse_build_log(raw_log), - eval_id: id(3), - eval_commit_short: "9f2c7a113bad".into(), - jobset_id: id(2), - jobset_name: "packages".into(), - project_id: id(1), - project_name: "circus".into(), - is_admin: true, - auth_name: "preview-admin".into(), - }) -} - -const PREVIEW_SUCCEEDED_BUILD_LOG: &str = - include_str!("fixtures/build-00000004.internal-json"); -const PREVIEW_FAILED_BUILD_LOG: &str = - include_str!("fixtures/build-00000005.internal-json"); -const PREVIEW_RUNNING_BUILD_LOG: &str = - include_str!("fixtures/build-00000006.internal-json"); - pub(super) fn eval_view(n: u128, status: &str, class: &str) -> EvalView { EvalView { commit_url: Some( @@ -280,7 +107,6 @@ pub(super) fn eval_view(n: u128, status: &str, class: &str) -> EvalView { running_since: (status == "Running").then(|| Timestamp::now().as_second() - 40), progress: (status == "Running").then(|| { EvalProgressView { - id: id(n), count: "1,204 / 1,530".into(), percent: 78, } @@ -300,71 +126,3 @@ pub(super) fn evals_fixture() -> Vec { eval_view(13, "Running", "running"), ] } - -pub(super) fn eval_summaries() -> Vec { - vec![EvalSummaryView { - id: id(3), - commit_short: "9f2c7a113bad".into(), - commit_subject: "evaluator: record the commit subject".into(), - status_text: "Completed".into(), - status_class: "completed".into(), - time: "2026-06-18 11:42 UTC".into(), - time_iso: "2026-06-18T11:42:00+00:00".into(), - duration: "1m 12s".into(), - running_since: None, - progress: None, - succeeded: 18, - failed: 1, - pending: 0, - hidden: false, - }] -} - -pub(super) fn project_summaries() -> Vec { - vec![ProjectSummaryView { - id: id(1), - name: "circus".into(), - jobset_count: 2, - last_eval_status: "Completed".into(), - last_eval_class: "completed".into(), - last_eval_time: "2026-06-18 11:42".into(), - failing_jobs: 1, - queued_jobs: 3, - systems: "x86_64-linux, aarch64-linux".into(), - updated_at: "2026-06-18 11:50".into(), - }] -} - -pub(super) fn job_columns() -> Vec { - vec![ - JobStatusColumn { - eval_id: id(3), - label: "9f2c7a".into(), - title: "9f2c7a113bad".into(), - }, - JobStatusColumn { - eval_id: id(13), - label: "running".into(), - title: "running evaluation".into(), - }, - ] -} - -pub(super) fn job_rows() -> Vec { - vec![JobStatusRow { - job_name: "packages.x86_64-linux.circus-server".into(), - is_active: true, - cells: vec![ - JobStatusCell { - href: "/build/00000000-0000-0000-0000-000000000004".into(), - status_text: "Succeeded".into(), - status_class: "completed".into(), - }, - JobStatusCell { - href: "/build/00000000-0000-0000-0000-000000000006".into(), - status_text: "Running".into(), - status_class: "running".into(), - }, - ], - }] -} diff --git a/crates/server/src/routes/dashboard/preview/mod.rs b/crates/server/src/routes/dashboard/preview/mod.rs index 417aac75..7d1a83b7 100644 --- a/crates/server/src/routes/dashboard/preview/mod.rs +++ b/crates/server/src/routes/dashboard/preview/mod.rs @@ -6,7 +6,6 @@ use askama::Template; use axum::{ Router, body::Body, - extract::Path, http::{StatusCode, header}, response::{Html, IntoResponse, Redirect, Response}, routing::{delete, get, post, put}, @@ -68,7 +67,6 @@ pub fn router() -> Router { "/api/v1/builds/{build_id}/products/{product_id}/download", get(product_download), ) - .route("/", get(pages::home)) .route("/logout", post(preview_logout_action)) .route("/projects", get(pages::projects)) .route("/projects/new", get(pages::project_setup)) @@ -81,34 +79,19 @@ pub fn router() -> Router { post(preview_notifications_action), ) .route("/project/{id}", get(pages::project)) - .route("/jobset/{id}", get(pages::jobset)) - .route("/jobset/{id}/jobs", get(pages::jobset_jobs)) .route("/jobset/{id}/delete", post(preview_project_action)) - .route("/evaluations", get(pages::evaluations)) - .route("/evaluation/{id}", get(pages::evaluation)) .route( "/evaluation/{id}/visibility", post(preview_evaluations_action), ) .route("/evaluation/{id}/cancel", post(preview_evaluations_action)) .route("/evaluation/{id}/restart", post(preview_evaluations_action)) - .route("/builds", get(pages::builds)) - .route("/build/{id}", get(pages::build)) - .route("/build/{id}/log", get(build_log)) .route("/build/{id}/bump", post(preview_queue_action)) - .route("/channels", get(pages::channels)) - .route("/channel/{id}", get(pages::channel)) - .route("/news", get(pages::news).post(preview_news_action)) - .route("/news/{id}/delete", post(preview_news_action)) .route("/admin", get(pages::admin)) .route("/users", get(pages::users)) - .route("/starred", get(pages::starred)) .route("/metrics", get(pages::metrics)) - .route("/login", get(pages::login).post(preview_login_action)) .route("/private", get(pages::private)) - .route("/caches", get(pages::caches)) .route("/caches/{name}", get(pages::cache_detail)) - .route("/caches/{name}/nars", get(pages::cache_nars)) } pub(super) fn render(template: T) -> Response { @@ -125,11 +108,7 @@ pub(super) fn render(template: T) -> Response { } async fn index() -> Redirect { - Redirect::temporary("/") -} - -async fn preview_login_action() -> Redirect { - Redirect::to("/login") + Redirect::temporary("/projects") } async fn preview_logout_action() -> Redirect { @@ -152,17 +131,6 @@ async fn preview_queue_action() -> Redirect { Redirect::to("/queue") } -async fn preview_news_action() -> Redirect { - Redirect::to("/news") -} - -async fn build_log(Path(id): Path) -> Response { - fixtures::build_log_template(id).map_or_else( - || (StatusCode::NOT_FOUND, "Build log not found").into_response(), - render, - ) -} - async fn product_download() -> Response { Response::builder() .header(header::CONTENT_TYPE, "application/octet-stream") diff --git a/crates/server/src/routes/dashboard/preview/pages.rs b/crates/server/src/routes/dashboard/preview/pages.rs index 4ae653ba..909dbc08 100644 --- a/crates/server/src/routes/dashboard/preview/pages.rs +++ b/crates/server/src/routes/dashboard/preview/pages.rs @@ -1,42 +1,14 @@ -use axum::{extract::Query, response::Response}; -use circus_common::models::{BuildProduct, SystemStatus}; -use jiff::{SignedDuration, Timestamp}; +use axum::response::Response; +use circus_common::models::SystemStatus; use super::{ super::{ - shared::{ - ApiKeyView, - BrokeInView, - LinkedIdentityView, - PrivateTemplate, - QueueSystemView, - StarredJobView, - UserView, - WorkerSummaryView, - }, + shared::{ApiKeyView, LinkedIdentityView, PrivateTemplate, UserView}, templates::{ AdminTemplate, AgentView, - BuildTemplate, - BuildsTemplate, CacheDetailTemplate, - CacheNarsTemplate, - CacheRowView, - CachesTemplate, - ChannelTemplate, - ChannelView, - ChannelsTemplate, - EvaluationTemplate, - EvaluationsTemplate, - HomeTemplate, - JobsetJobsTemplate, - JobsetTemplate, - LoginIcon, - LoginProvider, - LoginTemplate, MetricsTemplate, - NarRowView, - NewsTemplate, NotificationTaskView, NotificationsTemplate, PinnedOutputView, @@ -44,103 +16,13 @@ use super::{ ProjectTemplate, ProjectsTemplate, SortHeaderView, - StarredTemplate, UsersTemplate, }, }, - fixtures::{ - self, - builds_fixture, - channel_fixture, - csrf, - eval_summaries, - evals_fixture, - id, - job_columns, - job_rows, - jobset_fixture, - news_items, - project_fixture, - project_summaries, - ui, - }, + fixtures::{csrf, evals_fixture, id, jobset_fixture, project_fixture, ui}, render, }; -#[derive(serde::Deserialize)] -pub(super) struct PreviewBuildFilterParams { - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - status: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - system: Option, - #[serde( - default, - deserialize_with = "crate::routes::serde_util::empty_string_as_none" - )] - job_name: Option, -} - -pub(super) async fn home() -> Response { - render(HomeTemplate { - ui: ui(), - total_builds: 1842, - completed_builds: 1710, - failed_builds: 27, - running_builds: 3, - pending_builds: 19, - recent_builds: builds_fixture(), - failed_builds_list: vec![fixtures::build_view( - 5, - "packages.aarch64-linux.circus-server", - "Failed", - "failed", - )], - recent_evals: evals_fixture(), - projects: project_summaries(), - queue_by_system: vec![ - QueueSystemView { - system: "x86_64-linux".into(), - count: 12, - }, - QueueSystemView { - system: "aarch64-linux".into(), - count: 7, - }, - ], - workers: vec![ - WorkerSummaryView { - name: "agent-fast-01".into(), - system: "x86_64-linux".into(), - status_text: "busy".into(), - status_class: "running".into(), - current_jobs: 2, - max_jobs: 4, - }, - WorkerSummaryView { - name: "agent-arm-01".into(), - system: "aarch64-linux".into(), - status_text: "idle".into(), - status_class: "completed".into(), - current_jobs: 0, - max_jobs: 2, - }, - ], - system_filters: vec!["aarch64-linux".into(), "x86_64-linux".into()], - worker_online: 2, - worker_total: 3, - refreshed_at: "2026-06-18 12:00 UTC".into(), - announcements: news_items(), - is_admin: true, - auth_name: "operator".into(), - }) -} - pub(super) async fn projects() -> Response { render(ProjectsTemplate { ui: ui(), @@ -194,212 +76,6 @@ pub(super) async fn project() -> Response { }) } -pub(super) async fn jobset() -> Response { - render(JobsetTemplate { - ui: ui(), - project: project_fixture(), - jobset: jobset_fixture(), - eval_summaries: eval_summaries(), - project_mutable: true, - is_admin: true, - auth_name: "operator".into(), - csrf_token: csrf(), - }) -} - -pub(super) async fn jobset_jobs() -> Response { - render(JobsetJobsTemplate { - ui: ui(), - project: project_fixture(), - jobset: jobset_fixture(), - columns: job_columns(), - rows: job_rows(), - show_inactive: false, - is_admin: true, - auth_name: "operator".into(), - }) -} - -pub(super) async fn evaluations() -> Response { - render(EvaluationsTemplate { - ui: ui(), - evals: evals_fixture(), - filter_project: String::new(), - filter_jobset: String::new(), - filter_commit: String::new(), - filter_status: String::new(), - limit: 20, - has_prev: false, - has_next: false, - prev_offset: 0, - next_offset: 20, - page: 1, - total_pages: 1, - is_admin: true, - auth_name: "operator".into(), - csrf_token: csrf(), - }) -} - -pub(super) async fn evaluation() -> Response { - let failed_derivations = vec![ - fixtures::build_view( - 5, - "packages.aarch64-linux.circus-server", - "Failed", - "failed", - ), - fixtures::build_view(7, "drv:0vdd2i8j-intermediate", "Failed", "failed"), - ]; - render(EvaluationTemplate { - ui: ui(), - eval: fixtures::eval_view(3, "Completed", "completed"), - builds: builds_fixture(), - failed_derivations, - project_name: "circus".into(), - project_id: id(1), - jobset_name: "packages".into(), - jobset_id: id(2), - succeeded_count: 2, - failed_count: 1, - running_count: 1, - pending_count: 1, - is_admin: true, - auth_name: "operator".into(), - csrf_token: csrf(), - }) -} - -pub(super) async fn builds( - Query(params): Query, -) -> Response { - let status = params.status.unwrap_or_default(); - let system = params.system.unwrap_or_default(); - let job_name = params.job_name.unwrap_or_default(); - let status_filter = status.to_lowercase(); - let system_filter = system.to_lowercase(); - let job_filter = job_name.to_lowercase(); - let builds = builds_fixture() - .into_iter() - .filter(|build| { - let status_matches = status_filter.is_empty() - || build.status_class == status_filter - || (status_filter == "succeeded" && build.status_class == "completed"); - let system_matches = system_filter.is_empty() - || build.system.to_lowercase().contains(&system_filter); - let job_matches = job_filter.is_empty() - || build.job_name.to_lowercase().contains(&job_filter); - - status_matches && system_matches && job_matches - }) - .collect(); - - render(BuildsTemplate { - ui: ui(), - builds, - limit: 20, - has_prev: false, - has_next: false, - prev_offset: 0, - next_offset: 20, - page: 1, - total_pages: 1, - filter_status: status, - filter_system: system, - filter_job: job_name, - is_admin: true, - auth_name: "operator".into(), - }) -} - -pub(super) async fn build() -> Response { - let build_id = id(4); - render(BuildTemplate { - ui: ui(), - build: fixtures::build_view( - 4, - "packages.x86_64-linux.circus-server", - "Succeeded", - "completed", - ), - builder_label: "agent-fast-01".into(), - products: vec![BuildProduct { - id: id(32), - build_id, - name: "out".into(), - path: "/nix/store/preview-circus-server".into(), - sha256_hash: Some("sha256-preview".into()), - file_size: Some(42_000_000), - content_type: Some("application/x-nix-archive".into()), - is_directory: true, - gc_root_path: Some("/nix/var/nix/gcroots/circus/preview".into()), - created_at: Timestamp::now() - SignedDuration::from_mins(1), - }], - dependencies: vec![fixtures::build_view( - 6, - "checks.x86_64-linux.config", - "Succeeded", - "completed", - )], - dependents: Vec::new(), - broke_in: Some(BrokeInView { - build_id: id(5), - commit_short: "4be1c0ffee21".into(), - commit_subject: "server: bump axum to 0.9".into(), - last_success_build_id: id(4), - last_success_short: "9f2c7a113bad".into(), - }), - eval_id: id(3), - eval_commit_short: "9f2c7a113bad".into(), - jobset_id: id(2), - jobset_name: "packages".into(), - project_id: id(1), - project_name: "circus".into(), - is_admin: true, - auth_name: "operator".into(), - }) -} - -pub(super) async fn channels() -> Response { - render(ChannelsTemplate { - ui: ui(), - channels: vec![ChannelView { - id: id(5), - name: "latest".into(), - current_evaluation_id: Some(id(3)), - updated_at: "2026-06-18 12:02 UTC".into(), - status_text: "Completed".into(), - status_class: "completed".into(), - job_count: 3, - }], - is_admin: true, - auth_name: "operator".into(), - }) -} - -pub(super) async fn channel() -> Response { - render(ChannelTemplate { - ui: ui(), - channel: channel_fixture(), - builds: builds_fixture(), - succeeded_count: 2, - failed_count: 1, - pending_count: 1, - is_admin: true, - auth_name: "operator".into(), - }) -} - -pub(super) async fn news() -> Response { - render(NewsTemplate { - ui: ui(), - items: news_items(), - is_admin: true, - auth_name: "operator".into(), - csrf_token: csrf(), - }) -} - pub(super) async fn admin() -> Response { render(AdminTemplate { ui: ui(), @@ -506,27 +182,6 @@ pub(super) async fn users() -> Response { }) } -pub(super) async fn starred() -> Response { - render(StarredTemplate { - ui: ui(), - starred_jobs: vec![StarredJobView { - id: id(61), - project_id: id(1), - project_name: "circus".into(), - jobset_id: Some(id(2)), - jobset_name: "packages".into(), - job_name: "packages.x86_64-linux.circus-server".into(), - status_text: "Succeeded".into(), - status_class: "completed".into(), - latest_build_id: Some(id(4)), - }], - is_logged_in: true, - is_admin: true, - auth_name: "operator".into(), - csrf_token: csrf(), - }) -} - pub(super) async fn metrics() -> Response { render(MetricsTemplate { ui: ui(), @@ -535,29 +190,6 @@ pub(super) async fn metrics() -> Response { }) } -pub(super) async fn login() -> Response { - render(LoginTemplate { - ui: ui(), - error: Some("Preview mode accepts no credentials.".into()), - is_admin: false, - auth_name: String::new(), - providers: vec![ - LoginProvider { - href: "/api/v1/auth/github".into(), - label: "GitHub".into(), - icon: LoginIcon::Github, - }, - LoginProvider { - href: "/api/v1/auth/oidc/pocketid".into(), - label: "PocketID".into(), - icon: LoginIcon::Oidc, - }, - ], - password_login: true, - next: None, - }) -} - pub(super) async fn private() -> Response { render(PrivateTemplate { ui: ui(), @@ -566,37 +198,6 @@ pub(super) async fn private() -> Response { }) } -pub(super) async fn caches() -> Response { - render(CachesTemplate { - ui: ui(), - is_admin: true, - auth_name: "operator".into(), - total_nars: 42, - total_compressed: "12.3 MiB".into(), - total_uncompressed: "45.6 MiB".into(), - caches: vec![ - CacheRowView { - name: "global".into(), - scope_label: "Global".into(), - active: true, - nar_count: 30, - compressed: "8.1 MiB".into(), - requests_per_hour: 142, - detail_href: "/caches/global".into(), - }, - CacheRowView { - name: "circus".into(), - scope_label: "Project".into(), - active: true, - nar_count: 12, - compressed: "4.2 MiB".into(), - requests_per_hour: 37, - detail_href: "/caches/circus".into(), - }, - ], - }) -} - pub(super) async fn cache_detail() -> Response { render(CacheDetailTemplate { ui: ui(), @@ -632,82 +233,3 @@ pub(super) async fn cache_detail() -> Response { is_global: true, }) } - -pub(super) async fn cache_nars() -> Response { - render(CacheNarsTemplate { - ui: ui(), - is_admin: true, - auth_name: "operator".into(), - name: "global".into(), - scope_label: "Global".into(), - detail_href: "/caches/global".into(), - filter_hash: String::new(), - filter_package: String::new(), - sort_headers: [ - ("hash", "Hash"), - ("package", "Package"), - ("nar_size", "NAR size"), - ("compressed", "Compressed"), - ("created", "Created"), - ("last_fetched", "Last fetched"), - ] - .into_iter() - .map(|(key, label)| { - let active = key == "last_fetched"; - SortHeaderView { - key: key.into(), - label: label.into(), - href: format!("/caches/global/nars?sort={key}&dir=asc"), - default_dir: "asc".into(), - active, - indicator: if active { "desc" } else { "" }.into(), - aria_sort: if active { "descending" } else { "none" }.into(), - } - }) - .collect(), - sort_key: "last_fetched".into(), - sort_dir: "desc".into(), - total_nars: 30, - nar_size: "45.6 MiB".into(), - file_size: "8.1 MiB".into(), - last_uploaded: "2026-06-18 12:00 UTC".into(), - last_uploaded_iso: "2026-06-18T12:00:00+00:00".into(), - oldest_fetched: "2026-06-18 11:30 UTC".into(), - oldest_fetched_iso: "2026-06-18T11:30:00+00:00".into(), - nars: vec![ - NarRowView { - hash: "9f2c7a113badf00d7e57c".into(), - package: "circus-server".into(), - store_path: "/nix/store/9f2c7a113badf00d7e57c-circus-server" - .into(), - nar_size: "1.5 MiB".into(), - nar_bytes: "1,572,864 bytes".into(), - compressed: "420 KiB".into(), - compressed_bytes: "430,080 bytes".into(), - created_at: "2026-06-18 11:45 UTC".into(), - created_iso: "2026-06-18T11:45:00+00:00".into(), - last_fetched: "2026-06-18 11:50 UTC".into(), - last_fetched_iso: "2026-06-18T11:50:00+00:00".into(), - }, - NarRowView { - hash: "a1b2c3d4e5f6a7b8c9d0".into(), - package: "circus-agent".into(), - store_path: "/nix/store/a1b2c3d4e5f6a7b8c9d0-circus-agent".into(), - nar_size: "2.1 MiB".into(), - nar_bytes: "2,202,009 bytes".into(), - compressed: "680 KiB".into(), - compressed_bytes: "696,320 bytes".into(), - created_at: "2026-06-18 11:30 UTC".into(), - created_iso: "2026-06-18T11:30:00+00:00".into(), - last_fetched: "2026-06-18 11:45 UTC".into(), - last_fetched_iso: "2026-06-18T11:45:00+00:00".into(), - }, - ], - page: 1, - total_pages: 2, - has_prev: false, - has_next: true, - prev_href: "/caches/global/nars?offset=0&limit=20".into(), - next_href: "/caches/global/nars?offset=20&limit=20".into(), - }) -} diff --git a/crates/server/src/routes/dashboard/shared.rs b/crates/server/src/routes/dashboard/shared.rs index 0f94e488..63368790 100644 --- a/crates/server/src/routes/dashboard/shared.rs +++ b/crates/server/src/routes/dashboard/shared.rs @@ -225,7 +225,6 @@ pub(super) struct EvalView { } pub(super) struct EvalProgressView { - pub(super) id: Uuid, pub(super) count: String, pub(super) percent: i64, } @@ -1059,11 +1058,7 @@ pub(super) fn eval_progress(e: &Evaluation) -> Option { }, _ => ("-".to_owned(), 0), }; - Some(EvalProgressView { - id: e.id, - count, - percent, - }) + Some(EvalProgressView { count, percent }) } pub(super) fn eval_view(e: &Evaluation) -> EvalView { diff --git a/crates/server/src/routes/dashboard/templates.rs b/crates/server/src/routes/dashboard/templates.rs index 8603a781..11ddfa75 100644 --- a/crates/server/src/routes/dashboard/templates.rs +++ b/crates/server/src/routes/dashboard/templates.rs @@ -8,59 +8,11 @@ )] use askama::Template; -use circus_common::models::{ - BuildProduct, - Channel, - Jobset, - NewsItem, - Project, - SystemStatus, -}; -use circus_config::Config; +use circus_common::models::{Jobset, Project, SystemStatus}; use uuid::Uuid; pub(super) use super::shared::UiTemplateConfig; -use super::{ - build_log::BuildLogView, - shared::{ - ApiKeyView, - BrokeInView, - BuildView, - EvalSummaryView, - EvalView, - JobStatusColumn, - JobStatusRow, - ProjectSummaryView, - QueueSystemView, - StarredJobView, - UserView, - WorkerSummaryView, - }, -}; - -#[derive(Template)] -#[template(path = "home.html")] -pub(super) struct HomeTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) total_builds: i64, - pub(super) completed_builds: i64, - pub(super) failed_builds: i64, - pub(super) running_builds: i64, - pub(super) pending_builds: i64, - pub(super) recent_builds: Vec, - pub(super) failed_builds_list: Vec, - pub(super) recent_evals: Vec, - pub(super) projects: Vec, - pub(super) queue_by_system: Vec, - pub(super) workers: Vec, - pub(super) system_filters: Vec, - pub(super) worker_online: i64, - pub(super) worker_total: i64, - pub(super) refreshed_at: String, - pub(super) announcements: Vec, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} +use super::shared::{ApiKeyView, EvalView, UserView}; #[derive(Template)] #[template(path = "projects.html")] @@ -93,170 +45,6 @@ pub(super) struct ProjectTemplate { pub(super) csrf_token: String, } -#[derive(Template)] -#[template(path = "jobset.html")] -pub(super) struct JobsetTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) project: Project, - pub(super) jobset: Jobset, - pub(super) eval_summaries: Vec, - pub(super) project_mutable: bool, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, -} - -#[derive(Template)] -#[template(path = "jobset_jobs.html")] -pub(super) struct JobsetJobsTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) project: Project, - pub(super) jobset: Jobset, - pub(super) columns: Vec, - pub(super) rows: Vec, - pub(super) show_inactive: bool, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} - -#[derive(Template)] -#[template(path = "evaluations.html")] -pub(super) struct EvaluationsTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) evals: Vec, - pub(super) filter_project: String, - pub(super) filter_jobset: String, - pub(super) filter_commit: String, - pub(super) filter_status: String, - pub(super) limit: i64, - pub(super) has_prev: bool, - pub(super) has_next: bool, - pub(super) prev_offset: i64, - pub(super) next_offset: i64, - pub(super) page: i64, - pub(super) total_pages: i64, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, -} - -#[derive(Template)] -#[template(path = "evaluation.html")] -pub(super) struct EvaluationTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) eval: EvalView, - pub(super) builds: Vec, - pub(super) failed_derivations: Vec, - pub(super) project_name: String, - pub(super) project_id: Uuid, - pub(super) jobset_name: String, - pub(super) jobset_id: Uuid, - pub(super) succeeded_count: i64, - pub(super) failed_count: i64, - pub(super) running_count: i64, - pub(super) pending_count: i64, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, -} - -#[derive(Template)] -#[template(path = "builds.html")] -pub(super) struct BuildsTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) builds: Vec, - pub(super) limit: i64, - pub(super) has_prev: bool, - pub(super) has_next: bool, - pub(super) prev_offset: i64, - pub(super) next_offset: i64, - pub(super) page: i64, - pub(super) total_pages: i64, - pub(super) filter_status: String, - pub(super) filter_system: String, - pub(super) filter_job: String, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} - -#[derive(Template)] -#[template(path = "build.html")] -pub(super) struct BuildTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) build: BuildView, - pub(super) builder_label: String, - pub(super) products: Vec, - pub(super) dependencies: Vec, - pub(super) dependents: Vec, - pub(super) broke_in: Option, - pub(super) eval_id: Uuid, - pub(super) eval_commit_short: String, - pub(super) jobset_id: Uuid, - pub(super) jobset_name: String, - pub(super) project_id: Uuid, - pub(super) project_name: String, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} - -#[derive(Template)] -#[template(path = "build_log.html")] -pub(super) struct BuildLogTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) build: BuildView, - pub(super) log: BuildLogView, - pub(super) eval_id: Uuid, - pub(super) eval_commit_short: String, - pub(super) jobset_id: Uuid, - pub(super) jobset_name: String, - pub(super) project_id: Uuid, - pub(super) project_name: String, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} - -#[derive(Template)] -#[template(path = "channels.html")] -pub(super) struct ChannelsTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) channels: Vec, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} - -pub(super) struct ChannelView { - pub(super) id: Uuid, - pub(super) name: String, - pub(super) current_evaluation_id: Option, - pub(super) updated_at: String, - pub(super) status_text: String, - pub(super) status_class: String, - pub(super) job_count: i64, -} - -#[derive(Template)] -#[template(path = "channel.html")] -pub(super) struct ChannelTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) channel: Channel, - pub(super) builds: Vec, - pub(super) succeeded_count: i64, - pub(super) failed_count: i64, - pub(super) pending_count: i64, - pub(super) is_admin: bool, - pub(super) auth_name: String, -} - -#[derive(Template)] -#[template(path = "news.html")] -pub(super) struct NewsTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) items: Vec, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, -} - pub(super) struct AgentView { pub(super) machine_id: Uuid, pub(super) name: String, @@ -312,90 +100,6 @@ mod tests { use super::*; - fn build( - id: Uuid, - job_name: &str, - status_text: &str, - status_class: &str, - ) -> BuildView { - BuildView { - id, - id_short: super::super::shared::short_uuid(id), - job_name: job_name.into(), - project_id: Some(Uuid::nil()), - project_name: "nh".into(), - jobset_id: Some(Uuid::nil()), - jobset_name: "default".into(), - status_text: status_text.into(), - status_class: status_class.into(), - system: "aarch64-linux".into(), - created_at: "2026-06-15 09:27".into(), - started_at: String::new(), - completed_at: String::new(), - duration: "1m 12s".into(), - started_epoch: None, - priority: 0, - is_aggregate: false, - signed: false, - drv_path: "/nix/store/very-long-derivation-path-that-should-truncate.drv" - .into(), - output_path: "/nix/store/very-long-output-path-that-should-truncate" - .into(), - error_message: String::new(), - error_lines: Vec::new(), - has_log: true, - } - } - - fn dashboard( - recent_builds: Vec, - failed_builds_list: Vec, - ) -> HomeTemplate { - HomeTemplate { - ui: UiTemplateConfig::from_config(&UiConfig::default()), - total_builds: 1859, - completed_builds: 1480, - failed_builds: 272, - running_builds: 1, - pending_builds: 70, - recent_builds, - failed_builds_list, - recent_evals: Vec::new(), - projects: vec![ProjectSummaryView { - id: Uuid::nil(), - name: - "very-long-project-name-that-needs-predictable-truncation".into(), - jobset_count: 2, - last_eval_status: "Succeeded".into(), - last_eval_class: "completed".into(), - last_eval_time: "2026-06-15 09:20".into(), - failing_jobs: 3, - queued_jobs: 5, - systems: "x86_64-linux, aarch64-linux".into(), - updated_at: "2026-06-15 09:21".into(), - }], - queue_by_system: vec![QueueSystemView { - system: "aarch64-linux".into(), - count: 70, - }], - workers: vec![WorkerSummaryView { - name: "builder-01".into(), - system: "aarch64-linux".into(), - status_text: "busy".into(), - status_class: "running".into(), - current_jobs: 1, - max_jobs: 4, - }], - system_filters: vec!["aarch64-linux".into(), "x86_64-linux".into()], - worker_online: 1, - worker_total: 1, - refreshed_at: "09:27 UTC".into(), - announcements: Vec::new(), - is_admin: true, - auth_name: "operator".into(), - } - } - #[test] fn declarative_project_hides_mutation_controls() { let html = ProjectTemplate { @@ -468,78 +172,6 @@ pub(super) struct ProjectSetupTemplate { pub(super) csrf_token: String, } -#[derive(Template)] -#[template(path = "login.html")] -pub struct LoginTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) error: Option, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) providers: Vec, - pub(super) password_login: bool, - /// Where to send the user once signed in. - pub(super) next: Option, -} - -pub(super) struct LoginProvider { - pub(super) href: String, - pub(super) label: String, - pub(super) icon: LoginIcon, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum LoginIcon { - Github, - Oidc, -} - -impl LoginTemplate { - pub fn new(config: &Config, error: Option) -> Self { - let github = config.oauth.github.as_ref().map(|_| { - LoginProvider { - href: "/api/v1/auth/github".into(), - label: "GitHub".into(), - icon: LoginIcon::Github, - } - }); - let oidc = config.oauth.oidc.iter().map(|(name, provider)| { - LoginProvider { - href: format!("/api/v1/auth/oidc/{name}"), - label: provider.display_name.clone(), - icon: LoginIcon::Oidc, - } - }); - let providers = github.into_iter().chain(oidc).collect(); - - Self { - ui: UiTemplateConfig::from_config(&config.ui), - error, - is_admin: false, - auth_name: String::new(), - providers, - password_login: config.server.password_login, - next: None, - } - } - - /// Carries `next` through every way of signing in on the page. - #[must_use] - pub fn with_next(mut self, next: Option<&str>) -> Self { - let Some(next) = crate::routes::return_to(next) else { - return self; - }; - - let query: String = url::form_urlencoded::Serializer::new(String::new()) - .append_pair("next", next) - .finish(); - for provider in &mut self.providers { - provider.href = format!("{}?{query}", provider.href); - } - self.next = Some(next.to_owned()); - self - } -} - #[derive(Template)] #[template(path = "users.html")] pub(super) struct UsersTemplate { @@ -557,58 +189,6 @@ pub(super) struct UsersTemplate { pub(super) csrf_token: String, } -#[derive(Template)] -#[template(path = "account.html")] -pub(super) struct AccountTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, - pub(super) username: String, - pub(super) role: String, - pub(super) has_password: bool, - pub(super) providers: Vec, -} - -#[derive(Template)] -#[template(path = "account_link.html")] -pub(super) struct AccountLinkTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, - pub(super) username: String, - pub(super) name: String, - pub(super) label: String, - pub(super) has_password: bool, - pub(super) error: Option, -} - -pub(super) struct AccountProvider { - pub(super) name: String, - pub(super) label: String, - pub(super) status: LinkStatus, -} - -#[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum LinkStatus { - /// The account was created by signing in with this provider. - Native, - Linked, - Unlinked, -} - -#[derive(Template)] -#[template(path = "starred.html")] -pub(super) struct StarredTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) starred_jobs: Vec, - pub(super) is_logged_in: bool, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) csrf_token: String, -} - #[derive(Template)] #[template(path = "metrics.html")] pub(super) struct MetricsTemplate { @@ -629,29 +209,6 @@ pub(super) struct NotificationsTemplate { pub(super) csrf_token: String, } -/// One row in the unified Caches listing. -pub(super) struct CacheRowView { - pub(super) name: String, - pub(super) scope_label: String, - pub(super) active: bool, - pub(super) nar_count: i64, - pub(super) compressed: String, - pub(super) requests_per_hour: i64, - pub(super) detail_href: String, -} - -#[derive(Template)] -#[template(path = "caches.html")] -pub(super) struct CachesTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) total_nars: i64, - pub(super) total_compressed: String, - pub(super) total_uncompressed: String, - pub(super) caches: Vec, -} - #[derive(Template)] #[template(path = "cache_detail.html")] #[expect( @@ -684,48 +241,3 @@ pub(super) struct CacheDetailTemplate { pub(super) gc_error: bool, pub(super) is_global: bool, } - -/// One row in the per-cache NAR inventory. -pub(super) struct NarRowView { - pub(super) hash: String, - pub(super) package: String, - pub(super) store_path: String, - pub(super) nar_size: String, - pub(super) nar_bytes: String, - pub(super) compressed: String, - pub(super) compressed_bytes: String, - pub(super) created_at: String, - pub(super) created_iso: String, - pub(super) last_fetched: String, - pub(super) last_fetched_iso: String, -} - -#[derive(Template)] -#[template(path = "cache_nars.html")] -pub(super) struct CacheNarsTemplate { - pub(super) ui: UiTemplateConfig, - pub(super) is_admin: bool, - pub(super) auth_name: String, - pub(super) name: String, - pub(super) scope_label: String, - pub(super) detail_href: String, - pub(super) filter_hash: String, - pub(super) filter_package: String, - pub(super) sort_headers: Vec, - pub(super) sort_key: String, - pub(super) sort_dir: String, - pub(super) total_nars: i64, - pub(super) nar_size: String, - pub(super) file_size: String, - pub(super) last_uploaded: String, - pub(super) last_uploaded_iso: String, - pub(super) oldest_fetched: String, - pub(super) oldest_fetched_iso: String, - pub(super) nars: Vec, - pub(super) page: i64, - pub(super) total_pages: i64, - pub(super) has_prev: bool, - pub(super) has_next: bool, - pub(super) prev_href: String, - pub(super) next_href: String, -} diff --git a/crates/server/src/routes/dashboard/views/account.rs b/crates/server/src/routes/dashboard/views/account.rs new file mode 100644 index 00000000..8c61f8e8 --- /dev/null +++ b/crates/server/src/routes/dashboard/views/account.rs @@ -0,0 +1,510 @@ +//! Sign-in and account pages. + +use std::fmt; + +use circus_common::models::User; +use circus_config::Config; +use serde::Deserialize; +use topcoat::{ + Result, + context::{Cx, app_context}, + router::{ + error::{internal_server_error, not_found, see_other}, + page, + path_param, + query_params, + }, + view::{View, component, view}, +}; +use url::form_urlencoded; + +use crate::{ + routes::{ + dashboard::{ + components::result_code, + layout::{document, signed_in}, + shared::DashboardContext, + }, + return_to, + }, + state::AppState, +}; + +/// Why a sign-in attempt failed, carried to the login page as `?error=`. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum SignInError { + InvalidPassword, + PasswordDisabled, + MissingKey, + InvalidKey, + MissingCredentials, + ProviderUnavailable, + AccountConflict, + AlreadyLinked, + ProviderFailed, +} + +impl SignInError { + #[must_use] + pub const fn message(self) -> &'static str { + match self { + Self::InvalidPassword => "Invalid username or password", + Self::PasswordDisabled => "Password sign-in is disabled", + Self::MissingKey => "API key is required", + Self::InvalidKey => "Invalid API key", + Self::MissingCredentials => { + "Please provide either username/password or API key" + }, + Self::ProviderUnavailable => { + "Sign-in is temporarily unavailable. Please try again." + }, + Self::AccountConflict => { + "An account already uses this username or email." + }, + Self::AlreadyLinked => { + "This sign-in is already linked to an account, or yours already has \ + one from this provider." + }, + Self::ProviderFailed => { + "Unable to sign in. Please try again or contact an administrator." + }, + } + } + + /// The login page showing this error, keeping a safe `next` target. + #[must_use] + pub fn login_href(self, next: Option<&str>) -> String { + let mut query = form_urlencoded::Serializer::new(String::new()); + query.append_pair("error", &self.to_string()); + + if let Some(next) = return_to(next) { + query.append_pair("next", next); + } + + format!("/login?{}", query.finish()) + } +} + +impl fmt::Display for SignInError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::InvalidPassword => "invalid-password", + Self::PasswordDisabled => "password-disabled", + Self::MissingKey => "missing-key", + Self::InvalidKey => "invalid-key", + Self::MissingCredentials => "missing-credentials", + Self::ProviderUnavailable => "provider-unavailable", + Self::AccountConflict => "account-conflict", + Self::AlreadyLinked => "already-linked", + Self::ProviderFailed => "provider-failed", + }) + } +} + +/// Why linking a provider failed, carried as `?error=`. +#[derive(Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum LinkError { + IncorrectPassword, +} + +impl LinkError { + const fn message(self) -> &'static str { + match self { + Self::IncorrectPassword => "Incorrect password", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Icon { + Github, + Oidc, +} + +struct LoginLink { + href: String, + label: String, + icon: Icon, +} + +fn providers(config: &Config, next: Option<&str>) -> Vec { + let query = next.map(|next| { + form_urlencoded::Serializer::new(String::new()) + .append_pair("next", next) + .finish() + }); + let href = |base: String| { + match &query { + Some(query) => format!("{base}?{query}"), + None => base, + } + }; + let github = config.oauth.github.as_ref().map(|_| { + LoginLink { + href: href("/api/v1/auth/github".to_owned()), + label: "GitHub".to_owned(), + icon: Icon::Github, + } + }); + let oidc = config.oauth.oidc.iter().map(|(name, provider)| { + LoginLink { + href: href(format!("/api/v1/auth/oidc/{name}")), + label: provider.display_name.clone(), + icon: Icon::Oidc, + } + }); + + github.into_iter().chain(oidc).collect() +} + +#[query_params(error = bad_request)] +struct LoginQuery { + next: Option, + /// Show the page even when it would hand straight off to a provider, so an + /// API key can still be entered. + local: Option, + error: Option, +} + +#[page("/login")] +async fn login(cx: &Cx) -> Result { + let state = app_context::(cx); + let query = query_params::(cx)?; + let sign_in_error = result_code::(query.error.as_deref()); + let next = return_to(query.next.as_deref()).map(str::to_owned); + let providers = providers(&state.config, next.as_deref()); + let password_login = state.config.server.password_login; + + if let [provider] = providers.as_slice() + && !password_login + && query.local.is_none() + && sign_in_error.is_none() + { + return Err(see_other(provider.href.clone()).into()); + } + + let (viewer, _) = signed_in(cx).await; + let brand = state.config.ui.brand_name.clone(); + let error = sign_in_error.map(SignInError::message); + + Ok(view! { + document(title: "Sign in", viewer: &viewer, hide_auth: true, + + ) + }) +} + +#[component] +async fn next_field(next: Option) -> Result { + Ok(view! { + if let Some(next) = next { + + } + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum LinkStatus { + /// The account was created by signing in with this provider. + Native, + Linked, + Unlinked, +} + +struct AccountProvider { + name: String, + label: String, + status: LinkStatus, +} + +/// The signed-in user, or a redirect to sign in that comes back to `next`. +async fn account_user( + cx: &Cx, + next: Option<&str>, +) -> Result<(DashboardContext, User)> { + let (viewer, user) = signed_in(cx).await; + let Some(user) = user else { + let href = next.map_or_else( + || "/login".to_owned(), + |next| format!("/login?next={next}"), + ); + return Err(see_other(href).into()); + }; + + Ok((viewer, user)) +} + +#[page("/account")] +async fn account(cx: &Cx) -> Result { + let state = app_context::(cx); + let (viewer, user) = account_user(cx, Some("/account")).await?; + let linked = + circus_common::repo::users::linked_providers(&state.pool, user.id) + .await + .map_err(|error| { + tracing::error!(user_id = %user.id, "failed to list linked identities: {error}"); + internal_server_error(error) + })?; + let native_issuer = + circus_common::repo::users::native_external_id(&state.pool, user.id) + .await + .map_err(|error| { + tracing::error!(user_id = %user.id, "failed to read account identity: {error}"); + internal_server_error(error) + })? + .and_then(|id| id.rsplit_once('#').map(|(issuer, _)| issuer.to_owned())); + let providers: Vec = state + .config + .oauth + .oidc + .iter() + .map(|(name, provider)| { + let native = native_issuer.as_deref().is_some_and(|issuer| { + issuer.trim_end_matches('/') + == provider.issuer_url.trim_end_matches('/') + }); + + AccountProvider { + name: name.clone(), + label: provider.display_name.clone(), + status: if native { + LinkStatus::Native + } else if linked.contains(name) { + LinkStatus::Linked + } else { + LinkStatus::Unlinked + }, + } + }) + .collect(); + let has_password = + state.config.server.password_login && user.password_hash.is_some(); + let role = user.role.to_string(); + let csrf_token = viewer.csrf_token.clone(); + + Ok(view! { + document(title: "Account", viewer: &viewer, + + + + ) + }) +} + +#[component] +async fn account_method( + provider: AccountProvider, + csrf_token: String, +) -> Result { + let unlink = format!("/account/unlink/{}", provider.name); + let link = format!("/account/link/{}", provider.name); + + Ok(view! { + + }) +} + +path_param!(provider); + +#[query_params(error = bad_request)] +struct LinkQuery { + error: Option, +} + +#[page("/account/link/{provider}")] +async fn account_link(cx: &Cx) -> Result { + let state = app_context::(cx); + let (viewer, user) = account_user(cx, None).await?; + let name = path_param::(cx).to_owned(); + let config = state.config.oauth.oidc.get(&name).ok_or_else(not_found)?; + let label = config.display_name.clone(); + let error = + result_code::(query_params::(cx)?.error.as_deref()) + .map(LinkError::message); + let has_password = user.password_hash.is_some(); + let action = format!("/account/link/{name}"); + let link_label = format!("Link {label}"); + + Ok(view! { + document(title: &link_label, viewer: &viewer, + + + ) + }) +} + +#[component] +async fn github_icon() -> Result { + Ok(view! { + + }) +} + +#[component] +async fn shield_icon() -> Result { + Ok(view! { + + }) +} + +#[component] +async fn key_icon() -> Result { + Ok(view! { + + }) +} + +#[component] +async fn lock_icon() -> Result { + Ok(view! { + + }) +} diff --git a/crates/server/src/routes/dashboard/views/builds.rs b/crates/server/src/routes/dashboard/views/builds.rs new file mode 100644 index 00000000..f053f2cc --- /dev/null +++ b/crates/server/src/routes/dashboard/views/builds.rs @@ -0,0 +1,1331 @@ +//! The dashboard home, the build list, a build, and its log. + +use std::{collections::HashMap, path::Path, time::Duration}; + +use circus_common::models::{BuildProduct, NewsItem}; +use jiff::Timestamp; +use topcoat::{ + Result, + context::{Cx, app_context}, + router::{error::RouterErrorExt, page, path_param, query_params}, + runtime::{connected, shard}, + view::{View, component, emit, live, view}, +}; +use uuid::Uuid; + +use super::super::{ + build_log::{BuildLogView, parse_build_log}, + layout::{document, shard_viewer, viewer}, + pages::{ + dashboard_system_filters, + derivation_name, + format_elapsed, + is_failed_status, + }, + shared::{ + BrokeInView, + BuildView, + DashboardPage, + EvalView, + Pagination, + ProjectSummaryView, + QueueSystemView, + WorkerSummaryView, + build_view, + build_view_with_context, + eval_view, + }, +}; +use crate::{operator, state::AppState}; + +path_param!(id: Uuid, error = not_found); + +#[query_params(error = bad_request)] +struct HomeQuery { + q: Option, + status: Option, +} + +#[page("/")] +async fn home_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Home).await?; + let state = app_context::(cx); + let query = query_params::(cx)?; + let search = query.q.clone().unwrap_or_default(); + let status = query.status.clone().unwrap_or_default(); + let include_hidden = viewer.is_admin; + let overview = operator::overview(state, include_hidden) + .await + .map_err(|error| error.0)?; + let recent_evals: Vec = + circus_common::repo::evaluations::list_filtered_with_visibility( + &state.pool, + None, + None, + 5, + 0, + include_hidden, + ) + .await + .unwrap_or_default() + .iter() + .map(eval_view) + .collect(); + let announcements = circus_common::repo::news::list(&state.pool, 3, 0) + .await + .unwrap_or_default(); + let needle = search.trim().to_lowercase(); + let recent_builds: Vec = overview + .recent_builds + .iter() + .map(BuildView::from) + .filter(|build| status.is_empty() || build.status_class == status) + .filter(|build| { + needle.is_empty() + || [&build.project_name, &build.job_name, &build.system] + .iter() + .any(|field| field.to_lowercase().contains(&needle)) + }) + .collect(); + let totals = Totals { + total: overview.total_builds, + succeeded: overview.completed_builds, + failed: overview.failed_builds, + running: overview.running_builds, + pending: overview.pending_builds, + }; + + Ok(view! { + document(title: "Dashboard", viewer: &viewer, topbar: false, + + announcement_lines(items: announcements) + metric_strip(totals: totals) +
+
+ projects_panel( + projects: overview.projects.iter().map(ProjectSummaryView::from).collect(), + is_admin: viewer.is_admin, + ) + recent_builds_panel(builds: recent_builds, search: search, status: status) + agents_panel( + workers: overview.workers.iter().map(WorkerSummaryView::from).collect(), + show_names: !viewer.auth_name.is_empty(), + ) +
+ +
+ ) + }) +} + +#[component] +async fn announcement_lines(items: Vec) -> Result { + Ok(view! { + if !items.is_empty() { +
+ for item in items { +
+ (item.title) ": " (item.content) + +
+ } +
+ } + }) +} + +struct Totals { + total: i64, + succeeded: i64, + failed: i64, + running: i64, + pending: i64, +} + +const fn share(part: i64, whole: i64) -> i64 { + if whole > 0 { part * 100 / whole } else { 0 } +} + +#[component] +async fn metric_strip(totals: Totals) -> Result { + let finished = totals.total - totals.running - totals.pending; + let success_rate = if finished > 0 { + format!("{}%", totals.succeeded * 100 / finished) + } else { + "-".to_owned() + }; + + Ok(view! { +
+ + "Total builds" + (totals.total) + "all tracked builds" + + + "Succeeded" + (totals.succeeded) + (share(totals.succeeded, totals.total)) "% of all builds" + + + "Failed" + (totals.failed) + (share(totals.failed, totals.total)) "% of all builds" + + + "Running" + (totals.running) + "currently running" + + + "Queued" + (totals.pending) + "pending now" + + + "Success rate" + (success_rate) + "completed / finished" + +
+ }) +} + +#[component] +async fn projects_panel( + projects: Vec, + is_admin: bool, +) -> Result { + Ok(view! { +
+
+

"Projects"

+ + "all projects" + if is_admin { + "new project" + } + +
+ if projects.is_empty() { +
+
"No projects yet"
+ if is_admin { +
+ "Create a project" + " to get started." +
+ } +
+ } else { +
+ + + + + + + + + + + + + + + for project in projects { + + + + + + + + + + + } + +
"Project""Jobsets""Last eval""Eval status""Failing jobs""Queued""Systems""Updated"
+ (project.name) + (project.jobset_count)(project.last_eval_time) + + (project.last_eval_status) + + + (project.failing_jobs) + + (project.queued_jobs) + (project.systems)(project.updated_at)
+
+ } +
+ }) +} + +#[component] +async fn recent_builds_panel( + builds: Vec, + search: String, + status: String, +) -> Result { + let options = [ + ("", "All status"), + ("failed", "Failed"), + ("succeeded", "Succeeded"), + ("running", "Running"), + ("pending", "Queued"), + ]; + + Ok(view! { +
+
+

"Builds"

+ "all builds" +
+
+ + + +
+ if builds.is_empty() { +
+
"No builds yet"
+
"Builds appear once an evaluation produces jobs."
+
+ } else { +
+ + + + + + + + + + + + + + + for build in builds { + + build_id_cell(id: build.id, id_short: build.id_short) + + + + + + + log_cell(id: build.id, has_log: build.has_log) + + } + +
"Build""Project""Job""System""Status""Duration""Created""Log"
+ entity_link(kind: "project", id: build.project_id, name: build.project_name) + (build.job_name)(build.system) + (build.status_text) + (build.duration)(build.created_at)
+
+ } +
+ }) +} + +#[component] +async fn agents_panel( + workers: Vec, + show_names: bool, +) -> Result { + Ok(view! { +
+
+

"Agents"

+ "manage" +
+ if workers.is_empty() { +
+
"No agents"
+
"No agent sessions have checked in."
+
+ } else { +
+ + + + + + + + + + + for (index, worker) in workers.into_iter().enumerate() { + + + + + + + } + +
"Agent""System""Status""Load"
+ if show_names { + (worker.name) + } else { + "Agent #" (index + 1) + } + (worker.system) + (worker.status_text) + (worker.current_jobs) "/" (worker.max_jobs)
+
+ } +
+ }) +} + +#[component] +async fn queue_panel(queue: Vec) -> Result { + Ok(view! { +
+
+

"Queue"

+ "queue" +
+

"Queued by system"

+ if queue.is_empty() { +
"No queued builds."
+ } else { + + } +
+ }) +} + +#[component] +async fn recent_evals_panel(evals: Vec) -> Result { + Ok(view! { +
+
+

"Recent evaluations"

+ "all" +
+ if evals.is_empty() { +
"No recent evaluations."
+ } else { + + } +
+ }) +} + +#[component] +async fn build_id_cell(id: Uuid, id_short: String) -> Result { + Ok(view! { + + + "#" (id_short) + + + }) +} + +/// A link to a project or jobset, or a dash when the build has lost it. +#[component] +async fn entity_link( + kind: &str, + id: Option, + name: String, +) -> Result { + Ok(view! { + match id { + Some(id) => (name), + None => "-", + } + }) +} + +#[component] +async fn log_cell(id: Uuid, has_log: bool) -> Result { + Ok(view! { + + if has_log { + "log" + } else { + "-" + } + + }) +} + +#[query_params(error = bad_request)] +pub(in crate::routes::dashboard) struct BuildFilterParams { + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + status: Option, + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + system: Option, + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + job_name: Option, + limit: Option, + offset: Option, +} + +#[page("/builds")] +async fn builds_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Builds).await?; + let state = app_context::(cx); + let params = query_params::(cx)?; + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let offset = params.offset.unwrap_or(0).max(0); + let items = circus_common::repo::builds::list_filtered( + &state.pool, + None, + params.status.as_deref(), + params.system.as_deref(), + params.job_name.as_deref(), + limit, + offset, + ) + .await + .unwrap_or_default(); + let total = circus_common::repo::builds::count_filtered( + &state.pool, + None, + params.status.as_deref(), + params.system.as_deref(), + params.job_name.as_deref(), + ) + .await + .unwrap_or(0); + let pagination = Pagination::new(total, offset, limit); + + let mut context_by_eval = HashMap::new(); + for item in &items { + if context_by_eval.contains_key(&item.evaluation_id) { + continue; + } + + let Ok(eval) = + circus_common::repo::evaluations::get(&state.pool, item.evaluation_id) + .await + else { + continue; + }; + let Ok(jobset) = + circus_common::repo::jobsets::get(&state.pool, eval.jobset_id).await + else { + continue; + }; + let Ok(project) = + circus_common::repo::projects::get(&state.pool, jobset.project_id).await + else { + continue; + }; + context_by_eval.insert( + item.evaluation_id, + (project.id, project.name, jobset.id, jobset.name), + ); + } + + let rows: Vec = items + .iter() + .map(|item| { + context_by_eval.get(&item.evaluation_id).map_or_else( + || build_view(item), + |(project_id, project_name, jobset_id, jobset_name)| { + build_view_with_context( + item, + *project_id, + project_name, + *jobset_id, + jobset_name, + ) + }, + ) + }) + .collect(); + let status = params.status.clone().unwrap_or_default(); + let system = params.system.clone().unwrap_or_default(); + let job = params.job_name.clone().unwrap_or_default(); + let page_href = |page_offset: i64| { + let query: String = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("offset", &page_offset.to_string()) + .append_pair("limit", &limit.to_string()) + .append_pair("status", &status) + .append_pair("system", &system) + .append_pair("job_name", &job) + .finish(); + format!("/builds?{query}") + }; + let prev_href = page_href(pagination.prev_offset); + let next_href = page_href(pagination.next_offset); + let statuses = [ + ("", "All"), + ("pending", "Pending"), + ("running", "Running"), + ("succeeded", "Succeeded"), + ("failed", "Failed"), + ]; + + Ok(view! { + document(title: "Builds", viewer: &viewer, +

"Builds"

+
+ + + + +
+ if rows.is_empty() { +
+
"No builds match filters"
+
"Try adjusting the filters above or wait for builds to be queued."
+
+ } else { +
+ + + + + + + + + + + + + + + + for build in rows { + + build_id_cell(id: build.id, id_short: build.id_short) + + + + + + + + log_cell(id: build.id, has_log: build.has_log) + + } + +
"Build""Project""Jobset""Job""System""Status""Duration""Created""Log"
+ entity_link(kind: "project", id: build.project_id, name: build.project_name) + + entity_link(kind: "jobset", id: build.jobset_id, name: build.jobset_name) + + (build.job_name) + (build.system) + (build.status_text) + (build.duration)(build.created_at)
+
+ if pagination.total_pages > 1 { + + } + } + ) + }) +} + +struct Lineage { + eval_id: Uuid, + commit_short: String, + jobset_id: Uuid, + jobset_name: String, + project_id: Uuid, + project_name: String, +} + +async fn lineage(state: &AppState, evaluation_id: Uuid) -> Result { + let eval = circus_common::repo::evaluations::get(&state.pool, evaluation_id) + .await + .ok() + .ok_or_not_found()?; + let jobset = circus_common::repo::jobsets::get(&state.pool, eval.jobset_id) + .await + .ok() + .ok_or_not_found()?; + let project = + circus_common::repo::projects::get(&state.pool, jobset.project_id) + .await + .ok() + .ok_or_not_found()?; + + Ok(Lineage { + eval_id: eval.id, + commit_short: eval.commit_hash.chars().take(12).collect(), + jobset_id: jobset.id, + jobset_name: jobset.name, + project_id: project.id, + project_name: project.name, + }) +} + +#[page("/build/{id}")] +async fn build_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Build).await?; + let state = app_context::(cx); + let id = *path_param::(cx)?; + let build = circus_common::repo::builds::get(&state.pool, id) + .await + .ok() + .ok_or_not_found()?; + let lineage = lineage(state, build.evaluation_id).await?; + let products = + circus_common::repo::build_products::list_for_build(&state.pool, id) + .await + .unwrap_or_default(); + let dependencies: Vec = + circus_common::repo::build_dependencies::list_dependency_builds( + &state.pool, + id, + ) + .await + .unwrap_or_default() + .iter() + .map(build_view) + .collect(); + let dependents: Vec = + circus_common::repo::build_dependencies::list_dependent_builds( + &state.pool, + id, + ) + .await + .unwrap_or_default() + .iter() + .map(build_view) + .collect(); + let broke_in = if is_failed_status(build.status) { + circus_common::repo::builds::broke_in(&state.pool, id) + .await + .unwrap_or_else(|error| { + tracing::warn!(build_id = %id, "Failed to find where the job broke: {error}"); + None + }) + .map(|found| { + BrokeInView { + build_id: found.build_id, + commit_short: found.commit_hash.chars().take(12).collect(), + commit_subject: found.commit_subject.unwrap_or_default(), + last_success_build_id: found.last_success_build_id, + last_success_short: found + .last_success_commit + .chars() + .take(12) + .collect(), + } + }) + } else { + None + }; + let builder_label = match build.agent_machine_id { + Some(machine_id) => { + circus_common::repo::builder_sessions::get(&state.pool, machine_id) + .await + .map_or_else(|_| "local".to_owned(), |session| session.name) + }, + None => "local".to_owned(), + }; + let build = build_view(&build); + let title = format!("Build {}", build.job_name); + + Ok(view! { + document(title: &title, viewer: &viewer, + +

"Build: " (build.job_name.clone())

+ build_overview(details: &build, builder_label: builder_label, broke_in: broke_in) +
+
+

"Reproduce This Build"

+
+
+

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

+
+ "Using Nix (flakes):" +
"nix build " (build.drv_path.clone()) "^*"
+
+
+ "Using legacy nix-build:" +
"nix-build " (build.drv_path.clone())
+
+

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

+
+
+
+
+

"Dependencies"

+
+ if dependencies.is_empty() && dependents.is_empty() { +
+
"No build dependency relationships recorded."
+
+ } else { +
+
+ related_builds(title: "Required Builds", empty: "No dependencies.", builds: dependencies) + related_builds(title: "Dependent Builds", empty: "No dependents.", builds: dependents) +
+
+ } +
+ products_panel(build_id: build.id, products: products) + ) + }) +} + +#[component] +async fn build_overview( + details: &BuildView, + builder_label: String, + broke_in: Option, +) -> Result { + let signed = if details.signed { "Yes" } else { "No" }; + + Ok(view! { +
+
+

"Overview"

+
+
+
+ meta_item(label: "Status", + (details.status_text.clone()) + ) + meta_item(label: "System", (details.system.clone())) + meta_item(label: "Builder", (builder_label)) + meta_item(label: "Created", (details.created_at.clone())) + if !details.started_at.is_empty() { + meta_item(label: "Started", (details.started_at.clone())) + } + if !details.completed_at.is_empty() { + meta_item(label: "Completed", (details.completed_at.clone())) + } + match (details.started_epoch, details.duration.is_empty()) { + (Some(started), true) => meta_item(label: "Elapsed", elapsed(started: started)), + (Some(started), false) => meta_item(label: "Duration", elapsed(started: started)), + (None, false) => meta_item(label: "Duration", (details.duration.clone())), + (None, true) => {}, + } + meta_item(label: "Priority", (details.priority)) + meta_item(label: "Signed", (signed)) + if details.is_aggregate { + meta_item(label: "Aggregate", "Yes") + } + meta_item(label: "Derivation", (details.drv_path.clone())) + if !details.output_path.is_empty() { + meta_item(label: "Output", (details.output_path.clone())) + } + if let Some(broke) = broke_in { +
+ "Broke in" + + if broke.build_id == details.id { + "This evaluation" + } else { + (broke.commit_short) + } + if !broke.commit_subject.is_empty() { + (broke.commit_subject) + } + + "Last passed at " + (broke.last_success_short) + + +
+ } +
+ if !details.error_lines.is_empty() { +
+

"Errors"

+
    + for line in &details.error_lines { +
  • (line.text.clone())
  • + } +
+
+ } else if !details.error_message.is_empty() { +
+

"Errors"

+
(details.error_message.clone())
+
+ } + if details.has_log { +

+ "View full log" +

+ } +
+
+ }) +} + +#[component] +async fn meta_item( + label: &str, + child: topcoat::view::Child<'_>, +) -> Result { + Ok(view! { +
+ (label) + (child) +
+ }) +} + +/// A running build's elapsed time, ticking once a second while the page is +/// open. +#[shard] +async fn elapsed(cx: &Cx, started: i64) -> Result { + shard_viewer(cx, DashboardPage::Build).await?; + + Ok(live! { + let mut tick = tokio::time::interval(Duration::from_secs(1)); + + loop { + tick.tick().await; + let secs = Timestamp::now().as_second().saturating_sub(started).max(0); + let token = emit! { (format_elapsed(secs)) }?; + + if !connected(cx) { + break Ok(token); + } + } + }) +} + +#[component] +async fn related_builds( + title: &str, + empty: &str, + builds: Vec, +) -> Result { + Ok(view! { +
+

(title)

+ if builds.is_empty() { +
+
(empty)
+
+ } else { +
+ + + + + + + + + + + for dep in builds { + + + + + + + } + +
"Job""Status""System""Output"
+ + (derivation_name(&dep.job_name)) + + + (dep.status_text) + (dep.system) + if dep.output_path.is_empty() { + "-" + } else { + (dep.output_path) + } +
+
+ } +
+ }) +} + +#[component] +async fn products_panel( + build_id: Uuid, + products: Vec, +) -> Result { + Ok(view! { +
+
+

"Build Products"

+
+ if products.is_empty() { +
+
"No products recorded."
+
+ } else { +
+ + + + + + + + + + + + + for product in products { + + + + + + + + + } + +
"Name""Path""Type""Size""SHA-256"
+ (product.name) " " + if product.is_directory { + "dir" + } + (product.path)(product.content_type.unwrap_or_else(|| "-".to_owned())) + match product.file_size { + Some(size) => (format!("{size} B")), + None => "-", + } + + match product.sha256_hash { + Some(hash) => (hash), + None => "-", + } + + + "Download" + +
+
+ } +
+ }) +} + +#[page("/build/{id}/log")] +async fn build_log_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Build).await?; + let state = app_context::(cx); + let id = *path_param::(cx)?; + let build = circus_common::repo::builds::get(&state.pool, id) + .await + .ok() + .ok_or_not_found()?; + circus_common::repo::evaluations::get_visible( + &state.pool, + build.evaluation_id, + viewer.is_admin, + ) + .await + .ok() + .ok_or_not_found()?; + let lineage = lineage(state, build.evaluation_id).await?; + let path = build + .log_path + .as_deref() + .filter(|path| !path.is_empty()) + .ok_or_not_found()?; + let path = crate::routes::canonical_log_file( + &state.config.logs.log_dir, + Path::new(path), + ) + .await + .ok_or_not_found()?; + let raw = tokio::fs::read_to_string(path) + .await + .ok() + .ok_or_not_found()?; + let log = parse_build_log(&raw); + let build = build_view(&build); + let title = format!("Log {}", build.job_name); + + Ok(view! { + document(title: &title, viewer: &viewer, + + log_page(details: &build, log: log) + ) + }) +} + +#[component] +async fn log_page(details: &BuildView, log: BuildLogView) -> Result { + let failed = details.status_class == "failed"; + + Ok(view! { +
+
+
"Build log"
+

(details.job_name.clone())

+
+ (details.status_text.clone()) + (details.system.clone()) + if !details.duration.is_empty() { + (details.duration.clone()) + } + "Build #" (details.id_short.clone()) +
+
+
+
+ (log.summary.visible_lines) + "lines" +
+
+ (log.summary.activity_count) + "activities" +
+
+ (log.summary.error_count) + "errors" +
+
+ (log.summary.warning_count) + "warnings" +
+
+
+
+ +
+
+

"Log Stream"

+ "Nix output" +
+ if log.lines.is_empty() { +
+
"No displayable log lines."
+
+ } else { +
    + for line in &log.lines { + if line.is_phase() { +
  1. + (line.number) + (line.text.clone()) +
  2. + } else { +
  3. + (line.number) + (line.text.clone()) +
  4. + } + } +
+ } +
+
+ }) +} + +#[cfg(test)] +mod tests { + use super::BuildFilterParams; + + #[test] + fn blank_filter_params_deserialize_to_none() { + let params = serde_urlencoded::from_str::( + "offset=50&limit=50&status=&system=&job_name=", + ) + .expect("deserialize query"); + assert_eq!(params.status, None); + assert_eq!(params.system, None); + assert_eq!(params.job_name, None); + assert_eq!(params.offset, Some(50)); + + let kept = serde_urlencoded::from_str::("status=failed") + .expect("deserialize query"); + assert_eq!(kept.status.as_deref(), Some("failed")); + } +} diff --git a/crates/server/src/routes/dashboard/views/evaluations.rs b/crates/server/src/routes/dashboard/views/evaluations.rs new file mode 100644 index 00000000..25502bce --- /dev/null +++ b/crates/server/src/routes/dashboard/views/evaluations.rs @@ -0,0 +1,1436 @@ +//! Evaluation and jobset pages. + +use std::{ + collections::{BTreeMap, HashMap, hash_map::Entry}, + time::Duration, +}; + +use circus_common::models::{ + Build, + BuildStatus, + Evaluation, + EvaluationStatus, + Jobset, + JobsetState, + JobsetTriggerMode, + Project, +}; +use jiff::Timestamp; +use topcoat::{ + Result, + context::{Cx, app_context}, + router::{error::not_found, page, path_param, query_params}, + runtime::{connected, shard}, + view::{View, component, emit, live, view}, +}; +use url::form_urlencoded; +use uuid::Uuid; + +use super::super::{ + components::{confirm_button, local_time}, + layout::{document, shard_viewer, viewer}, + pages::{ + format_elapsed, + is_failed_derivation_status, + is_failed_status, + is_job_name, + }, + shared::{ + BuildView, + DashboardPage, + DiagnosticSegment, + EvalProgressView, + EvalSummaryView, + EvalView, + JobStatusCell, + JobStatusColumn, + JobStatusRow, + Pagination, + build_view, + commit_url, + eval_badge, + eval_progress, + eval_running_since, + eval_view, + eval_view_with_context, + format_duration, + status_badge, + }, +}; +use crate::{routes::declarative::project_is_mutable, state::AppState}; + +/// Running evaluations tick their elapsed time every second and reload from +/// the database every third tick. +const TICK: Duration = Duration::from_secs(1); +const RELOAD_EVERY: u32 = 3; + +path_param!(id: Uuid, error = not_found); + +fn short_commit(hash: &str) -> String { + hash.get(..12).unwrap_or(hash).to_owned() +} + +/// A view-model timestamp in the viewer's timezone, or `-` when unset. +#[component] +async fn when(iso: &str, text: &str, zone: bool) -> Result { + Ok(view! { + if iso.is_empty() { + "-" + } else { + match iso.parse::() { + Ok(at) => local_time(at: at, zone: zone), + Err(_) => (text), + } + } + }) +} + +fn non_empty(value: &str) -> Option<&str> { + Some(value).filter(|value| !value.is_empty()) +} + +fn elapsed_since(epoch: i64) -> String { + format_elapsed((Timestamp::now().as_second() - epoch).max(0)) +} + +#[component] +async fn status_badges( + class: &str, + text: &str, + hidden: bool, +) -> Result { + Ok(view! { + (text) + if hidden { + "Hidden" + } + }) +} + +#[component] +async fn commit_cell( + id: Uuid, + short: &str, + subject: &str, + url: Option<&str>, +) -> Result { + Ok(view! { + + (short) + if !subject.is_empty() { + match url { + Some(url) => (subject), + None => (subject), + } + } + + }) +} + +#[component] +async fn visibility_form( + id: Uuid, + hidden: bool, + return_to: &str, + csrf_token: &str, + label: &str, +) -> Result { + let (value, verb) = if hidden { + ("false", "Unhide") + } else { + ("true", "Hide") + }; + + Ok(view! { +
+ + + + +
+ }) +} + +#[component] +async fn progress_bar(progress: &EvalProgressView) -> Result { + Ok(view! { + + + + + (progress.count.clone()) + + }) +} + +/// A list row whose clock and progress tick while its evaluation runs. +trait RunningRow { + fn id(&self) -> Uuid; + fn update(&mut self, eval: &Evaluation); + fn is_running(&self) -> bool; +} + +impl RunningRow for EvalView { + fn id(&self) -> Uuid { + self.id + } + + fn update(&mut self, eval: &Evaluation) { + self.running_since = eval_running_since(eval); + self.progress = eval_progress(eval); + } + + fn is_running(&self) -> bool { + self.running_since.is_some() + } +} + +impl RunningRow for EvalSummaryView { + fn id(&self) -> Uuid { + self.id + } + + fn update(&mut self, eval: &Evaluation) { + self.running_since = eval_running_since(eval); + self.progress = eval_progress(eval); + } + + fn is_running(&self) -> bool { + self.running_since.is_some() + } +} + +/// Refreshes only the running rows, since reloading the whole list every +/// tick costs a query per row. True once one stopped, so the caller reloads. +async fn refresh_running( + state: &AppState, + rows: &mut [impl RunningRow], + include_hidden: bool, +) -> bool { + let mut finished = false; + for row in rows.iter_mut().filter(|row| row.is_running()) { + match still_running(state, row.id(), include_hidden).await { + Some(eval) => row.update(&eval), + None => finished = true, + } + } + finished +} + +/// The evaluation, while it is still running and the viewer may see it. +async fn still_running( + state: &AppState, + id: Uuid, + include_hidden: bool, +) -> Option { + circus_common::repo::evaluations::get_visible(&state.pool, id, include_hidden) + .await + .ok() + .filter(|eval| eval.status == EvaluationStatus::Running) +} + +#[component] +async fn duration_cell( + running_since: Option, + progress: Option<&EvalProgressView>, + duration: &str, +) -> Result { + Ok(view! { + + match running_since { + Some(since) => { + (elapsed_since(since)) + if let Some(progress) = progress { + progress_bar(progress: progress) + } + }, + None => if duration.is_empty() { "-" } else { (duration) }, + } + + }) +} + +#[query_params(error = bad_request)] +struct EvaluationsQuery { + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + project: Option, + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + jobset: Option, + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + commit: Option, + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + status: Option, + limit: Option, + offset: Option, +} + +const EVAL_STATUSES: &[(&str, &str)] = &[ + ("pending", "Pending"), + ("running", "Running"), + ("completed", "Completed"), + ("failed", "Failed"), + ("cancelled", "Cancelled"), + ("timed_out", "Timed out"), +]; + +/// The jobset's name, its project's name, and the repository URL. +async fn jobset_names( + state: &AppState, + jobset_id: Uuid, +) -> (String, String, Option) { + let Ok(jobset) = + circus_common::repo::jobsets::get(&state.pool, jobset_id).await + else { + return ("-".to_owned(), "-".to_owned(), None); + }; + + match circus_common::repo::projects::get(&state.pool, jobset.project_id).await + { + Ok(project) => (jobset.name, project.name, Some(project.repository_url)), + Err(_) => (jobset.name, "-".to_owned(), None), + } +} + +async fn load_evaluations( + state: &AppState, + filter: circus_common::repo::evaluations::EvaluationListFilter<'_>, + limit: i64, + offset: i64, +) -> (Vec, i64) { + let items = circus_common::repo::evaluations::list_page_filtered( + &state.pool, + filter, + limit, + offset, + ) + .await + .unwrap_or_default(); + let total = + circus_common::repo::evaluations::count_page_filtered(&state.pool, filter) + .await + .unwrap_or(0); + + let mut names: HashMap)> = + HashMap::new(); + let mut evals = Vec::new(); + for e in &items { + let (jobset_name, project_name, repository_url) = + match names.entry(e.jobset_id) { + Entry::Occupied(slot) => slot.into_mut(), + Entry::Vacant(slot) => { + slot.insert(jobset_names(state, e.jobset_id).await) + }, + }; + evals.push(eval_view_with_context( + e, + jobset_name, + project_name, + repository_url.as_deref(), + )); + } + + (evals, total) +} + +#[page("/evaluations")] +async fn evaluations_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Evaluations).await?; + let query = query_params::(cx)?; + let field = |value: &Option| value.clone().unwrap_or_default(); + let status = field(&query.status); + let project = field(&query.project); + let jobset = field(&query.jobset); + let commit = field(&query.commit); + + Ok(view! { + document(title: "Evaluations", viewer: &viewer, + +

"Evaluations"

+ +
+ + + + + +
+ + evaluations_list( + status: status.clone(), + project: project.clone(), + jobset: jobset.clone(), + commit: commit.clone(), + limit: query.limit, + offset: query.offset, + ) + ) + }) +} + +/// The filtered list. While a listed evaluation runs it ticks and reloads, so +/// a finished evaluation gets its final row or drops out of the filter. +#[shard] +async fn evaluations_list( + cx: &Cx, + status: String, + project: String, + jobset: String, + commit: String, + limit: Option, + offset: Option, +) -> Result { + let mut ctx = shard_viewer(cx, DashboardPage::Evaluations).await?; + let state = app_context::(cx); + let per_page = limit.unwrap_or(50).clamp(1, 200); + let first = offset.unwrap_or(0).max(0); + + Ok(live! { + let page_href = |start: i64| { + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("offset", &start.to_string()) + .append_pair("limit", &per_page.to_string()) + .append_pair("status", &status) + .append_pair("project", &project) + .append_pair("jobset", &jobset) + .append_pair("commit", &commit) + .finish(); + format!("/evaluations?{query}") + }; + let load = async |include_hidden: bool| { + let filter = circus_common::repo::evaluations::EvaluationListFilter { + project: non_empty(&project), + jobset: non_empty(&jobset), + commit: non_empty(&commit), + status: non_empty(&status), + include_hidden, + }; + load_evaluations(state, filter, per_page, first).await + }; + + let (mut evals, mut total) = load(ctx.is_admin).await; + let mut tick = 0u32; + + loop { + let running = evals.iter().any(RunningRow::is_running); + let pagination = Pagination::new(total, first, per_page); + let token = emit! { + if evals.is_empty() { +
+
"No evaluations yet"
+
"Evaluations will appear here once a jobset is evaluated."
+
+ } else { +
+ + + + + + + + + + if ctx.is_admin { + + } + + + + + + + + + + + if ctx.is_admin { + + } + + + + #[key(e.id.as_u128())] + for e in evals.iter() { + + commit_cell( + id: e.id, + short: &e.commit_short, + subject: &e.commit_subject, + url: e.commit_url.as_deref(), + ) + + + + + + duration_cell(running_since: e.running_since, progress: e.progress.as_ref(), duration: &e.duration) + if ctx.is_admin { + + } + + } + +
"Commit""Project""Jobset""Status""Started""Finished""Duration""Actions"
(e.project_name.clone())(e.jobset_name.clone())status_badges(class: &e.status_class, text: &e.status_text, hidden: e.hidden)when(iso: &e.started_iso, text: &e.started, zone: false)when(iso: &e.finished_iso, text: &e.finished, zone: false) + visibility_form( + id: e.id, + hidden: e.hidden, + return_to: "/evaluations", + csrf_token: &ctx.csrf_token, + label: "", + ) +
+
+ if pagination.total_pages > 1 { + + } + } + }?; + + if !running || !connected(cx) { + break Ok(token); + } + + tokio::time::sleep(TICK).await; + tick += 1; + if tick.is_multiple_of(RELOAD_EVERY) { + let was_admin = ctx.is_admin; + ctx = shard_viewer(cx, DashboardPage::Evaluations).await?; + // Losing admin must drop the cached hidden rows too. + if ctx.is_admin != was_admin + || refresh_running(state, &mut evals, ctx.is_admin).await + { + (evals, total) = load(ctx.is_admin).await; + } + } + } + }) +} + +struct EvaluationData { + eval: EvalView, + status: EvaluationStatus, + builds: Vec, + failed_derivations: Vec, + project: Project, + jobset: Jobset, + succeeded: usize, + failed: usize, + running: usize, + pending: usize, +} + +async fn load_evaluation( + state: &AppState, + id: Uuid, + include_hidden: bool, +) -> Option { + let eval = circus_common::repo::evaluations::get_visible( + &state.pool, + id, + include_hidden, + ) + .await + .ok()?; + let jobset = circus_common::repo::jobsets::get(&state.pool, eval.jobset_id) + .await + .ok()?; + let project = + circus_common::repo::projects::get(&state.pool, jobset.project_id) + .await + .ok()?; + let builds = + circus_common::repo::builds::list_for_evaluation(&state.pool, id) + .await + .unwrap_or_default(); + + let top_level: Vec<&Build> = builds + .iter() + .filter(|build| is_job_name(&build.job_name)) + .collect(); + let count = |matches: fn(BuildStatus) -> bool| { + top_level + .iter() + .filter(|build| matches(build.status)) + .count() + }; + + let view = EvalView { + commit_url: commit_url(&project.repository_url, &eval.commit_hash), + ..eval_view(&eval) + }; + + Some(EvaluationData { + succeeded: count(|status| status == BuildStatus::Succeeded), + failed: count(is_failed_status), + running: count(|status| status == BuildStatus::Running), + pending: count(|status| status == BuildStatus::Pending), + failed_derivations: builds + .iter() + .filter(|build| is_failed_derivation_status(build.status)) + .map(build_view) + .collect(), + builds: top_level.into_iter().map(build_view).collect(), + status: eval.status, + eval: view, + project, + jobset, + }) +} + +#[page("/evaluation/{id}")] +async fn evaluation_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Evaluation).await?; + let state = app_context::(cx); + let id = *path_param::(cx)?; + let data = load_evaluation(state, id, viewer.is_admin) + .await + .ok_or_else(not_found)?; + let title = format!("Evaluation {}", data.eval.commit_short); + + Ok(view! { + document(title: &title, viewer: &viewer, + +

"Evaluation " (data.eval.commit_short.clone())

+ evaluation_body(id: id.to_string()) + ) + }) +} + +/// Everything below the heading. While the evaluation runs it ticks and +/// reloads, and its final render shows the finished builds. +#[shard] +async fn evaluation_body(cx: &Cx, id: String) -> Result { + let mut ctx = shard_viewer(cx, DashboardPage::Evaluation).await?; + let state = app_context::(cx); + let id = id.parse::().map_err(|_| not_found())?; + let first = load_evaluation(state, id, ctx.is_admin) + .await + .ok_or_else(not_found)?; + + Ok(live! { + let mut data = first; + let mut tick = 0u32; + + loop { + let running = data.status == EvaluationStatus::Running; + let token = emit! { + evaluation_details(data: &data, is_admin: ctx.is_admin, csrf_token: &ctx.csrf_token) + }?; + + if !running || !connected(cx) { + break Ok(token); + } + + tokio::time::sleep(TICK).await; + tick += 1; + if tick.is_multiple_of(RELOAD_EVERY) { + ctx = shard_viewer(cx, DashboardPage::Evaluation).await?; + if let Some(eval) = still_running(state, id, ctx.is_admin).await { + data.eval.running_since = eval_running_since(&eval); + data.eval.progress = eval_progress(&eval); + } else { + let Some(fresh) = load_evaluation(state, id, ctx.is_admin).await else { + break Ok(token); + }; + data = fresh; + } + } + } + }) +} + +#[component] +async fn evaluation_details( + data: &EvaluationData, + is_admin: bool, + csrf_token: &str, +) -> Result { + let eval = &data.eval; + let can_cancel = matches!(eval.status_class.as_str(), "running" | "pending"); + let can_restart = matches!( + eval.status_class.as_str(), + "failed" | "cancelled" | "timed-out" + ); + let return_to = format!("/evaluation/{}", eval.id); + + Ok(view! { + if is_admin { +
+ visibility_form( + id: eval.id, + hidden: eval.hidden, + return_to: &return_to, + csrf_token: csrf_token, + label: "Evaluation", + ) + if can_cancel { +
+ + confirm_button(prompt: "Cancel this evaluation?", class: "btn btn-small btn-danger", label: "Cancel Evaluation") +
+ } + if can_restart { +
+ + confirm_button( + prompt: "Restart this evaluation and discard its current builds?", + class: "btn btn-small btn-secondary", + label: "Restart Evaluation", + ) +
+ } +
+ } +
+
+ "Status" + + status_badges(class: &eval.status_class, text: &eval.status_text, hidden: eval.hidden) + +
+
+ "Queued" + + when(iso: &eval.time_iso, text: &eval.time, zone: true) + +
+
+ "Started" + + when(iso: &eval.started_iso, text: &eval.started, zone: true) + +
+ match eval.running_since { + Some(since) =>
+ "Elapsed" + (elapsed_since(since)) +
, + None => if !eval.duration.is_empty() { +
+ "Duration" + (eval.duration.clone()) +
+ }, + } + if let Some(progress) = eval.progress.as_ref() { +
+ "Attributes" + + (progress.count.clone()) + + + + +
+ } +
+ "Commit" + + match eval.commit_url.as_deref() { + Some(url) => (eval.commit_hash.clone()), + None => (eval.commit_hash.clone()), + } + if !eval.commit_subject.is_empty() { + (eval.commit_subject.clone()) + } + +
+ if let Some(replacement) = eval.superseded_by { +
+ "Superseded by" + + "newer evaluation" + +
+ } +
+ if !eval.error_message.is_empty() { +
+
+

"Evaluation Error"

+ "Nix diagnostics" +
+
+          for segment in eval.error_segments.iter() {
+            diagnostic_segment(segment: segment)
+          }
+        
+
+ } + +
+
+

"Build Outcomes"

+ "This evaluation" +
+
+ outcome_card(class: "outcome-success", count: data.succeeded, label: "Succeeded") + outcome_card(class: "outcome-failed", count: data.failed, label: "Failed") + outcome_card(class: "outcome-running", count: data.running, label: "Running") + outcome_card(class: "outcome-pending", count: data.pending, label: "Pending") +
+
+ + if !data.failed_derivations.is_empty() { +

"Failed Derivations"

+
+ + + + + + + + + + for b in data.failed_derivations.iter() { + + + + + + } + +
"Derivation""Status""System"
(b.job_name.clone())(b.status_text.clone())(b.system.clone())
+
+ } + +

"Builds"

+ if data.builds.is_empty() { +
"No builds for this evaluation."
+ } else { +
+ + + + + + + + + + + for b in data.builds.iter() { + + + + + + + } + +
"Job""Status""System""Created"
(b.job_name.clone())(b.status_text.clone())(b.system.clone())(b.created_at.clone())
+
+ } + }) +} + +#[component] +async fn diagnostic_segment(segment: &DiagnosticSegment) -> Result { + Ok(view! { + (segment.text.as_str()) + }) +} + +#[component] +async fn outcome_card( + class: &str, + count: usize, + label: &str, +) -> Result { + Ok(view! { +
+
(count)
+
(label)
+
+ }) +} + +async fn recent_evaluations( + state: &AppState, + jobset: Uuid, + include_hidden: bool, +) -> (Vec, Vec) { + let evals = circus_common::repo::evaluations::list_filtered_with_visibility( + &state.pool, + Some(jobset), + None, + 20, + 0, + include_hidden, + ) + .await + .unwrap_or_default(); + let eval_ids: Vec = evals.iter().map(|e| e.id).collect(); + let builds = circus_common::repo::builds::list_for_jobset_evaluations( + &state.pool, + jobset, + &eval_ids, + ) + .await + .unwrap_or_default(); + (evals, builds) +} + +async fn jobset_and_project( + state: &AppState, + id: Uuid, +) -> Option<(Jobset, Project)> { + let jobset = circus_common::repo::jobsets::get(&state.pool, id) + .await + .ok()?; + let project = + circus_common::repo::projects::get(&state.pool, jobset.project_id) + .await + .ok()?; + Some((jobset, project)) +} + +fn eval_summaries( + evals: &[Evaluation], + builds: &[Build], +) -> Vec { + let mut builds_by_eval: HashMap> = HashMap::new(); + for build in builds.iter().filter(|build| is_job_name(&build.job_name)) { + builds_by_eval + .entry(build.evaluation_id) + .or_default() + .push(build); + } + + evals + .iter() + .map(|e| { + let (status_text, status_class) = eval_badge(&e.status); + let eval_builds = builds_by_eval + .get(&e.id) + .map_or_else(|| &[][..], Vec::as_slice); + let count = |matches: fn(BuildStatus) -> bool| { + eval_builds + .iter() + .filter(|build| matches(build.status)) + .count() as i64 + }; + + EvalSummaryView { + id: e.id, + commit_short: short_commit(&e.commit_hash), + commit_subject: e.commit_subject.clone().unwrap_or_default(), + status_text, + status_class, + time: e.evaluation_time.strftime("%Y-%m-%d %H:%M UTC").to_string(), + time_iso: e.evaluation_time.to_string(), + duration: format_duration( + e.started_at.as_ref(), + e.finished_at.as_ref(), + ), + running_since: eval_running_since(e), + progress: eval_progress(e), + succeeded: count(|status| status == BuildStatus::Succeeded), + failed: count(|status| { + matches!( + status, + BuildStatus::Failed + | BuildStatus::DependencyFailed + | BuildStatus::FailedWithOutput + | BuildStatus::Timeout + | BuildStatus::CachedFailure + | BuildStatus::LogLimitExceeded + | BuildStatus::NarSizeLimitExceeded + | BuildStatus::NonDeterministic + ) + }), + pending: count(|status| status == BuildStatus::Pending), + hidden: e.hidden, + } + }) + .collect() +} + +#[component] +async fn jobset_breadcrumbs( + project: &Project, + jobset: &Jobset, + current: Option<&str>, +) -> Result { + Ok(view! { + + }) +} + +#[component] +async fn jobset_tabs(id: Uuid, jobs: bool) -> Result { + Ok(view! { + + }) +} + +#[page("/jobset/{id}")] +async fn jobset_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Jobset).await?; + let state = app_context::(cx); + let id = *path_param::(cx)?; + let (jobset, project) = + jobset_and_project(state, id).await.ok_or_else(not_found)?; + let mutable = project_is_mutable(state, &project); + + Ok(view! { + document(title: &jobset.name, viewer: &viewer, + jobset_breadcrumbs(project: &project, jobset: &jobset, current: None) +

(jobset.name.clone())

+ + jobset_tabs(id: jobset.id, jobs: false) + + if project.managed_declaratively && !mutable { +

"Managed by declarative configuration. Runtime changes are disabled."

+ } + + if viewer.is_admin && mutable { +
+
+ + confirm_button( + prompt: "Delete this jobset and all of its evaluations/builds?", + class: "btn btn-danger btn-small", + label: "Delete Jobset", + ) +
+
+ } + + jobset_details(jobset: &jobset) + + jobset_evaluations(id: jobset.id.to_string()) + ) + }) +} + +/// The latest evaluation's counts and the recent evaluations. While an +/// evaluation runs it ticks and reloads, so its final row and counts land. +#[shard] +async fn jobset_evaluations(cx: &Cx, id: String) -> Result { + let mut ctx = shard_viewer(cx, DashboardPage::Jobset).await?; + let state = app_context::(cx); + let id = id.parse::().map_err(|_| not_found())?; + let return_to = format!("/jobset/{id}"); + + Ok(live! { + let load = async |include_hidden: bool| { + let (evals, builds) = recent_evaluations(state, id, include_hidden).await; + eval_summaries(&evals, &builds) + }; + + let mut summaries = load(ctx.is_admin).await; + let mut tick = 0u32; + + loop { + let running = summaries.iter().any(RunningRow::is_running); + let token = emit! { + if let Some(latest) = summaries.first() { +

"Latest Evaluation"

+
+ stat_card(count: latest.succeeded, label: "Succeeded") + stat_card(count: latest.failed, label: "Failed") + stat_card(count: latest.pending, label: "Pending") +
+ } + +

"Recent Evaluations"

+ if summaries.is_empty() { +
+
"No evaluations yet"
+
+ "The evaluator will schedule this jobset based on its trigger mode and check interval." +
+
+ } else { +
+ + + + + + + + + + if ctx.is_admin { + + } + + + + + + + + + + + if ctx.is_admin { + + } + + + + #[key(e.id.as_u128())] + for e in summaries.iter() { + + commit_cell(id: e.id, short: &e.commit_short, subject: &e.commit_subject, url: None) + + + + + duration_cell(running_since: e.running_since, progress: e.progress.as_ref(), duration: &e.duration) + + if ctx.is_admin { + + } + + } + +
"Commit""Status""Succeeded""Failed""Pending""Duration""Time""Actions"
status_badges(class: &e.status_class, text: &e.status_text, hidden: e.hidden)(e.succeeded)(e.failed)(e.pending)when(iso: &e.time_iso, text: &e.time, zone: false) + visibility_form( + id: e.id, + hidden: e.hidden, + return_to: &return_to, + csrf_token: &ctx.csrf_token, + label: "", + ) +
+
+ } + }?; + + if !running || !connected(cx) { + break Ok(token); + } + + tokio::time::sleep(TICK).await; + tick += 1; + if tick.is_multiple_of(RELOAD_EVERY) { + let was_admin = ctx.is_admin; + ctx = shard_viewer(cx, DashboardPage::Jobset).await?; + // Losing admin must drop the cached hidden rows too. + if ctx.is_admin != was_admin + || refresh_running(state, &mut summaries, ctx.is_admin).await + { + summaries = load(ctx.is_admin).await; + } + } + } + }) +} + +#[component] +async fn stat_card(count: i64, label: &str) -> Result { + Ok(view! { +
+
(count)
+
(label)
+
+ }) +} + +#[component] +async fn jobset_details(jobset: &Jobset) -> Result { + let (state_class, state_text) = match jobset.state { + JobsetState::Disabled => ("cancelled", "Disabled"), + JobsetState::Enabled => ("completed", "Enabled"), + JobsetState::OneShot => ("pending", "One-Shot"), + JobsetState::OneAtATime => ("running", "One-at-a-Time"), + }; + let trigger = match jobset.trigger_mode { + JobsetTriggerMode::SourceChange => "Source change", + JobsetTriggerMode::Interval => "Interval rebuild", + }; + let yes_no = |value: bool| if value { "Yes" } else { "No" }; + + Ok(view! { +
+
"Expression"
+
(jobset.nix_expression.clone())
+
"Flake mode"
+
(yes_no(jobset.flake_mode))
+
"State"
+
(state_text)
+
"Trigger mode"
+
(trigger)
+
"Check interval"
+
(jobset.check_interval) "s"
+
"Only build latest"
+
(yes_no(jobset.only_build_latest))
+ if !jobset.path_filters.is_empty() { +
"Path filters"
+
+
    + for path_filter in jobset.path_filters.iter() { +
  • (path_filter.clone())
  • + } +
+
+ } + if let Some(systems) = jobset.systems.as_ref() { +
"Systems"
+
(systems.join(", "))
+ } +
"Last checked"
+
+ match jobset.last_checked_at { + Some(at) => (at.strftime("%Y-%m-%d %H:%M:%S").to_string()), + None => "Never", + } +
+
+ }) +} + +#[query_params(error = bad_request)] +struct JobsQuery { + show_inactive: Option, + #[serde( + default, + deserialize_with = "crate::routes::serde_util::empty_string_as_none" + )] + q: Option, +} + +impl JobsQuery { + fn show_inactive(&self) -> bool { + self + .show_inactive + .as_deref() + .is_some_and(|value| matches!(value, "1" | "true" | "yes" | "on")) + } +} + +fn job_history( + evals: &[Evaluation], + builds: Vec, + show_inactive: bool, + search: &str, +) -> (Vec, Vec) { + let latest_eval_id = evals.first().map(|e| e.id); + let columns: Vec = evals + .iter() + .map(|e| { + let hidden_suffix = if e.hidden { " (hidden)" } else { "" }; + JobStatusColumn { + eval_id: e.id, + label: e.evaluation_time.strftime("%m-%d %H:%M").to_string(), + title: format!("{}{hidden_suffix}", short_commit(&e.commit_hash)), + } + }) + .collect(); + + let mut builds_by_job: BTreeMap> = + BTreeMap::new(); + for build in builds + .into_iter() + .filter(|build| is_job_name(&build.job_name)) + { + builds_by_job + .entry(build.job_name.clone()) + .or_default() + .insert(build.evaluation_id, build); + } + + let search = search.to_lowercase(); + let mut rows = Vec::new(); + for (job_name, by_eval) in builds_by_job { + let is_active = + latest_eval_id.is_some_and(|eval_id| by_eval.contains_key(&eval_id)); + if (!show_inactive && !is_active) + || !job_name.to_lowercase().contains(&search) + { + continue; + } + let cells = columns + .iter() + .map(|column| { + by_eval.get(&column.eval_id).map_or_else( + || { + JobStatusCell { + href: String::new(), + status_text: "-".to_owned(), + status_class: "skipped".to_owned(), + } + }, + |build| { + let (status_text, status_class) = status_badge(build.status); + JobStatusCell { + href: format!("/build/{}", build.id), + status_text, + status_class, + } + }, + ) + }) + .collect(); + rows.push(JobStatusRow { + job_name, + is_active, + cells, + }); + } + + (columns, rows) +} + +#[page("/jobset/{id}/jobs")] +async fn jobset_jobs_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::JobsetJobs).await?; + let state = app_context::(cx); + let id = *path_param::(cx)?; + let query = query_params::(cx)?; + let show_inactive = query.show_inactive(); + let search = query.q.clone().unwrap_or_default(); + let (jobset, project) = + jobset_and_project(state, id).await.ok_or_else(not_found)?; + let (evals, builds) = recent_evaluations(state, id, viewer.is_admin).await; + let (columns, rows) = job_history(&evals, builds, show_inactive, &search); + let toggle_href = if show_inactive { + format!("/jobset/{}/jobs", jobset.id) + } else { + format!("/jobset/{}/jobs?show_inactive=true", jobset.id) + }; + + let title = format!("{} Jobs", jobset.name); + + Ok(view! { + document(title: &title, viewer: &viewer, + jobset_breadcrumbs(project: &project, jobset: &jobset, current: Some("Jobs")) +

(jobset.name.clone())

+ + jobset_tabs(id: jobset.id, jobs: true) + +
+
+ if show_inactive { + + } + +
+ + (if show_inactive { "Hide inactive jobs" } else { "Show inactive jobs" }) + +
+ + if columns.is_empty() { +
+
"No evaluations yet"
+
"Job status history will appear after this jobset evaluates."
+
+ } else if rows.is_empty() && !search.is_empty() { +
+
"No matching jobs"
+
"No job name contains that search."
+
+ } else if rows.is_empty() { +
+
"No active jobs"
+
"Use \"Show inactive jobs\" to include jobs missing from the latest evaluation."
+
+ } else { +
+ + + + + for column in columns.iter() { + + } + + + + for row in rows { + + + for cell in row.cells { + + } + + } + +
"Job" + (column.label.clone()) +
+ (row.job_name.clone()) + if !row.is_active { + "Inactive" + } + + if cell.href.is_empty() { + (cell.status_text) + } else { + (cell.status_text) + } +
+
+ } + ) + }) +} diff --git a/crates/server/src/routes/dashboard/views/misc.rs b/crates/server/src/routes/dashboard/views/misc.rs new file mode 100644 index 00000000..caa2b489 --- /dev/null +++ b/crates/server/src/routes/dashboard/views/misc.rs @@ -0,0 +1,924 @@ +//! Channels, news, starred jobs and the cache listings. + +use axum::extract::Query; +use circus_common::{ + CiError, + models::{BuildStatus, SortDirection}, + repo::narinfo_cache::{NarSort, NarSortColumn}, +}; +use jiff::Timestamp; +use topcoat::{ + Result, + context::{Cx, app_context}, + router::{ + error::{bad_request, internal_server_error, not_found}, + page, + path_param, + request, + }, + view::{View, component, view}, +}; +use uuid::Uuid; + +use super::super::{ + components::{confirm_button, local_time}, + layout::{document, viewer}, + shared::{ + BuildView, + CacheNarsParams, + DashboardContext, + DashboardPage, + Pagination, + StarredJobView, + build_view, + format_bytes, + format_exact_bytes, + status_badge, + store_path_hash, + }, + templates::SortHeaderView, +}; +use crate::{error::ApiError, state::AppState}; + +path_param!(id: Uuid, error = not_found); +path_param!(name); + +fn db_error(error: CiError) -> topcoat::Error { + internal_server_error(error).into() +} + +fn api_error(error: ApiError) -> topcoat::Error { + db_error(error.0) +} + +#[component] +async fn breadcrumbs( + trail: Vec<(String, String)>, + current: String, +) -> Result { + Ok(view! { + + }) +} + +#[component] +async fn empty(title: &str, hint: Option<&str>) -> Result { + Ok(view! { +
+
(title)
+ if let Some(hint) = hint { +
(hint)
+ } +
+ }) +} + +#[component] +async fn optional_time( + at: Option, + missing: &str, +) -> Result { + Ok(view! { + match at { + Some(at) => local_time(at: at, zone: false), + None => (missing.to_owned()), + } + }) +} + +#[page("/channels")] +async fn channels_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Channels).await?; + let state = app_context::(cx); + let channels = circus_common::repo::channels::list_all(&state.pool) + .await + .unwrap_or_default(); + + Ok(view! { + document(title: "Channels", viewer: &viewer, + breadcrumbs(trail: Vec::new(), current: "Channels".to_owned()) +

"Channels"

+ if channels.is_empty() { + empty( + title: "No channels configured", + hint: Some("Channels track successful evaluations for stable release tracking."), + ) + } else { +
+
+

"Channels"

+
+
+ + + + + + + + + + + + + #[key(*channel.id.as_bytes())] + for channel in channels { + + + + + + + + + } + +
"Name""Status""Jobs""Current Evaluation""Updated"
(channel.name) + if channel.current_evaluation_id.is_some() { + "Active" + } else { + "Pending" + } + "0" + match channel.current_evaluation_id { + Some(eval_id) => (eval_id.to_string()), + None => "-", + } + local_time(at: channel.updated_at, zone: false) + "Browse" +
+
+
+ } + ) + }) +} + +#[page("/channel/{id}")] +async fn channel_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Channel).await?; + let state = app_context::(cx); + let id = *path_param::(cx)?; + let channel = circus_common::repo::channels::get(&state.pool, id) + .await + .map_err(|_| not_found())?; + + let builds = match channel.current_evaluation_id { + Some(eval_id) => { + circus_common::repo::builds::list_for_evaluation(&state.pool, eval_id) + .await + .unwrap_or_default() + }, + None => Vec::new(), + }; + let count = |pred: fn(BuildStatus) -> bool| { + builds.iter().filter(|build| pred(build.status)).count() + }; + let succeeded = count(|status| status == BuildStatus::Succeeded); + let failed = count(|status| { + matches!( + status, + BuildStatus::Failed + | BuildStatus::FailedWithOutput + | BuildStatus::Timeout + | BuildStatus::DependencyFailed + | BuildStatus::Aborted + ) + }); + let pending = count(|status| { + matches!(status, BuildStatus::Pending | BuildStatus::Running) + }); + let builds: Vec = builds.iter().map(build_view).collect(); + let title = format!("Channel {}", channel.name); + let nix_channel = format!( + "nix-channel --add http:///api/v1/channels/{}/nixexprs.tar.xz {}", + channel.id, channel.name + ); + + Ok(view! { + document(title: &title, viewer: &viewer, + breadcrumbs( + trail: vec![("/channels".to_owned(), "Channels".to_owned())], + current: channel.name.clone(), + ) +

"Channel: " (channel.name.clone())

+
+
"Current Evaluation"
+
+ match channel.current_evaluation_id { + Some(eval_id) => (eval_id.to_string()), + None => "None", + } +
+
"Updated"
+
local_time(at: channel.updated_at, zone: false)
+
"Nix Channel"
+
(nix_channel)
+
+ if builds.is_empty() { + if channel.current_evaluation_id.is_none() { + empty( + title: "No evaluation promoted to this channel yet", + hint: Some("Use the API or admin panel to promote a completed evaluation."), + ) + } else { + empty(title: "No succeeded builds in current evaluation", hint: None) + } + } else { +
+
+

"Contents (" (builds.len()) " jobs)"

+ + if succeeded > 0 { + (succeeded) " succeeded" + } + if failed > 0 { + (failed) " failed" + } + if pending > 0 { + (pending) " pending" + } + +
+
+ + + + + + + + + + + for build in builds { + + + + + + + } + +
"Job""System""Status""Output"
(build.job_name)(build.system) + (build.status_text) + + if build.output_path.is_empty() { + "-" + } else { + (build.output_path) + } +
+
+
+ } + ) + }) +} + +#[page("/news")] +async fn news_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::News).await?; + let state = app_context::(cx); + let items = circus_common::repo::news::list(&state.pool, 50, 0) + .await + .unwrap_or_default(); + let csrf_token = viewer.csrf_token.clone(); + let is_admin = viewer.is_admin; + + Ok(view! { + document(title: "News", viewer: &viewer, +

"News"

+ if is_admin { +
+
+

"Post news item"

+
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+ } + if items.is_empty() { + empty(title: "No news", hint: Some("News items will appear here.")) + } else { +
+ #[key(*item.id.as_bytes())] + for item in items { +
+
+

(item.title)

+ + local_time(at: item.created_at, zone: false) + if is_admin { +
+ + confirm_button( + prompt: "Delete this news item?", + class: "btn btn-small btn-danger", + label: "Delete", + ) +
+ } +
+
+
+
(item.content)
+
+
+ } +
+ } + ) + }) +} + +/// The latest build of each starred job, from the newest visible evaluation +/// of its jobset. +async fn starred_jobs( + state: &AppState, + viewer: &DashboardContext, + user: Uuid, +) -> Vec { + let pool = &state.pool; + let starred = + circus_common::repo::starred_jobs::list_for_user(pool, user, 100, 0) + .await + .unwrap_or_default(); + let mut views = Vec::with_capacity(starred.len()); + + for star in starred { + let project_name = + circus_common::repo::projects::get(pool, star.project_id) + .await + .map_or_else(|_| "-".to_owned(), |project| project.name); + let mut jobset_name = "-".to_owned(); + let mut latest = None; + + if let Some(jobset_id) = star.jobset_id { + if let Ok(jobset) = + circus_common::repo::jobsets::get(pool, jobset_id).await + { + jobset_name = jobset.name; + } + + let evals = + circus_common::repo::evaluations::list_filtered_with_visibility( + pool, + Some(jobset_id), + None, + 1, + 0, + viewer.is_admin, + ) + .await + .unwrap_or_default(); + + if let Some(eval) = evals.first() { + latest = circus_common::repo::builds::list_filtered( + pool, + Some(eval.id), + None, + None, + Some(&star.job_name), + 1, + 0, + ) + .await + .unwrap_or_default() + .into_iter() + .next(); + } + } + + let (status_text, status_class) = latest.as_ref().map_or_else( + || ("No builds".to_owned(), "pending".to_owned()), + |build| status_badge(build.status), + ); + + views.push(StarredJobView { + id: star.id, + project_id: star.project_id, + project_name, + jobset_id: star.jobset_id, + jobset_name, + job_name: star.job_name, + status_text, + status_class, + latest_build_id: latest.map(|build| build.id), + }); + } + + views +} + +#[page("/starred")] +async fn starred_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Starred).await?; + let state = app_context::(cx); + let jobs = match viewer.viewer_user_id { + Some(user) => Some(starred_jobs(state, &viewer, user).await), + None => None, + }; + let csrf_token = viewer.csrf_token.clone(); + + Ok(view! { + document(title: "Starred Jobs", viewer: &viewer, +

"Starred Jobs"

+ match jobs { + None => { +
+
"Login required"
+
+ "Please " "login" " to view your starred jobs." +
+
+ }, + Some(jobs) => { + if jobs.is_empty() { + empty( + title: "No starred jobs", + hint: Some("Star jobs from project or build pages to track them here."), + ) + } else { +
+ + + + + + + + + + + + #[key(*job.id.as_bytes())] + for job in jobs { + + + + + + + + } + +
"Project""Jobset""Job Name""Latest Status""Actions"
(job.project_name) + match job.jobset_id { + Some(id) => (job.jobset_name), + None => "-", + } + (job.job_name) + (job.status_text) + +
+ + confirm_button( + prompt: "Remove this job from your starred list?", + class: "btn btn-small btn-secondary", + label: "Unstar", + ) +
+ if let Some(id) = job.latest_build_id { + "View Build" + } +
+
+ } + }, + } + ) + }) +} + +struct CacheRow { + name: String, + scope_label: String, + active: bool, + nar_count: i64, + compressed: String, + requests_per_hour: i64, +} + +#[page("/caches")] +async fn caches_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::Caches).await?; + let state = app_context::(cx); + let refs = crate::cache_overview::list_cache_refs(state) + .await + .map_err(api_error)?; + + // The unscoped (global) summary already covers every NAR. Summing the + // per-cache rows would double count project-scoped entries. + let totals = + circus_common::repo::narinfo_cache::storage_summary(&state.pool, None) + .await + .map_err(db_error)?; + + let mut rows = Vec::with_capacity(refs.len()); + for cache in refs { + let storage = circus_common::repo::narinfo_cache::storage_summary( + &state.pool, + cache.scope, + ) + .await + .map_err(db_error)?; + let (requests_per_hour, _bytes) = + circus_common::repo::cache_traffic::traffic_last_hour( + &state.pool, + &cache.name, + ) + .await + .map_err(db_error)?; + + rows.push(CacheRow { + scope_label: cache.scope_label().to_owned(), + name: cache.name, + active: cache.active, + nar_count: storage.nar_count, + compressed: format_bytes(storage.compressed_bytes), + requests_per_hour, + }); + } + + Ok(view! { + document(title: "Caches", viewer: &viewer, +

"Binary Caches"

+
+ stat_card(label: "Total NARs", value: totals.nar_count.to_string()) + stat_card(label: "Compressed", value: format_bytes(totals.compressed_bytes)) + stat_card(label: "Uncompressed", value: format_bytes(totals.uncompressed_bytes)) +
+ if rows.is_empty() { + empty( + title: "No caches configured", + hint: Some("Enable the global cache in config or enable a project's cache to see it here."), + ) + } else { +
+ + + + + + + + + + + + + + + + + + + + + for row in rows { + + + + + + + + + } + +
"Name""Scope""Status""NARs""Compressed""Requests/hr"
(row.name)(row.scope_label) + if row.active { + "Active" + } else { + "Inactive" + } + (row.nar_count)(row.compressed)(row.requests_per_hour)
+
+ } + ) + }) +} + +#[component] +async fn stat_card(label: &str, value: String) -> Result { + Ok(view! { +
+
(label)
+
(value)
+
+ }) +} + +/// Each header click steps its column through ascending, descending, and back +/// to the default newest-first order. +fn nar_sort_headers( + detail_href: &str, + params: &CacheNarsParams, + active: Option, +) -> Vec { + NarSortColumn::ALL + .into_iter() + .map(|column| { + let active_dir = active + .filter(|sort| sort.column == column) + .map(|sort| sort.direction); + let next_dir = match active_dir { + None => Some(SortDirection::Asc), + Some(SortDirection::Asc) => Some(SortDirection::Desc), + Some(SortDirection::Desc) => None, + }; + let next_sort = next_dir.map(|direction| NarSort { column, direction }); + + SortHeaderView { + key: column.as_str().to_owned(), + label: nar_sort_label(column).to_owned(), + href: nars_href(detail_href, params, next_sort, None), + default_dir: SortDirection::Asc.as_str().to_owned(), + active: active_dir.is_some(), + indicator: active_dir.map_or("", SortDirection::as_str).to_owned(), + aria_sort: match active_dir { + None => "none", + Some(SortDirection::Asc) => "ascending", + Some(SortDirection::Desc) => "descending", + } + .to_owned(), + } + }) + .collect() +} + +/// `page` is an `(offset, limit)` pair, and `None` starts from the first page. +fn nars_href( + detail_href: &str, + params: &CacheNarsParams, + sort: Option, + page: Option<(i64, i64)>, +) -> String { + let mut query = url::form_urlencoded::Serializer::new(String::new()); + if let Some(hash) = ¶ms.hash { + query.append_pair("hash", hash); + } + if let Some(package) = ¶ms.package { + query.append_pair("package", package); + } + if let Some(active) = sort { + query.append_pair("sort", active.column.as_str()); + query.append_pair("dir", active.direction.as_str()); + } + if let Some((offset, limit)) = page { + query.append_pair("offset", &offset.to_string()); + query.append_pair("limit", &limit.to_string()); + } + format!("{detail_href}/nars?{}", query.finish()) +} + +const fn nar_sort_label(column: NarSortColumn) -> &'static str { + match column { + NarSortColumn::Hash => "Hash", + NarSortColumn::Package => "Package", + NarSortColumn::NarSize => "NAR size", + NarSortColumn::Compressed => "Compressed", + NarSortColumn::Created => "Created", + NarSortColumn::LastFetched => "Last fetched", + } +} + +#[page("/caches/{name}/nars")] +async fn cache_nars_page(cx: &Cx) -> Result { + let viewer = viewer(cx, DashboardPage::CacheNars).await?; + let state = app_context::(cx); + let Query(params) = Query::::try_from_uri(request::uri(cx)) + .map_err(|_| bad_request("invalid NAR filter"))?; + let name = path_param::(cx); + let cache = crate::cache_overview::resolve_cache_ref(state, name) + .await + .map_err(api_error)? + .ok_or_else(not_found)?; + + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let offset = params.offset.unwrap_or(0).max(0); + let sort = params.sort.map(|column| { + NarSort { + column, + direction: params.dir.unwrap_or(SortDirection::Asc), + } + }); + let pool = &state.pool; + let hash = params.hash.clone(); + let package = params.package.clone(); + + let items = circus_common::repo::narinfo_cache::list_filtered( + pool, + cache.scope, + hash.as_deref(), + package.as_deref(), + sort, + limit, + offset, + ) + .await + .map_err(db_error)?; + let total = circus_common::repo::narinfo_cache::count_filtered( + pool, + cache.scope, + hash.as_deref(), + package.as_deref(), + ) + .await + .map_err(db_error)?; + let summary = + circus_common::repo::narinfo_cache::storage_summary(pool, cache.scope) + .await + .map_err(db_error)?; + let (last_uploaded, oldest_fetched) = + circus_common::repo::narinfo_cache::storage_extremes(pool, cache.scope) + .await + .map_err(db_error)?; + + let pagination = Pagination::new(total, offset, limit); + let detail_href = format!("/caches/{}", cache.name); + let prev_href = nars_href( + &detail_href, + ¶ms, + sort, + Some((pagination.prev_offset, limit)), + ); + let next_href = nars_href( + &detail_href, + ¶ms, + sort, + Some((pagination.next_offset, limit)), + ); + let sort_headers = nar_sort_headers(&detail_href, ¶ms, sort); + let title = format!("{} NARs", cache.name); + let scope_label = cache.scope_label().to_owned(); + + Ok(view! { + document(title: &title, viewer: &viewer, + breadcrumbs( + trail: vec![ + ("/caches".to_owned(), "Caches".to_owned()), + (detail_href.clone(), cache.name.clone()), + ], + current: "NARs".to_owned(), + ) +
+
+

(cache.name.clone()) " · NARs"

+ (scope_label) +
+
+
+ stat_card(label: "Total NARs", value: summary.nar_count.to_string()) + stat_card(label: "NAR size", value: format_bytes(summary.uncompressed_bytes)) + stat_card(label: "File size", value: format_bytes(summary.compressed_bytes)) +
+
"Last uploaded"
+
optional_time(at: last_uploaded, missing: "-")
+
+
+
"Oldest fetched"
+
optional_time(at: oldest_fetched, missing: "-")
+
+
+
+ + + if let Some(active) = sort { + + + } + +
+ if items.is_empty() { + empty( + title: "No NARs match filters", + hint: Some("Adjust the filters above, or wait for builds to upload paths."), + ) + } else { +
+ + + + + + + + + + + + for header in sort_headers { + + } + + + + #[key(item.store_path.clone())] + for item in items { + nar_row( + hash: store_path_hash(&item.store_path), + package: item.package_name, + store_path: item.store_path, + nar_size: item.nar_size, + file_size: item.file_size, + created_at: item.created_at, + last_fetched_at: item.last_fetched_at, + ) + } + +
+ + (header.label) + + +
+
+ if pagination.total_pages > 1 { + + } + } + ) + }) +} + +#[component] +async fn nar_row( + hash: String, + package: String, + store_path: String, + nar_size: i64, + file_size: Option, + created_at: Timestamp, + last_fetched_at: Option, +) -> Result { + Ok(view! { + + (hash.clone()) + +
+ (package.clone()) +
+ + "/nix/store/" + (hash) + "-" (package) + + +
+
+ + (format_bytes(nar_size)) + + (file_size.map_or_else(|| "-".to_owned(), format_bytes)) + + local_time(at: created_at, zone: false) + optional_time(at: last_fetched_at, missing: "Never") + + }) +} diff --git a/crates/server/src/routes/dashboard/views/mod.rs b/crates/server/src/routes/dashboard/views/mod.rs new file mode 100644 index 00000000..d4d0b724 --- /dev/null +++ b/crates/server/src/routes/dashboard/views/mod.rs @@ -0,0 +1,6 @@ +//! Topcoat views for each dashboard area. + +pub mod account; +pub mod builds; +pub mod evaluations; +pub mod misc; diff --git a/crates/server/src/routes/mod.rs b/crates/server/src/routes/mod.rs index 9f3787d1..6ebfa2b4 100644 --- a/crates/server/src/routes/mod.rs +++ b/crates/server/src/routes/mod.rs @@ -42,6 +42,7 @@ use axum::{ }; use circus_config::Config; use dashmap::DashMap; +use topcoat::asset::AssetBundle; use tower_http::{ cors::{AllowOrigin, Any, CorsLayer}, limit::RequestBodyLimitLayer, @@ -321,7 +322,7 @@ pub fn return_to(raw: Option<&str>) -> Option<&str> { }) } -pub fn public_router(config: &Config) -> Router { +pub fn public_router(config: &Config, login_page: bool) -> Router { let mut router = Router::new() .merge(health::router()) .merge(badges::router()) @@ -332,7 +333,7 @@ pub fn public_router(config: &Config) -> Router { .merge(webhooks::router()) // OAuth and LDAP routes use their own auth mechanisms. .merge(oauth::router()) - .merge(oidc::router()) + .merge(oidc::router(login_page)) .merge(ldap::router()); if config.server.openapi_enabled { @@ -342,7 +343,11 @@ pub fn public_router(config: &Config) -> Router { router } -pub fn ui_router(state: AppState, config: &Config) -> Router { +pub fn ui_router( + state: AppState, + config: &Config, + dashboard_assets: Option, +) -> Router { let mut router = Router::new(); if config.ui.assets_enabled() { @@ -359,25 +364,10 @@ pub fn ui_router(state: AppState, config: &Config) -> Router { } } - if config.ui.dashboard_enabled() { - // Topcoat resolves the session itself, since its connected reruns never - // pass through axum's middleware. - match dashboard::assets::load() { - Ok(assets) => { - let live = dashboard::live::service(state.clone(), assets); - router = router - .route_service("/queue", live.clone()) - .route_service("/_topcoat/{*rest}", live); - }, - Err(error) => { - tracing::error!( - "dashboard assets unavailable, live pages disabled: {error}" - ); - }, - } - + if let Some(assets) = dashboard_assets { + let live = dashboard::live::service(state.clone(), assets); router = router.merge( - dashboard::router() + dashboard::router(live) .route_layer(middleware::from_fn_with_state(state, extract_session)), ); } @@ -385,7 +375,11 @@ pub fn ui_router(state: AppState, config: &Config) -> Router { router } -pub fn router(state: AppState, config: &Config) -> Router { +/// # Errors +/// +/// Returns an error when the dashboard is enabled but its asset bundle is +/// missing, see `circus-server --bundle-assets`. +pub fn router(state: AppState, config: &Config) -> std::io::Result { let server_config = &config.server; let cors_layer = if server_config.cors_permissive { tracing::warn!( @@ -407,12 +401,20 @@ pub fn router(state: AppState, config: &Config) -> Router { CorsLayer::new().allow_origin(AllowOrigin::list(origins)) }; + // Every dashboard page needs the runtime, so a missing bundle fails startup + // rather than leaving a server without its login page. + let dashboard_assets = config + .ui + .dashboard_enabled() + .then(dashboard::assets::load) + .transpose()?; + let mut app = Router::new() .nest("/api/v1", api_router(state.clone())) - .merge(public_router(config)); + .merge(public_router(config, dashboard_assets.is_some())); if config.ui.enabled { - app = app.merge(ui_router(state.clone(), config)); + app = app.merge(ui_router(state.clone(), config, dashboard_assets)); } app = app @@ -449,7 +451,7 @@ pub fn router(state: AppState, config: &Config) -> Router { )); } - app.with_state(state) + Ok(app.with_state(state)) } #[cfg(test)] diff --git a/crates/server/src/routes/oidc.rs b/crates/server/src/routes/oidc.rs index 44050e4f..0c723272 100644 --- a/crates/server/src/routes/oidc.rs +++ b/crates/server/src/routes/oidc.rs @@ -1,11 +1,11 @@ use std::{collections::BTreeMap, error::Error, sync::Arc, time::Duration}; -use askama::Template; use axum::{ + Extension, Router, extract::{Path, Query, State, rejection::QueryRejection}, http::{StatusCode, header::SET_COOKIE}, - response::{AppendHeaders, Html, IntoResponse, Redirect, Response}, + response::{AppendHeaders, IntoResponse, Redirect, Response}, routing::get, }; use axum_extra::extract::cookie::CookieJar; @@ -35,7 +35,7 @@ use uuid::Uuid; use crate::{ audit::record_with_actor, oauth_client::{AuthCodeClient, ClientAuth, Secret, TokenError}, - routes::dashboard::templates::LoginTemplate, + routes::dashboard::views::account::SignInError, session_cookie::{ OIDC_FLOW_COOKIE, USER_SESSION_MAX_AGE_SECS, @@ -210,17 +210,12 @@ impl LoginError { } } - const fn message(&self) -> &'static str { + const fn sign_in_error(&self) -> SignInError { match self { - Self::Unavailable(_) => { - "Sign-in is temporarily unavailable. Please try again." - }, - Self::Conflict(_) => "An account already uses this username or email.", - Self::AlreadyLinked(_) => { - "This sign-in is already linked to an account, or yours already has \ - one from this provider." - }, - _ => "Unable to sign in. Please try again or contact an administrator.", + Self::Unavailable(_) => SignInError::ProviderUnavailable, + Self::Conflict(_) => SignInError::AccountConflict, + Self::AlreadyLinked(_) => SignInError::AlreadyLinked, + _ => SignInError::ProviderFailed, } } } @@ -479,6 +474,7 @@ async fn login_failure( state: &AppState, provider: &str, error: LoginError, + login_page: bool, ) -> Response { tracing::debug!(%provider, ?error, "OIDC login failed"); @@ -495,12 +491,6 @@ async fn login_failure( .await; } - let html = LoginTemplate::new(&state.config, Some(error.message().into())) - .render() - .unwrap_or_else(|render_error| { - tracing::error!(%render_error, "Unable to render OIDC login failure"); - error.message().to_owned() - }); let redirect_uri = state .config .oauth @@ -508,15 +498,17 @@ async fn login_failure( .get(provider) .map_or("", |config| config.redirect_uri.as_str()); - ( - error.status(), - [( - SET_COOKIE, - clear_oidc_flow_cookie(&state.config.server, redirect_uri), - )], - Html(html), - ) - .into_response() + let cookie = [( + SET_COOKIE, + clear_oidc_flow_cookie(&state.config.server, redirect_uri), + )]; + let sign_in_error = error.sign_in_error(); + + if login_page { + (cookie, Redirect::to(&sign_in_error.login_href(None))).into_response() + } else { + (error.status(), cookie, sign_in_error.message()).into_response() + } } async fn start_login( @@ -823,12 +815,13 @@ struct LoginParams { async fn oidc_login( State(state): State, + Extension(LoginPage(login_page)): Extension, Path(provider): Path, Query(params): Query, ) -> Response { match start_login(&state, &provider, None, params.next.as_deref()).await { Ok(response) => response, - Err(error) => login_failure(&state, &provider, error).await, + Err(error) => login_failure(&state, &provider, error, login_page).await, } } @@ -865,26 +858,32 @@ pub async fn start_link( ) -> Response { match start_login(state, provider, Some(user), None).await { Ok(response) => response, - Err(error) => login_failure(state, provider, error).await, + Err(error) => login_failure(state, provider, error, true).await, } } async fn oidc_callback( State(state): State, + Extension(LoginPage(login_page)): Extension, Path(provider): Path, jar: CookieJar, params: Result, QueryRejection>, ) -> Response { match complete_login(&state, &provider, &jar, params).await { Ok(response) => response, - Err(error) => login_failure(&state, &provider, error).await, + Err(error) => login_failure(&state, &provider, error, login_page).await, } } -pub fn router() -> Router { +/// Whether the dashboard's `/login` is mounted to show a failed sign-in. +#[derive(Clone, Copy)] +struct LoginPage(bool); + +pub fn router(login_page: bool) -> Router { Router::new() .route("/api/v1/auth/oidc/{provider}", get(oidc_login)) .route("/api/v1/auth/oidc/{provider}/callback", get(oidc_callback)) + .layer(Extension(LoginPage(login_page))) } #[cfg(test)] diff --git a/crates/server/templates/account.html b/crates/server/templates/account.html deleted file mode 100644 index cb92fe12..00000000 --- a/crates/server/templates/account.html +++ /dev/null @@ -1,64 +0,0 @@ -{% extends "base.html" %} -{% block title %}Account - circus{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} - - - -{% endblock %} diff --git a/crates/server/templates/account_link.html b/crates/server/templates/account_link.html deleted file mode 100644 index d5b94bb3..00000000 --- a/crates/server/templates/account_link.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "base.html" %} -{% block title %}Link {{ label }} - circus{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} - -{% endblock %} diff --git a/crates/server/templates/build.html b/crates/server/templates/build.html deleted file mode 100644 index 42410dd7..00000000 --- a/crates/server/templates/build.html +++ /dev/null @@ -1,358 +0,0 @@ -{% extends "base.html" %} -{% block title %}Build {{ build.job_name }} - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

Build: {{ build.job_name }}

- -
-
-

Overview

-
-
-
-
- Status - - {{ build.status_text }} - -
-
- System - {{ build.system }} -
-
- Builder - {{ builder_label }} -
-
- Created - {{ build.created_at }} -
- {% if !build.started_at.is_empty() %} -
- Started - {{ build.started_at }} -
- {% endif %} - {% if !build.completed_at.is_empty() %} -
- Completed - {{ build.completed_at }} -
- {% endif %} - {% if !build.duration.is_empty() %} -
- Duration - - {{ build.duration }} - -
- {% elif let Some(ts) = build.started_epoch %} -
- Elapsed - - -
- {% endif %} -
- Priority - {{ build.priority }} -
-
- Signed - - {% if build.signed %} - Yes - {% else %} - No - {% endif %} - -
- {% if build.is_aggregate %} -
- Aggregate - Yes -
- {% endif %} -
- Derivation - - {{ build.drv_path }} - -
- {% if !build.output_path.is_empty() %} -
- Output - - {{ build.output_path }} - -
- {% endif %} - {% if let Some(broke) = broke_in %} -
- Broke in - - {% if broke.build_id == build.id %} - This evaluation - {% else %} - - {#- -#} - {{- broke.commit_short -}} - {#- -#} - - {% endif %} - {% if !broke.commit_subject.is_empty() %} - {{ broke.commit_subject }} - {% endif %} - - Last passed at - - {#- -#} - {{- broke.last_success_short -}} - {#- -#} - - - -
- {% endif %} -
- - {% if !build.error_lines.is_empty() %} -
-

Errors

-
    - {% for line in build.error_lines %} -
  • {{ line.text }}
  • - {% endfor %} -
-
- {% elif !build.error_message.is_empty() %} -
-

Errors

-
-{{ build.error_message }}
-
- {% endif %} - {% if build.has_log %} -

- View full 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. -

-
-
- -
-
-

Dependencies

-
- {% if dependencies.is_empty() && dependents.is_empty() %} -
-
No build dependency relationships recorded.
-
- {% else %} -
-
-
-

Required Builds

- {% if dependencies.is_empty() %} -
-
No dependencies.
-
- {% else %} -
- - - - - - - - - - - {% for dep in dependencies %} - - - - - - - {% endfor %} - -
JobStatusSystemOutput
- {{ dep.job_name }} - - - {{ dep.status_text }} - - {{ dep.system }} - {% if !dep.output_path.is_empty() %} - {{ dep.output_path }} - {% else %} - - - {% endif %} -
-
- {% endif %} -
-
-

Dependent Builds

- {% if dependents.is_empty() %} -
-
No dependents.
-
- {% else %} -
- - - - - - - - - - - {% for dep in dependents %} - - - - - - - {% endfor %} - -
JobStatusSystemOutput
- {{ dep.job_name }} - - - {{ dep.status_text }} - - {{ dep.system }} - {% if !dep.output_path.is_empty() %} - {{ dep.output_path }} - {% else %} - - - {% endif %} -
-
- {% endif %} -
-
-
- {% endif %} -
- -
-
-

Build Products

-
- {% if products.is_empty() %} -
-
No products recorded.
-
- {% else %} -
- - - - - - - - - - - - - {% for p in products %} - - - - - - - - - {% endfor %} - -
NamePathTypeSizeSHA-256
- {{ p.name }} - {% if p.is_directory %}dir{% endif %} - - {{ p.path }} - - {% match p.content_type %} - {% when Some with (ct) %} - {{ ct }} - {% when None %} - - - {% endmatch %} - - {% match p.file_size %} - {% when Some with (sz) %} - {{ sz }} B - {% when None %} - - - {% endmatch %} - - {% match p.sha256_hash %} - {% when Some with (h) %} - {{ h }} - {% when None %} - - - {% endmatch %} - - - Download - -
-
- {% endif %} -
-{% endblock %} diff --git a/crates/server/templates/build_log.html b/crates/server/templates/build_log.html deleted file mode 100644 index 975928e2..00000000 --- a/crates/server/templates/build_log.html +++ /dev/null @@ -1,143 +0,0 @@ -{% extends "base.html" %} -{% block title %}Build {{ build.job_name }} log - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -
-
-
Build log
-

{{ build.job_name }}

-
- {{ build.status_text }} - {{ build.system }} - {% if !build.duration.is_empty() %}{{ build.duration }}{% endif %} - Build #{{ build.id_short }} -
-
-
-
- {{ log.summary.visible_lines }} - lines -
-
- {{ log.summary.activity_count }} - activities -
-
- {{ log.summary.error_count }} - errors -
-
- {{ log.summary.warning_count }} - warnings -
-
-
- -
- - -
-
-

Log Stream

- Nix output -
- {% if log.lines.is_empty() %} -
-
No displayable log lines.
-
- {% else %} -
    - {% for line in log.lines %} - {% if line.is_phase() %} -
  1. - {{ line.number }} - {{ line.text }} -
  2. - {% else %} -
  3. - {{ line.number }} - {{ line.text }} -
  4. - {% endif %} - {% endfor %} -
- {% endif %} -
-
-{% endblock %} diff --git a/crates/server/templates/builds.html b/crates/server/templates/builds.html deleted file mode 100644 index 699a485f..00000000 --- a/crates/server/templates/builds.html +++ /dev/null @@ -1,120 +0,0 @@ -{% extends "base.html" %} -{% block title %}Builds - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block content %} -

Builds

- -
- - - - -
- - {% if builds.is_empty() %} -
-
No builds match filters
-
Try adjusting the filters above or wait for builds to be queued.
-
- {% else %} -
- - - - - - - - - - - - - - - - {% for b in builds %} - - - - - - - - - - - - {% endfor %} - -
BuildProjectJobsetJobSystemStatusDurationCreatedLog
- - #{{ b.id_short }} - - - {% match b.project_id %} - {% when Some with (project_id) %} - {{ b.project_name }} - {% when None %} - - - {% endmatch %} - - {% match b.jobset_id %} - {% when Some with (jobset_id) %} - {{ b.jobset_name }} - {% when None %} - - - {% endmatch %} - - {{ b.job_name }} - {{ b.system }} - {{ b.status_text }} - {{ b.duration }}{{ b.created_at }} - {% if b.has_log %} - log - {% else %} - - - {% endif %} -
-
- {% if total_pages > 1 %} - - {% endif %} - {% endif %} -{% endblock %} diff --git a/crates/server/templates/cache_nars.html b/crates/server/templates/cache_nars.html deleted file mode 100644 index 48ff8555..00000000 --- a/crates/server/templates/cache_nars.html +++ /dev/null @@ -1,189 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ name }} NARs - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - -{% block content %} -
-
-

{{ name }} · NARs

- {{ scope_label }} -
-
- -
-
-
Total NARs
-
{{ total_nars }}
-
-
-
NAR size
-
{{ nar_size }}
-
-
-
File size
-
{{ file_size }}
-
-
-
Last uploaded
-
- {% if last_uploaded_iso.is_empty() %} - {{ last_uploaded }} - {% else %} - - {% endif %} -
-
-
-
Oldest fetched
-
- {% if oldest_fetched_iso.is_empty() %} - {{ oldest_fetched }} - {% else %} - - {% endif %} -
-
-
- -
- - - {% if !sort_key.is_empty() %} - - - {% endif %} - -
- - {% if nars.is_empty() %} -
-
No NARs match filters
-
Adjust the filters above, or wait for builds to upload paths.
-
- {% else %} -
- - - - - - - - - - - - {% for header in sort_headers %} - - {% endfor %} - - - - {% for n in nars %} - - - - - - - - - {% endfor %} - -
- - {{ header.label }} - - -
- {{ n.hash }} - -
- {{ n.package }} -
- - {#- -#} - {{- "/nix/store/" -}} - - {#- -#} - {{- n.hash -}} - - {#- -#} - {{- "-" }}{{ n.package -}} - - {#- -#} - -
-
-
{{ n.nar_size }}{{ n.compressed }} - - - {% if n.last_fetched_iso.is_empty() %} - {{ n.last_fetched }} - {% else %} - - {% endif %} -
-
- {% if total_pages > 1 %} - - {% endif %} - {% endif %} -{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/crates/server/templates/caches.html b/crates/server/templates/caches.html deleted file mode 100644 index 06bd4100..00000000 --- a/crates/server/templates/caches.html +++ /dev/null @@ -1,91 +0,0 @@ -{% extends "base.html" %} -{% block title %}Caches - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} - -{% block breadcrumbs %} - -{% endblock %} - -{% block content %} -

Binary Caches

- -
-
-
Total NARs
-
{{ total_nars }}
-
-
-
Compressed
-
{{ total_compressed }}
-
-
-
Uncompressed
-
{{ total_uncompressed }}
-
-
- - {% if caches.is_empty() %} -
-
No caches configured
-
Enable the global cache in config or enable a project's cache to see it here.
-
- {% else %} -
- - - - - - - - - - - - - - - - - - - - - {% for c in caches %} - - - - - - - - - {% endfor %} - -
NameScopeStatusNARsCompressedRequests/hr
- {{ c.name }} - - {{ c.scope_label }} - - {% if c.active %} - Active - {% else %} - Inactive - {% endif %} - {{ c.nar_count }}{{ c.compressed }}{{ c.requests_per_hour }}
-
- {% endif %} -{% endblock %} diff --git a/crates/server/templates/channel.html b/crates/server/templates/channel.html deleted file mode 100644 index 5d42d8c6..00000000 --- a/crates/server/templates/channel.html +++ /dev/null @@ -1,105 +0,0 @@ -{% extends "base.html" %} -{% block title %}Channel {{ channel.name }} - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

Channel: {{ channel.name }}

- -
-
Current Evaluation
-
- {% match channel.current_evaluation_id %} - {% when Some with (eval_id) %} - {{ eval_id }} - {% when None %} - None - {% endmatch %} -
-
Updated
-
- {{ channel.updated_at.strftime("%Y-%m-%d %H:%M UTC") }} -
-
Nix Channel
-
- nix-channel --add http://<server>/api/v1/channels/{{ channel.id }}/nixexprs.tar.xz {{ channel.name }} -
-
- - {% if builds.is_empty() %} -
- {% match channel.current_evaluation_id %} - {% when None %} -
No evaluation promoted to this channel yet
-
Use the API or admin panel to promote a completed evaluation.
- {% when Some with (_) %} -
No succeeded builds in current evaluation
- {% endmatch %} -
- {% else %} -
-
-

Contents ({{ builds.len() }} jobs)

- - {% if succeeded_count > 0 %} - {{ succeeded_count }} succeeded - {% endif %} - {% if failed_count > 0 %} - {{ failed_count }} failed - {% endif %} - {% if pending_count > 0 %} - {{ pending_count }} pending - {% endif %} - -
-
- - - - - - - - - - - {% for b in builds %} - - - - - - - {% endfor %} - -
JobSystemStatusOutput
- {{ b.job_name }} - {{ b.system }} - {{ b.status_text }} - - {% if !b.output_path.is_empty() %} - {{ b.output_path }} - {% else %} - - - {% endif %} -
-
-
- {% endif %} -{% endblock %} diff --git a/crates/server/templates/channels.html b/crates/server/templates/channels.html deleted file mode 100644 index c5b4deaf..00000000 --- a/crates/server/templates/channels.html +++ /dev/null @@ -1,74 +0,0 @@ -{% extends "base.html" %} -{% block title %}Channels - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

Channels

- - {% if channels.is_empty() %} -
-
No channels configured
-
Channels track successful evaluations for stable release tracking.
-
- {% else %} -
-
-

Channels

-
-
- - - - - - - - - - - - - {% for c in channels %} - - - - - - - - - {% endfor %} - -
NameStatusJobsCurrent EvaluationUpdated
- {{ c.name }} - - {{ c.status_text }} - {{ c.job_count }} - {% match c.current_evaluation_id %} - {% when Some with (eval_id) %} - {{ eval_id }} - {% when None %} - - - {% endmatch %} - {{ c.updated_at }} - Browse -
-
-
- {% endif %} -{% endblock %} diff --git a/crates/server/templates/evaluation.html b/crates/server/templates/evaluation.html deleted file mode 100644 index eb5c52cd..00000000 --- a/crates/server/templates/evaluation.html +++ /dev/null @@ -1,220 +0,0 @@ -{% extends "base.html" %} -{% block title %}Evaluation {{ eval.commit_short }} - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

Evaluation {{ eval.commit_short }}

- {% if is_admin %} -
-
- - - {% if eval.hidden %} - - - {% else %} - - - {% endif %} -
- {% if eval.status_class == "running" || eval.status_class == "pending" %} -
- - -
- {% endif %} - {% if eval.status_class == "failed" || eval.status_class == "cancelled" || eval.status_class == "timed-out" %} -
- - -
- {% endif %} -
- {% endif %} -
-
- Status - - {{ eval.status_text }} - {% if eval.hidden %}Hidden{% endif %} - -
-
- Queued - - - -
-
- Started - - {% if eval.started_iso.is_empty() %} - - - {% else %} - - {% endif %} - -
- {% if let Some(ts) = eval.running_since %} -
- Elapsed - - -
- {% elif !eval.duration.is_empty() %} -
- Duration - {{ eval.duration }} -
- {% endif %} - {% if let Some(progress) = eval.progress %} -
- Attributes - - {{ progress.count }} - - - - -
- {% endif %} -
- Commit - - {% match eval.commit_url %} - {% when Some with (url) %} - {{ eval.commit_hash }} - {% when None %} - {{ eval.commit_hash }} - {% endmatch %} - {% if !eval.commit_subject.is_empty() %} - {{ eval.commit_subject }} - {% endif %} - -
- {% if let Some(replacement) = eval.superseded_by %} -
- Superseded by - - newer evaluation - -
- {% endif %} -
- {% if !eval.error_message.is_empty() %} -
-
-

Evaluation Error

- Nix diagnostics -
-
{% for segment in eval.error_segments %}{{ segment.text }}{% endfor %}
-
- {% endif %} - -
-
-

Build Outcomes

- This evaluation -
-
-
-
{{ succeeded_count }}
-
Succeeded
-
-
-
{{ failed_count }}
-
Failed
-
-
-
{{ running_count }}
-
Running
-
-
-
{{ pending_count }}
-
Pending
-
-
-
- - {% if !failed_derivations.is_empty() %} -

Failed Derivations

-
- - - - - - - - - - {% for b in failed_derivations %} - - - - - - {% endfor %} - -
DerivationStatusSystem
{{ b.job_name }}{{ b.status_text }}{{ b.system }}
-
- {% endif %} - -

Builds

- {% if builds.is_empty() %} -
No builds for this evaluation.
- {% else %} -
- - - - - - - - - - - {% for b in builds %} - - - - - - - {% endfor %} - -
JobStatusSystemCreated
- {{ b.job_name }} - - {{ b.status_text }} - {{ b.system }}{{ b.created_at }}
-
- {% endif %} -{% endblock %} diff --git a/crates/server/templates/evaluations.html b/crates/server/templates/evaluations.html deleted file mode 100644 index e316c2f9..00000000 --- a/crates/server/templates/evaluations.html +++ /dev/null @@ -1,175 +0,0 @@ -{% extends "base.html" %} -{% block title %}Evaluations - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

Evaluations

- -
- - - - - -
- - {% if evals.is_empty() %} -
-
No evaluations yet
-
Evaluations will appear here once a jobset is evaluated.
-
- {% else %} -
- - - - - - - - - - {% if is_admin %}{% endif %} - - - - - - - - - - - {% if is_admin %}{% endif %} - - - - {% for e in evals %} - - - - - - - - {% if let Some(ts) = e.running_since %} - - {% elif !e.duration.is_empty() %} - - {% else %} - - {% endif %} - {% if is_admin %} - - {% endif %} - - {% endfor %} - -
CommitProjectJobsetStatusStartedFinishedDurationActions
- {{ e.commit_short }} - {% if !e.commit_subject.is_empty() %} - {% match e.commit_url %} - {% when Some with (url) %} - {{ e.commit_subject }} - {% when None %} - - {{ e.commit_subject }} - - {% endmatch %} - {% endif %} - {{ e.project_name }}{{ e.jobset_name }} - {{ e.status_text }} - {% if e.hidden %}Hidden{% endif %} - - {% if e.started_iso.is_empty() %} - - - {% else %} - - {% endif %} - - {% if e.finished_iso.is_empty() %} - - - {% else %} - - {% endif %} - - - - {% if let Some(progress) = e.progress %} - - - - - - {{- progress.count -}} - - - {% endif %} - {{ e.duration }}- -
- - - {% if e.hidden %} - - - {% else %} - - - {% endif %} -
-
-
- {% if total_pages > 1 %} - - {% endif %} - {% endif %} -{% endblock %} diff --git a/crates/server/templates/home.html b/crates/server/templates/home.html deleted file mode 100644 index d5487347..00000000 --- a/crates/server/templates/home.html +++ /dev/null @@ -1,488 +0,0 @@ -{% extends "base.html" %} -{% block title %}Dashboard - circus{% endblock %} -{% block topbar %} -{% endblock %} -{% block topbar_title %}Dashboard{% endblock %} -{% block topbar_context %}Nix evaluations, builds, workers, and queue state{% endblock %} -{% block content %} - - - {% if !announcements.is_empty() %} -
- {% for item in announcements %} -
- - {{ item.title }}: {{ item.content }} - - -
- {% endfor %} -
- {% endif %} - - {% let finished_builds = total_builds - running_builds - pending_builds %} -
- - Total builds - {{ total_builds }}all tracked builds - - - Succeeded - {{ completed_builds }} - {% if total_builds > 0 %} - {{ completed_builds * 100 / total_builds }}% of all builds - {% else %} - 0% of all builds - {% endif %} - - - - Failed - {{ failed_builds }} - {% if total_builds > 0 %} - {{ failed_builds * 100 / total_builds }}% of all builds - {% else %} - 0% of all builds - {% endif %} - - - - Running - {{ running_builds }}currently running - - - Queued - {{ pending_builds }}pending now - - - Success rate - - {% if finished_builds > 0 %} - {{ completed_builds * 100 / finished_builds }}% - {% else %} - - - {% endif %} - completed / finished - -
-
-
-
-
-

Projects

- - all projects - {% if is_admin %}new project{% endif %} - -
- {% if projects.is_empty() %} -
-
No projects yet
- {% if is_admin %} -
- Create a project - to get started. -
- {% endif %} -
- {% else %} -
- - - - - - - - - - - - - - - {% for p in projects %} - - - - - - - - - - - {% endfor %} - -
ProjectJobsetsLast evalEval statusFailing jobsQueuedSystemsUpdated
- {{ p.name }} - {{ p.jobset_count }}{{ p.last_eval_time }} - - {{ p.last_eval_status }} - - - {{ p.failing_jobs }} - - {{ p.queued_jobs }} - {{ p.systems }}{{ p.updated_at }}
-
- {% endif %} -
- -
-
-

Builds

- all builds -
-
- - - Open selected -
- {% if recent_builds.is_empty() %} -
-
No builds yet
-
Builds appear once an evaluation produces jobs.
-
- {% else %} -
- - - - - - - - - - - - - - - {% for b in recent_builds %} - - - - - - - - - - - {% endfor %} - -
BuildProjectJobSystemStatusDurationCreatedLog
- - #{{ b.id_short }} - - - {% match b.project_id %} - {% when Some with (project_id) %} - {{ b.project_name }} - {% when None %} - - - {% endmatch %} - {{ b.job_name }}{{ b.system }} - {{ b.status_text }} - {{ b.duration }}{{ b.created_at }} - {% if b.has_log %} - log - {% else %} - - - {% endif %} -
-
- {% endif %} -
- -
-
-

Agents

- manage -
- {% if workers.is_empty() %} -
-
No agents
-
No agent sessions have checked in.
-
- {% else %} -
- - - - - - - - - - - {% for w in workers %} - - - - - - - {% endfor %} - -
AgentSystemStatusLoad
- {% if !auth_name.is_empty() %} - {{ w.name }} - {% else %} - Agent #{{ loop.index }} - {% endif %} - {{ w.system }} - {{ w.status_text }} - {{ w.current_jobs }}/{{ w.max_jobs }}
-
- {% endif %} -
-
- - -
-{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/crates/server/templates/jobset.html b/crates/server/templates/jobset.html deleted file mode 100644 index 02e266fb..00000000 --- a/crates/server/templates/jobset.html +++ /dev/null @@ -1,225 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ jobset.name }} - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

{{ jobset.name }}

- - - - {% if project.managed_declaratively && !project_mutable %} -

Managed by declarative configuration. Runtime changes are disabled.

- {% endif %} - - {% if is_admin && project_mutable %} -
-
- - -
-
- {% endif %} - -
-
Expression
-
- {{ jobset.nix_expression }} -
-
Flake mode
-
- {% if jobset.flake_mode %} - Yes - {% else %} - No - {% endif %} -
-
State
-
- {% match jobset.state %} - {% when circus_common::models::JobsetState::Disabled %} - Disabled - {% when circus_common::models::JobsetState::Enabled %} - Enabled - {% when circus_common::models::JobsetState::OneShot %} - One-Shot - {% when circus_common::models::JobsetState::OneAtATime %} - One-at-a-Time - {% endmatch %} -
-
Trigger mode
-
- {% match jobset.trigger_mode %} - {% when circus_common::models::JobsetTriggerMode::SourceChange %} - Source change - {% when circus_common::models::JobsetTriggerMode::Interval %} - Interval rebuild - {% endmatch %} -
-
Check interval
-
- {{ jobset.check_interval }}s -
-
Only build latest
-
{% if jobset.only_build_latest %}Yes{% else %}No{% endif %}
- {% if !jobset.path_filters.is_empty() %} -
Path filters
-
-
    - {% for path_filter in jobset.path_filters %}
  • {{ path_filter }}
  • {% endfor %} -
-
- {% endif %} - {% if let Some(systems) = jobset.systems %} -
Systems
-
{{ systems|join(", ") }}
- {% endif %} -
Last checked
-
- {% if let Some(t) = jobset.last_checked_at %} - {{ t.strftime("%Y-%m-%d %H:%M:%S") }} - {% else %} - Never - {% endif %} -
-
- - {% if !eval_summaries.is_empty() %} -

Latest Evaluation

- {% let latest = eval_summaries[0] %} -
-
-
{{ latest.succeeded }}
-
Succeeded
-
-
-
{{ latest.failed }}
-
Failed
-
-
-
{{ latest.pending }}
-
Pending
-
-
- {% endif %} - -

Recent Evaluations

- {% if eval_summaries.is_empty() %} -
-
No evaluations yet
-
- The evaluator will schedule this jobset based on its trigger mode and check interval. -
-
- {% else %} -
- - - - - - - - - - {% if is_admin %}{% endif %} - - - - - - - - - - - {% if is_admin %}{% endif %} - - - - {% for e in eval_summaries %} - - - - - - - {% if let Some(ts) = e.running_since %} - - {% elif !e.duration.is_empty() %} - - {% else %} - - {% endif %} - - {% if is_admin %} - - {% endif %} - - {% endfor %} - -
CommitStatusSucceededFailedPendingDurationTimeActions
- {{ e.commit_short }} - {% if !e.commit_subject.is_empty() %} - - {{ e.commit_subject }} - - {% endif %} - - {{ e.status_text }} - {% if e.hidden %}Hidden{% endif %} - {{ e.succeeded }}{{ e.failed }}{{ e.pending }} - - - {% if let Some(progress) = e.progress %} - - - - - - {{- progress.count -}} - - - {% endif %} - {{ e.duration }}- - - -
- - - {% if e.hidden %} - - - {% else %} - - - {% endif %} -
-
-
- {% endif %} -{% endblock %} diff --git a/crates/server/templates/jobset_jobs.html b/crates/server/templates/jobset_jobs.html deleted file mode 100644 index 0ee3f5a0..00000000 --- a/crates/server/templates/jobset_jobs.html +++ /dev/null @@ -1,111 +0,0 @@ -{% extends "base.html" %} -{% block title %}{{ jobset.name }} Jobs - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

{{ jobset.name }}

- - - -
- - {% if show_inactive %} - Hide inactive jobs - {% else %} - - Show inactive jobs - - {% endif %} -
- - {% if columns.is_empty() %} -
-
No evaluations yet
-
Job status history will appear after this jobset evaluates.
-
- {% else if rows.is_empty() %} -
-
No active jobs
-
Use "Show inactive jobs" to include jobs missing from the latest evaluation.
-
- {% else %} -
- - - - - {% for column in columns %} - - {% endfor %} - - - - {% for row in rows %} - - - {% for cell in row.cells %} - - {% endfor %} - - {% endfor %} - -
Job - {{ column.label }} -
- {{ row.job_name }} - {% if !row.is_active %}Inactive{% endif %} - - {% if !cell.href.is_empty() %} - - {{ cell.status_text }} - - {% else %} - {{ cell.status_text }} - {% endif %} -
-
- {% endif %} -{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/crates/server/templates/login.html b/crates/server/templates/login.html deleted file mode 100644 index 6baa0a25..00000000 --- a/crates/server/templates/login.html +++ /dev/null @@ -1,69 +0,0 @@ -{% extends "base.html" %} -{% block title %}Sign in - {{ ui.brand_name }}{% endblock %} -{% block auth %} -{% endblock %} -{% block content %} - -{% endblock %} diff --git a/crates/server/templates/news.html b/crates/server/templates/news.html deleted file mode 100644 index 2dffc140..00000000 --- a/crates/server/templates/news.html +++ /dev/null @@ -1,71 +0,0 @@ -{% extends "base.html" %} -{% block title %}News - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block content %} -

News

- - {% if is_admin %} -
-
-

Post news item

-
-
-
- -
- - -
-
- - -
- -
-
-
- {% endif %} - - {% if items.is_empty() %} -
-
No news
-
News items will appear here.
-
- {% else %} -
- {% for item in items %} -
-
-

{{ item.title }}

- - {{ item.created_at.strftime("%Y-%m-%d %H:%M UTC") }} - {% if is_admin %} -
- - -
- {% endif %} -
-
-
-
{{ item.content }}
-
-
- {% endfor %} -
- {% endif %} -{% endblock %} diff --git a/crates/server/templates/starred.html b/crates/server/templates/starred.html deleted file mode 100644 index ac6e37dc..00000000 --- a/crates/server/templates/starred.html +++ /dev/null @@ -1,102 +0,0 @@ -{% extends "base.html" %} -{% block title %}Starred Jobs - circus{% endblock %} -{% block auth %} - {% if !auth_name.is_empty() %} - {{ auth_name }} -
- -
- {% else %} - Login - {% endif %} -{% endblock %} -{% block breadcrumbs %} - -{% endblock %} -{% block content %} -

Starred Jobs

- - {% if !is_logged_in %} -
-
Login required
-
- Please - login - to view your starred jobs. -
-
- {% else %} - {% if starred_jobs.is_empty() %} -
-
No starred jobs
-
Star jobs from project or build pages to track them here.
-
- {% else %} -
- - - - - - - - - - - - {% for s in starred_jobs %} - - - - - - - - {% endfor %} - -
ProjectJobsetJob NameLatest StatusActions
- {{ s.project_name }} - - {% if let Some(id) = s.jobset_id %} - {{ s.jobset_name }} - {% else %} - - - {% endif %} - {{ s.job_name }} - {{ s.status_text }} - - - {% if let Some(id) = s.latest_build_id %} - View Build - {% endif %} -
-
- {% endif %} - {% endif %} -{% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/crates/server/tests/api_tests.rs b/crates/server/tests/api_tests.rs index bcc39dc6..dca3076d 100644 --- a/crates/server/tests/api_tests.rs +++ b/crates/server/tests/api_tests.rs @@ -60,7 +60,7 @@ fn build_app(pool: circus_common::PgPool) -> axum::Router { cache_public_key: None, oidc_providers: circus_server::routes::oidc::OidcProviders::default(), }; - circus_server::routes::router(state, &config) + circus_server::routes::router(state, &config).unwrap() } #[tokio::test] @@ -87,7 +87,7 @@ async fn test_router_no_duplicate_routes() { oidc_providers: circus_server::routes::oidc::OidcProviders::default(), }; - let _app = circus_server::routes::router(state, &config); + let _app = circus_server::routes::router(state, &config).unwrap(); } fn build_app_with_config( @@ -110,7 +110,7 @@ fn build_app_with_config( cache_public_key: None, oidc_providers: circus_server::routes::oidc::OidcProviders::default(), }; - circus_server::routes::router(state, config) + circus_server::routes::router(state, config).unwrap() } fn build_app_public_reads(pool: circus_common::PgPool) -> axum::Router { diff --git a/crates/server/tests/e2e_test.rs b/crates/server/tests/e2e_test.rs index 5658bbac..cc35f5d3 100644 --- a/crates/server/tests/e2e_test.rs +++ b/crates/server/tests/e2e_test.rs @@ -300,7 +300,7 @@ async fn test_e2e_project_eval_build_flow() { cache_public_key: None, oidc_providers: circus_server::routes::oidc::OidcProviders::default(), }; - let app = circus_server::routes::router(state, &config); + let app = circus_server::routes::router(state, &config).expect("router"); // GET /health for service in [ diff --git a/nix/tests/api-crud.nix b/nix/tests/api-crud.nix index 72f73ddf..4cae9313 100644 --- a/nix/tests/api-crud.nix +++ b/nix/tests/api-crud.nix @@ -574,7 +574,7 @@ testers.runNixOSTest { with subtest("Login with invalid API key shows error"): body = machine.succeed( - "curl -sf -X POST http://127.0.0.1:3000/login " + "curl -sfL http://127.0.0.1:3000/login " "-d 'api_key=circus_invalid_key'" ) assert "Invalid" in body or "invalid" in body or "error" in body.lower(), \ @@ -582,7 +582,7 @@ testers.runNixOSTest { with subtest("Login with empty API key shows error"): body = machine.succeed( - "curl -sf -X POST http://127.0.0.1:3000/login " + "curl -sfL http://127.0.0.1:3000/login " "-d 'api_key='" ) assert "required" in body.lower() or "error" in body.lower() or "Invalid" in body, \ diff --git a/nix/tests/declarative.nix b/nix/tests/declarative.nix index b9005abb..bb5a39e3 100644 --- a/nix/tests/declarative.nix +++ b/nix/tests/declarative.nix @@ -200,55 +200,30 @@ in assert result.strip().startswith("$argon2"), f"Expected argon2 hash, got '{result.strip()[:20]}...'" # DECLARATIVE USER WEB LOGIN - with subtest("Web login with declarative admin user succeeds"): - result = machine.succeed( - "curl -s -w '\\n%{http_code}' " + # Sign-in answers 303 either way, and a failure lands on /login?error=. + def web_login(username, password): + return machine.succeed( + "curl -s -o /dev/null -w '%{http_code} %{redirect_url}' " "-X POST http://127.0.0.1:3000/login " - "-d 'username=decl-admin&password=SecretAdmin123!'" + f"-d 'username={username}&password={password}'" ) - lines = result.strip().split('\n') - code = lines[-1] - assert code in ("200", "302", "303"), f"Expected redirect on login, got {code}" - with subtest("Web login with declarative user (password_file) succeeds"): - result = machine.succeed( - "curl -s -w '\\n%{http_code}' " - "-X POST http://127.0.0.1:3000/login " - "-d 'username=decl-user&password=SecretUser123!'" - ) - lines = result.strip().split('\n') - code = lines[-1] - assert code in ("200", "302", "303"), f"Expected redirect on login, got {code}" - - with subtest("Web login with declarative user2 (password_file) succeeds"): - result = machine.succeed( - "curl -s -w '\\n%{http_code}' " - "-X POST http://127.0.0.1:3000/login " - "-d 'username=decl-user2&password=SecretUser123!'" - ) - lines = result.strip().split('\n') - code = lines[-1] - assert code in ("200", "302", "303"), f"Expected redirect on login, got {code}" + for username, password in [ + ("decl-admin", "SecretAdmin123!"), + ("decl-user", "SecretUser123!"), + ("decl-user2", "SecretUser123!"), + ]: + with subtest(f"Web login with declarative user {username} succeeds"): + result = web_login(username, password) + assert result.startswith("303 ") and "error=" not in result, f"Expected login redirect, got {result}" with subtest("Web login with wrong password fails"): - result = machine.succeed( - "curl -s -w '\\n%{http_code}' " - "-X POST http://127.0.0.1:3000/login " - "-d 'username=decl-admin&password=wrongpassword'" - ) - lines = result.strip().split('\n') - code = lines[-1] - assert code in ("401",), f"Expected 401 for wrong password, got {code}" + result = web_login("decl-admin", "wrongpassword") + assert "error=invalid-password" in result, f"Expected invalid-password redirect, got {result}" with subtest("Web login with disabled user fails"): - result = machine.succeed( - "curl -s -w '\\n%{http_code}' " - "-X POST http://127.0.0.1:3000/login " - "-d 'username=decl-disabled&password=DisabledPass123!'" - ) - lines = result.strip().split('\n') - code = lines[-1] - assert code in ("401", "403"), f"Expected login failure for disabled user, got {code}" + result = web_login("decl-disabled", "DisabledPass123!") + assert "error=" in result, f"Expected login failure for disabled user, got {result}" # DECLARATIVE API KEYS with subtest("Declarative API keys are created"):