feat: add swimlanes

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-07 22:53:48 +01:00
parent 9fe1630986
commit 45353089c2
51 changed files with 3845 additions and 147 deletions

View File

@@ -18,6 +18,12 @@ pub fn router() -> Router<AppState> {
.route("/orgs", post(create_org_submit))
.route("/orgs/{org}/projects", get(projects_list))
.route("/orgs/{org}/projects/{project}", get(project_detail))
.route(
"/orgs/{org}/projects/{project}/releases/{slug}",
get(artifact_detail),
)
.route("/orgs/{org}/releases", get(releases_page))
.route("/orgs/{org}/destinations", get(destinations_page))
.route("/orgs/{org}/usage", get(usage))
.route(
"/orgs/{org}/settings/members",
@@ -97,6 +103,7 @@ async fn dashboard(
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
active_tab => "dashboard",
},
)
.map_err(|e| {
@@ -111,40 +118,16 @@ async fn dashboard(
return Ok(Html(html).into_response());
}
// Fetch recent activity: for each org, get projects, then artifacts
let mut recent_activity = Vec::new();
for org in orgs {
let projects = state
.platform_client
.list_projects(&session.access_token, &org.name)
.await
.unwrap_or_default();
// Fetch recent releases for the first org to show the pipeline on dashboard
let first_org = &orgs[0];
let projects = state
.platform_client
.list_projects(&session.access_token, &first_org.name)
.await
.unwrap_or_default();
for project in projects.iter().take(5) {
let artifacts = state
.platform_client
.list_artifacts(&session.access_token, &org.name, project)
.await
.unwrap_or_default();
for artifact in artifacts {
recent_activity.push(context! {
org_name => org.name,
project_name => project,
slug => artifact.slug,
title => artifact.context.title,
description => artifact.context.description,
created_at => artifact.created_at,
});
if recent_activity.len() >= 10 {
break;
}
}
if recent_activity.len() >= 10 {
break;
}
}
}
let items = fetch_org_artifacts(&state, &session.access_token, &first_org.name, &projects).await;
let data = build_timeline(items, &first_org.name);
let html = state
.templates
@@ -155,8 +138,11 @@ async fn dashboard(
description => "Your Forage dashboard",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &first_org.name,
orgs => orgs_context(orgs),
recent_activity => recent_activity,
timeline => data.timeline,
lanes => data.lanes,
active_tab => "dashboard",
},
)
.map_err(|e| {
@@ -205,6 +191,7 @@ async fn create_org_submit(
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
active_tab => "dashboard",
error => "Invalid organisation name. Use lowercase letters, numbers, and hyphens only.",
},
)
@@ -248,6 +235,7 @@ async fn create_org_submit(
description => "Create your first organisation",
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
active_tab => "dashboard",
error => "Could not create organisation. Please try again.",
},
)
@@ -289,6 +277,7 @@ async fn projects_list(
orgs => orgs_context(orgs),
org_name => &org,
projects => projects,
active_tab => "projects",
},
)
.map_err(|e| {
@@ -329,6 +318,15 @@ async fn project_detail(
.await
.unwrap_or_default();
let items: Vec<ArtifactWithProject> = artifacts
.into_iter()
.map(|a| ArtifactWithProject {
artifact: a,
project_name: project.clone(),
})
.collect();
let data = build_timeline(items, &org);
let html = state
.templates
.render(
@@ -342,23 +340,99 @@ async fn project_detail(
orgs => orgs_context(orgs),
org_name => &org,
project_name => &project,
artifacts => artifacts.iter().map(|a| {
context! {
slug => a.slug,
title => a.context.title,
description => a.context.description,
created_at => a.created_at,
source_user => a.source.as_ref().and_then(|s| s.user.clone()),
source_type => a.source.as_ref().and_then(|s| s.source_type.clone()),
run_url => a.source.as_ref().and_then(|s| s.run_url.clone()),
commit_sha => a.git_ref.as_ref().map(|r| r.commit_sha.clone()),
branch => a.git_ref.as_ref().and_then(|r| r.branch.clone()),
version => a.git_ref.as_ref().and_then(|r| r.version.clone()),
destinations => a.destinations.iter().map(|d| {
context! { name => d.name, environment => d.environment }
}).collect::<Vec<_>>(),
}
}).collect::<Vec<_>>(),
active_tab => "projects",
timeline => data.timeline,
lanes => data.lanes,
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Artifact detail ─────────────────────────────────────────────────
async fn artifact_detail(
State(state): State<AppState>,
session: Session,
Path((org, project, slug)): Path<(String, String, String)>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
if !validate_slug(&project) {
return Err(error_page(
&state,
StatusCode::BAD_REQUEST,
"Invalid request",
"Invalid project name.",
));
}
let artifact = state
.platform_client
.get_artifact_by_slug(&session.access_token, &slug)
.await
.map_err(|e| match e {
forage_core::platform::PlatformError::NotFound(_) => error_page(
&state,
StatusCode::NOT_FOUND,
"Not found",
"This release could not be found.",
),
other => {
tracing::error!("failed to fetch artifact: {other}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
}
})?;
let html = state
.templates
.render(
"pages/artifact_detail.html.jinja",
context! {
title => format!("{} - {} - {} - Forage", artifact.context.title, project, org),
description => artifact.context.description,
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
project_name => &project,
active_tab => "projects",
artifact => context! {
slug => artifact.slug,
title => artifact.context.title,
description => artifact.context.description,
web => artifact.context.web,
pr => artifact.context.pr,
created_at => artifact.created_at,
source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
source_email => artifact.source.as_ref().and_then(|s| s.email.clone()),
source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
run_url => artifact.source.as_ref().and_then(|s| s.run_url.clone()),
commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
commit_message => artifact.git_ref.as_ref().and_then(|r| r.commit_message.clone()),
version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
repo_url => artifact.git_ref.as_ref().and_then(|r| r.repo_url.clone()),
destinations => artifact.destinations.iter().map(|d| {
context! { name => d.name, environment => d.environment }
}).collect::<Vec<_>>(),
},
},
)
.map_err(|e| {
@@ -404,6 +478,259 @@ async fn usage(
org_name => &org,
role => &current_org_data.role,
project_count => projects.len(),
active_tab => "usage",
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Timeline builder (shared between dashboard, project detail, releases) ───
struct ArtifactWithProject {
artifact: forage_core::platform::Artifact,
project_name: String,
}
struct TimelineData {
timeline: Vec<minijinja::Value>,
lanes: Vec<minijinja::Value>,
}
fn build_timeline(items: Vec<ArtifactWithProject>, org_name: &str) -> TimelineData {
struct RawRelease {
value: minijinja::Value,
has_dests: bool,
}
let mut raw_releases: Vec<RawRelease> = Vec::new();
let mut env_set = std::collections::BTreeSet::new();
for item in items {
let artifact = item.artifact;
let project = &item.project_name;
let mut release_envs = Vec::new();
let dests: Vec<minijinja::Value> = artifact
.destinations
.iter()
.map(|d| {
env_set.insert(d.environment.clone());
release_envs.push(d.environment.clone());
context! {
name => d.name,
environment => d.environment,
type_name => d.type_name,
type_version => d.type_version,
}
})
.collect();
let has_dests = !dests.is_empty();
let dest_envs_str = release_envs.join(",");
raw_releases.push(RawRelease {
value: context! {
slug => artifact.slug,
title => artifact.context.title,
description => artifact.context.description,
project_name => project,
org_name => org_name,
created_at => artifact.created_at,
commit_sha => artifact.git_ref.as_ref().map(|r| r.commit_sha.clone()),
branch => artifact.git_ref.as_ref().and_then(|r| r.branch.clone()),
version => artifact.git_ref.as_ref().and_then(|r| r.version.clone()),
source_user => artifact.source.as_ref().and_then(|s| s.user.clone()),
source_type => artifact.source.as_ref().and_then(|s| s.source_type.clone()),
destinations => dests,
dest_envs => dest_envs_str,
},
has_dests,
});
}
let lanes: Vec<minijinja::Value> = env_set
.into_iter()
.map(|env| context! { name => env })
.collect();
let mut timeline_items: Vec<minijinja::Value> = Vec::new();
let mut hidden_buf: Vec<minijinja::Value> = Vec::new();
for raw in raw_releases {
if raw.has_dests {
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
timeline_items.push(context! {
kind => "hidden",
count => count,
releases => std::mem::take(&mut hidden_buf),
});
}
timeline_items.push(context! {
kind => "release",
release => raw.value,
});
} else {
hidden_buf.push(raw.value);
}
}
if !hidden_buf.is_empty() {
let count = hidden_buf.len();
timeline_items.push(context! {
kind => "hidden",
count => count,
releases => std::mem::take(&mut hidden_buf),
});
}
TimelineData {
timeline: timeline_items,
lanes,
}
}
/// Fetch all artifacts across projects and return as ArtifactWithProject list.
async fn fetch_org_artifacts(
state: &AppState,
access_token: &str,
org: &str,
projects: &[String],
) -> Vec<ArtifactWithProject> {
let mut items = Vec::new();
for project in projects {
let artifacts = state
.platform_client
.list_artifacts(access_token, org, project)
.await
.unwrap_or_default();
for artifact in artifacts {
items.push(ArtifactWithProject {
artifact,
project_name: project.clone(),
});
}
}
items
}
// ─── Releases (Up-inspired pipeline) ─────────────────────────────────
async fn releases_page(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
require_org_membership(&state, orgs, &org)?;
let projects = state
.platform_client
.list_projects(&session.access_token, &org)
.await
.unwrap_or_default();
let items = fetch_org_artifacts(&state, &session.access_token, &org, &projects).await;
let data = build_timeline(items, &org);
let html = state
.templates
.render(
"pages/releases.html.jinja",
context! {
title => format!("Releases - {org} - Forage"),
description => format!("Deployment pipeline for {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
timeline => data.timeline,
lanes => data.lanes,
active_tab => "releases",
},
)
.map_err(|e| {
tracing::error!("template error: {e:#}");
error_page(
&state,
StatusCode::INTERNAL_SERVER_ERROR,
"Something went wrong",
"Please try again.",
)
})?;
Ok(Html(html).into_response())
}
// ─── Destinations ────────────────────────────────────────────────────
async fn destinations_page(
State(state): State<AppState>,
session: Session,
Path(org): Path<String>,
) -> Result<Response, Response> {
let orgs = &session.user.orgs;
let current_org = require_org_membership(&state, orgs, &org)?;
let is_admin = current_org.role == "owner" || current_org.role == "admin";
let projects = state
.platform_client
.list_projects(&session.access_token, &org)
.await
.unwrap_or_default();
// Aggregate unique destinations from artifacts
let mut destinations = Vec::new();
let mut seen = std::collections::HashSet::new();
for project in &projects {
let artifacts = state
.platform_client
.list_artifacts(&session.access_token, &org, project)
.await
.unwrap_or_default();
for artifact in &artifacts {
for dest in &artifact.destinations {
let key = (dest.name.clone(), dest.environment.clone());
if seen.insert(key) {
destinations.push(context! {
name => dest.name,
environment => dest.environment,
project_name => project,
artifact_title => artifact.context.title,
artifact_slug => artifact.slug,
created_at => artifact.created_at,
});
}
}
}
}
let html = state
.templates
.render(
"pages/destinations.html.jinja",
context! {
title => format!("Destinations - {org} - Forage"),
description => format!("Deployment destinations for {org}"),
user => context! { username => session.user.username },
csrf_token => &session.csrf_token,
current_org => &org,
orgs => orgs_context(orgs),
org_name => &org,
destinations => destinations,
is_admin => is_admin,
active_tab => "destinations",
},
)
.map_err(|e| {
@@ -450,6 +777,7 @@ async fn members_page(
orgs => orgs_context(orgs),
org_name => &org,
is_admin => is_admin,
active_tab => "members",
members => members.iter().map(|m| context! {
user_id => m.user_id,
username => m.username,